feat: 支持 v2 插件、独立前端和签名制品发布 (#130)

* feat: 支持 v2 插件、原生扩展与签名发布

* fix: 修复测试

* feat: 增强宿主 SDK、运行观测与制品维护能力

* feat: 增强文件传输、配置观测与本地开发能力

* feat: 支持 SSE 实时事件流并完善开发脚手架

* feat: 完善长连接鉴权复核与关闭回收

* feat: 增强生命周期与任务关闭可靠性

* feat: 收紧健康检查契约并补齐前端与真实网络验收

* feat: 增加SDK溯源与离线更新工具并验证跨版本兼容

* feat: 增加持久化业务示例与受控 Python 交付

* feat: 增加统一运行诊断与多worker状态观测

* feat: 增加固定目标发布等待与自动验收

* fix: 修复多环境插件部署前缀与代理配置不一致

* fix: 修复测试
This commit is contained in:
insistence
2026-10-09 23:49:45 +08:00
committed by GitHub
parent a89aec54aa
commit 4bd83bc7cd
287 changed files with 41945 additions and 331 deletions
@@ -0,0 +1,508 @@
import { createPluginClient } from '@ruoyi/plugin-bridge'
import './style.css'
const element = (id) => document.getElementById(id)
const filters = [...element('status-filters').querySelectorAll('button')]
const state = {
items: [],
total: 0,
page: 1,
pageSize: 10,
status: '',
connected: false,
ended: false,
canWrite: false,
loading: true,
loaded: false,
listError: false,
mutating: false,
editorLoading: false,
editorFailed: false,
editorTask: null,
deleteTask: null,
}
let client
let context = {}
let listController
let detailController
let listGeneration = 0
let detailGeneration = 0
function showNotice(message, error = false) {
element('notice').textContent = message
element('notice').classList.toggle('is-error', error)
}
function formatTime(value) {
const date = new Date(value)
if (!value || Number.isNaN(date.getTime())) return '—'
try {
return new Intl.DateTimeFormat(context.language || 'zh-CN', {
timeZone: context.timeZone || 'UTC',
dateStyle: 'short',
timeStyle: 'short',
}).format(date)
} catch {
return date.toISOString().slice(0, 16).replace('T', ' ') + ' UTC'
}
}
function setEditorControls() {
const writable = state.connected && !state.ended && state.canWrite
const blocked = state.editorLoading || state.editorFailed || state.mutating
element('task-fields').disabled = blocked
element('task-title').readOnly = !writable
element('task-description').readOnly = !writable
element('task-status').disabled = !writable
element('task-priority').disabled = !writable
element('form-read-only').hidden = writable || state.editorLoading
element('save-task').hidden = !writable
element('save-task').disabled = !writable || blocked
element('close-dialog').disabled = state.mutating
element('cancel-dialog').disabled = state.mutating
element('cancel-dialog').textContent = writable ? '取消' : '关闭'
element('dialog-heading').textContent = writable
? state.editorTask
? '编辑任务'
: '新增任务'
: '查看任务'
element('save-task').textContent = state.mutating
? '正在保存…'
: state.editorTask
? '保存修改'
: '新增任务'
}
function updateControls() {
const available = state.connected && !state.ended
const idle = available && !state.loading && !state.mutating
element('access-mode').textContent = state.ended
? '会话已结束'
: !state.connected
? '未连接'
: state.canWrite
? '可编辑 · 共享任务'
: '只读 · 共享任务'
element('read-only-notice').hidden = !available || state.canWrite
element('create-task').disabled = !idle || !state.canWrite
element('empty-create').disabled = !idle || !state.canWrite
element('empty-create').hidden = !state.canWrite || Boolean(state.status)
element('refresh').disabled = !idle
element('retry').disabled = !idle
element('page-size').disabled = !idle
element('previous-page').disabled = !idle || state.page <= 1
element('next-page').disabled = !idle || state.page * state.pageSize >= state.total
for (const button of filters) {
button.disabled = !idle
button.setAttribute('aria-pressed', String(button.dataset.status === state.status))
}
element('list-region').setAttribute('aria-busy', String(state.loading))
element('loading-state').hidden = !state.loading
element('loading-message').textContent = state.connected ? '正在加载任务…' : '正在连接工作区…'
element('task-table').hidden = !state.items.length
element('empty-state').hidden =
state.loading || state.listError || !state.loaded || Boolean(state.items.length)
element('empty-heading').textContent = state.status ? '没有符合条件的任务' : '还没有任务'
element('empty-description').textContent = state.status
? '试试其他状态,或查看全部任务。'
: state.canWrite
? '新增第一条任务,开始记录团队待办。'
: '当前还没有共享任务,请稍后刷新查看。'
element('clear-filter').hidden = !state.status
element('list-heading').textContent =
state.status === 'todo' ? '待完成任务' : state.status === 'done' ? '已完成任务' : '全部任务'
element('task-count').textContent = state.loaded ? `${state.total} 条` : '等待加载'
const start = state.total ? (state.page - 1) * state.pageSize + 1 : 0
const end = Math.min(state.page * state.pageSize, state.total)
element('page-summary').textContent = state.loaded
? `显示 ${start}–${end} 条,共 ${state.total} 条任务`
: '尚未加载任务'
element('page-number').textContent = state.loaded
? `${state.page} / ${Math.max(1, Math.ceil(state.total / state.pageSize))}`
: '—'
for (const button of element('task-rows').querySelectorAll('button')) button.disabled = !idle
element('cancel-delete').disabled = state.mutating
element('confirm-delete').disabled = !available || !state.canWrite || state.mutating
element('confirm-delete').textContent = state.mutating ? '正在删除…' : '确认删除'
setEditorControls()
}
function node(tag, className, text) {
const result = document.createElement(tag)
if (className) result.className = className
if (text !== undefined) result.textContent = text
return result
}
// 用户内容始终写入文本节点,任务标题和说明不会作为 HTML 执行。
function renderRows() {
const rows = state.items.map((task) => {
const row = node('tr', task.priority === 'high' ? 'is-priority' : '')
const titleCell = node('td')
const title = node('button', 'task-title-button', task.title)
title.type = 'button'
title.addEventListener('click', () => openEditor(task.id))
titleCell.append(title)
if (task.description) titleCell.append(node('p', 'task-description', task.description))
titleCell.append(node('span', 'task-id', `#${task.id}`))
const status = node('td')
status.dataset.label = '状态'
status.append(
node(
'span',
`status-badge status-${task.status}`,
task.status === 'done' ? '已完成' : '待完成'
)
)
const priority = node('td')
priority.dataset.label = '优先级'
priority.append(
node(
'span',
task.priority === 'high' ? 'priority-label priority-high' : 'priority-label',
task.priority === 'high' ? '优先处理' : '普通'
)
)
const updated = node('td')
updated.dataset.label = '更新时间'
updated.append(node('span', 'updated-time', formatTime(task.updatedAt)))
const actions = node('td')
const group = node('div', 'row-actions')
if (state.canWrite) {
const edit = node('button', 'row-button', '编辑')
edit.type = 'button'
edit.setAttribute('aria-label', `编辑任务:${task.title}`)
edit.addEventListener('click', () => openEditor(task.id))
const remove = node('button', 'row-button row-delete', '删除')
remove.type = 'button'
remove.setAttribute('aria-label', `删除任务:${task.title}`)
remove.addEventListener('click', () => openDelete(task))
group.append(edit, remove)
} else group.append(node('span', 'read-only-label', '仅查看'))
actions.append(group)
row.append(titleCell, status, priority, updated, actions)
return row
})
element('task-rows').replaceChildren(...rows)
}
async function loadTasks({ reloadInfo = false } = {}) {
if (!state.connected || state.ended) return
listController?.abort()
const controller = new AbortController()
listController = controller
const generation = ++listGeneration
state.loading = true
state.listError = false
element('list-error').hidden = true
updateControls()
try {
if (reloadInfo) {
const info = await client.request(
{ method: 'GET', path: 'info' },
{ signal: controller.signal }
)
if (generation !== listGeneration || state.ended) return
state.canWrite = info.canWrite === true
renderRows()
updateControls()
}
const params = { page: state.page, pageSize: state.pageSize }
if (state.status) params.status = state.status
const result = await client.request(
{ method: 'GET', path: 'tasks', params },
{ signal: controller.signal }
)
if (generation !== listGeneration || state.ended) return
state.canWrite = result.canWrite === true
state.total = result.total
const lastPage = Math.max(1, Math.ceil(result.total / state.pageSize))
if (state.page > lastPage) {
state.page = lastPage
return await loadTasks()
}
state.items = result.items
state.loaded = true
renderRows()
} catch (error) {
if (controller.signal.aborted || generation !== listGeneration || state.ended) return
state.listError = true
element('list-error-message').textContent = `任务加载失败。${error.message || '请稍后重试。'}`
element('list-error').hidden = false
} finally {
if (generation === listGeneration) {
state.loading = false
listController = null
updateControls()
}
}
}
function updateCounts() {
element('title-count').textContent = `${element('task-title').value.length} / 120`
element('description-count').textContent = `${element('task-description').value.length} / 500`
element('task-title').setCustomValidity('')
}
function fillEditor(task = {}) {
element('task-title').value = task.title || ''
element('task-description').value = task.description || ''
element('task-status').value = task.status || 'todo'
element('task-priority').value = task.priority || 'normal'
element('task-timestamps').hidden = !task.id
element('task-timestamps').textContent = task.id
? `创建于 ${formatTime(task.createdAt)} · 更新于 ${formatTime(task.updatedAt)}`
: ''
updateCounts()
}
async function openEditor(id) {
if (!state.connected || state.ended || state.mutating || (!id && !state.canWrite)) return
detailController?.abort()
const generation = ++detailGeneration
state.editorTask = id ? { id } : null
state.editorLoading = Boolean(id)
state.editorFailed = false
element('dialog-kicker').textContent = id ? `共享任务 #${id}` : '新任务'
element('form-error').hidden = true
element('save-state').textContent = ''
element('detail-loading').hidden = !id
fillEditor()
setEditorControls()
element('task-dialog').showModal()
if (!id) {
element('task-title').focus()
return
}
const controller = new AbortController()
detailController = controller
try {
const task = await client.request(
{ method: 'GET', path: `tasks/${id}` },
{ signal: controller.signal }
)
if (controller.signal.aborted || generation !== detailGeneration || state.ended) return
state.editorTask = task
fillEditor(task)
} catch (error) {
if (controller.signal.aborted || generation !== detailGeneration || state.ended) return
state.editorFailed = true
element('form-error').textContent = `任务详情加载失败。${error.message || '请关闭后重试。'}`
element('form-error').hidden = false
element('form-error').focus()
} finally {
if (generation === detailGeneration) {
state.editorLoading = false
element('detail-loading').hidden = true
setEditorControls()
}
}
}
function closeEditor() {
if (state.mutating) return
detailController?.abort()
detailGeneration++
element('task-dialog').close()
}
async function checkWriteAccess() {
try {
const info = await client.request({ method: 'GET', path: 'info' })
if (!state.ended) {
state.canWrite = info.canWrite === true
renderRows()
}
} catch {
/* 保存原始错误;后续刷新仍可重新读取权限。 */
}
}
async function saveTask(event) {
event.preventDefault()
if (state.mutating || state.editorLoading || state.editorFailed || !state.canWrite || state.ended)
return
const title = element('task-title').value.trim()
if (!title) element('task-title').setCustomValidity('请输入任务标题,不能只包含空格。')
if (!element('task-form').reportValidity()) return
const data = {
title,
description: element('task-description').value,
status: element('task-status').value,
priority: element('task-priority').value,
}
const id = state.editorTask?.id
state.mutating = true
element('form-error').hidden = true
element('save-state').textContent = '正在保存,请稍候…'
updateControls()
let saved = false
try {
await client.request({
method: id ? 'PUT' : 'POST',
path: id ? `tasks/${id}` : 'tasks',
data,
})
if (state.ended) return
saved = true
element('task-dialog').close()
const filteredOut = state.status && state.status !== data.status
showNotice(
`${id ? '任务已更新。' : '任务已新增。'}${filteredOut ? '该任务不在当前状态筛选中,可切换到“全部”查看。' : ''}`
)
if (!id) state.page = 1
} catch (error) {
if (state.ended) return
element('form-error').textContent =
`保存失败,填写内容已保留。${error.message || '请稍后重试。'}`
element('form-error').hidden = false
element('form-error').focus()
await checkWriteAccess()
} finally {
state.mutating = false
element('save-state').textContent = ''
updateControls()
}
if (saved) await loadTasks()
}
function openDelete(task) {
if (!state.canWrite || state.mutating || state.ended) return
state.deleteTask = task
element('delete-title').textContent = task.title
element('delete-error').hidden = true
element('delete-dialog').showModal()
element('cancel-delete').focus()
}
async function deleteTask() {
if (!state.deleteTask || !state.canWrite || state.mutating || state.ended) return
state.mutating = true
element('delete-error').hidden = true
updateControls()
let deleted = false
try {
await client.request({
method: 'DELETE',
path: `tasks/${state.deleteTask.id}`,
})
if (state.ended) return
deleted = true
element('delete-dialog').close()
showNotice('任务已删除。')
} catch (error) {
if (state.ended) return
element('delete-error').textContent = `删除失败。${error.message || '请稍后重试。'}`
element('delete-error').hidden = false
element('delete-error').focus()
await checkWriteAccess()
} finally {
state.mutating = false
updateControls()
}
if (deleted) await loadTasks()
}
function setStatus(status) {
state.status = status
state.page = 1
reloadQuery()
}
function reloadQuery() {
// 切换查询条件后清除旧结果,避免把上一种筛选误认为当前列表。
state.items = []
state.loaded = false
renderRows()
loadTasks()
}
function applyContext(next) {
context = { ...context, ...next }
document.documentElement.dataset.theme = context.theme?.mode === 'dark' ? 'dark' : 'light'
document.documentElement.lang = context.language || 'zh-CN'
renderRows()
updateControls()
}
function endSession() {
state.ended = true
state.canWrite = false
state.loading = false
listController?.abort()
detailController?.abort()
listGeneration++
detailGeneration++
element('task-dialog').close()
element('delete-dialog').close()
showNotice('会话已结束,请重新登录管理平台后打开任务清单。', true)
renderRows()
updateControls()
}
for (const button of filters)
button.addEventListener('click', () => setStatus(button.dataset.status))
element('clear-filter').addEventListener('click', () => setStatus(''))
for (const id of ['create-task', 'empty-create'])
element(id).addEventListener('click', () => openEditor())
for (const id of ['refresh', 'retry'])
element(id).addEventListener('click', () => loadTasks({ reloadInfo: true }))
element('previous-page').addEventListener('click', () => {
state.page--
reloadQuery()
})
element('next-page').addEventListener('click', () => {
state.page++
reloadQuery()
})
element('page-size').addEventListener('change', () => {
state.pageSize = Number(element('page-size').value)
state.page = 1
reloadQuery()
})
for (const id of ['close-dialog', 'cancel-dialog'])
element(id).addEventListener('click', closeEditor)
element('task-dialog').addEventListener('cancel', (event) => {
event.preventDefault()
closeEditor()
})
element('delete-dialog').addEventListener('cancel', (event) => {
if (state.mutating) event.preventDefault()
})
element('cancel-delete').addEventListener('click', () => {
if (!state.mutating) element('delete-dialog').close()
})
element('confirm-delete').addEventListener('click', deleteTask)
element('task-form').addEventListener('submit', saveTask)
for (const id of ['task-title', 'task-description'])
element(id).addEventListener('input', updateCounts)
async function connect() {
try {
const config = JSON.parse(element('ruoyi-plugin-config')?.textContent || '{}')
if (config.pluginId !== 'task_demo') throw new Error('请从管理平台或本地模拟宿主打开任务清单。')
client = createPluginClient({ pluginId: config.pluginId })
client.subscribe(({ type, payload }) => {
if (type === 'preferences') applyContext(payload)
if (type === 'refresh') loadTasks({ reloadInfo: true })
if (type === 'logout') endSession()
})
applyContext(await client.ready)
state.connected = true
await loadTasks({ reloadInfo: true })
} catch (error) {
state.loading = false
showNotice(error.message || '连接失败,请重新打开任务清单。', true)
updateControls()
}
}
connect()
window.addEventListener(
'pagehide',
() => {
listController?.abort()
detailController?.abort()
client?.destroy()
},
{ once: true }
)
@@ -0,0 +1,952 @@
:root {
font-family: 'Segoe UI', 'Microsoft YaHei', 'PingFang SC', sans-serif;
color: var(--ink);
background: var(--canvas);
color-scheme: light;
--canvas: #f3f6f7;
--surface: #ffffff;
--surface-subtle: #f7f9fa;
--ink: #172b36;
--muted: #516574;
--border: #d6e0e4;
--control-border: #879aa4;
--accent: #176b62;
--accent-hover: #10564e;
--on-accent: #ffffff;
--accent-soft: #e6f3f0;
--pending: #805300;
--pending-soft: #fff3d0;
--complete: #136849;
--complete-soft: #e0f3e9;
--danger: #b42318;
--danger-hover: #8f1d14;
--danger-soft: #fff0ee;
--on-danger: #ffffff;
--scrim: rgb(10 25 35 / 48%);
--shadow: 0 18px 60px rgb(18 41 53 / 18%);
--mono: 'Cascadia Mono', Consolas, monospace;
}
:root[data-theme='dark'] {
color-scheme: dark;
--canvas: #111c25;
--surface: #172631;
--surface-subtle: #1c2e3a;
--ink: #e8f0f5;
--muted: #aec0c9;
--border: #344854;
--control-border: #6c8390;
--accent: #79d8c6;
--accent-hover: #a5e7db;
--on-accent: #0d3028;
--accent-soft: #193c38;
--pending: #f4cd79;
--pending-soft: #43391e;
--complete: #8ce0b8;
--complete-soft: #1e3d32;
--danger: #ffad9f;
--danger-hover: #ffccbf;
--danger-soft: #432a29;
--on-danger: #431b15;
--scrim: rgb(3 10 15 / 68%);
--shadow: 0 18px 60px rgb(0 0 0 / 35%);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-size: 14px;
line-height: 1.6;
}
button,
input,
textarea,
select {
font: inherit;
}
button,
select {
cursor: pointer;
touch-action: manipulation;
}
button {
color: inherit;
}
button:disabled,
select:disabled {
cursor: not-allowed;
opacity: 0.55;
}
button,
input,
textarea,
select {
transition:
background-color 160ms ease,
border-color 160ms ease,
color 160ms ease;
}
:focus-visible {
outline: 3px solid var(--accent);
outline-offset: 3px;
}
[hidden] {
display: none !important;
}
h1,
h2,
h3,
p {
margin: 0;
}
svg {
display: block;
flex: 0 0 auto;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.skip-link {
position: fixed;
top: 8px;
left: 16px;
z-index: 5;
padding: 10px 16px;
transform: translateY(-180%);
color: var(--on-accent);
background: var(--accent);
border-radius: 6px;
}
.skip-link:focus {
transform: translateY(0);
}
.workspace {
width: min(100%, 1280px);
margin: 0 auto;
padding: 32px clamp(16px, 3vw, 40px) 40px;
}
.page-header {
display: flex;
gap: 24px;
justify-content: space-between;
align-items: center;
margin-bottom: 28px;
}
.page-heading {
display: flex;
align-items: flex-start;
gap: 16px;
}
.page-mark {
display: grid;
place-items: center;
width: 48px;
height: 48px;
border-radius: 12px;
background: var(--accent-soft);
color: var(--accent);
margin-top: 6px;
}
.page-mark svg {
width: 28px;
height: 28px;
}
.eyebrow {
margin-bottom: 4px;
color: var(--muted);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
}
h1 {
font-family: 'Microsoft YaHei UI', 'PingFang SC', sans-serif;
font-size: 28px;
line-height: 1.35;
font-weight: 650;
letter-spacing: 0.01em;
}
.page-description {
margin-top: 8px;
color: var(--muted);
}
.header-actions {
display: flex;
align-items: center;
gap: 18px;
flex-shrink: 0;
}
.access-mode {
color: var(--muted);
font-size: 13px;
white-space: nowrap;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
min-height: 42px;
padding: 9px 16px;
border: 1px solid transparent;
border-radius: 7px;
font-weight: 600;
white-space: nowrap;
}
.button-primary {
color: var(--on-accent);
background: var(--accent);
border-color: var(--accent);
}
.button-primary:not(:disabled):hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.button-secondary {
color: var(--ink);
background: var(--surface);
border-color: var(--control-border);
}
.button-secondary:not(:disabled):hover {
background: var(--surface-subtle);
border-color: var(--accent);
}
.button-danger {
background: var(--danger);
color: var(--on-danger);
}
.button-danger:not(:disabled):hover {
background: var(--danger-hover);
}
.icon {
width: 18px;
height: 18px;
}
.read-only-notice {
margin-bottom: 16px;
padding: 12px 16px;
color: var(--pending);
background: var(--pending-soft);
border-radius: 7px;
}
.notice {
margin-bottom: 16px;
padding: 12px 16px;
color: var(--accent);
background: var(--accent-soft);
border-radius: 7px;
overflow-wrap: anywhere;
}
.notice:empty {
display: none;
}
.notice.is-error {
color: var(--danger);
background: var(--danger-soft);
}
.list-panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.list-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 22px 24px 16px;
}
.list-heading-row {
display: flex;
align-items: center;
gap: 10px;
}
.list-heading-row h2 {
font-size: 18px;
font-weight: 650;
}
.task-count {
font-family: var(--mono);
color: var(--muted);
font-size: 12px;
padding: 2px 7px;
background: var(--surface-subtle);
border: 1px solid var(--border);
border-radius: 5px;
white-space: nowrap;
}
.filter-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0 24px 16px;
}
.status-filters {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 4px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface-subtle);
}
.filter-button {
min-height: 36px;
padding: 6px 17px;
border: 1px solid transparent;
border-radius: 5px;
background: transparent;
color: var(--muted);
white-space: nowrap;
}
.filter-button[aria-pressed='true'] {
color: var(--accent);
background: var(--surface);
border-color: var(--border);
font-weight: 650;
}
.filter-button:not(:disabled):hover {
color: var(--accent);
}
.list-hint {
color: var(--muted);
font-size: 12px;
}
.list-region {
min-height: 240px;
position: relative;
}
.loading-state {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
padding: 30px 20px;
color: var(--muted);
}
.loading-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
animation: loading-pulse 1s ease-in-out infinite alternate;
}
@keyframes loading-pulse {
from {
opacity: 0.3;
}
to {
opacity: 1;
}
}
.list-region[aria-busy='true'] .task-table {
opacity: 0.55;
}
.task-table {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
text-align: left;
}
.task-table th {
padding: 12px 16px;
background: var(--surface-subtle);
color: var(--muted);
font-size: 12px;
font-weight: 600;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
.task-table th:first-child {
width: 43%;
padding-left: 24px;
}
.task-table th:nth-child(2) {
width: 12%;
}
.task-table th:nth-child(3) {
width: 12%;
}
.task-table th:nth-child(4) {
width: 19%;
}
.task-table th:nth-child(5) {
width: 14%;
}
.task-table td {
padding: 18px 16px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.task-table td:first-child {
padding-left: 24px;
position: relative;
}
.task-table tr:last-child td {
border-bottom: 0;
}
.task-table tbody tr:hover {
background: var(--surface-subtle);
}
.task-table .is-priority td:first-child::before {
content: '';
position: absolute;
left: 0;
top: 18px;
bottom: 18px;
width: 3px;
border-radius: 0 3px 3px 0;
background: var(--pending);
}
.task-title-button {
display: block;
max-width: 100%;
padding: 0;
border: 0;
background: transparent;
color: var(--ink);
font-size: 15px;
font-weight: 600;
line-height: 1.6;
text-align: left;
overflow-wrap: anywhere;
}
.task-title-button:not(:disabled):hover {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 3px;
}
.task-description {
margin-top: 4px;
color: var(--muted);
font-size: 13px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
overflow-wrap: anywhere;
}
.task-id {
display: inline-block;
margin-top: 6px;
font: 11px/1.5 var(--mono);
color: var(--muted);
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
font-size: 12px;
font-weight: 600;
border-radius: 5px;
white-space: nowrap;
}
.status-badge::before {
content: '';
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
}
.status-todo {
color: var(--pending);
background: var(--pending-soft);
}
.status-done {
color: var(--complete);
background: var(--complete-soft);
}
.priority-label {
color: var(--muted);
font-size: 13px;
white-space: nowrap;
}
.priority-high {
color: var(--pending);
font-weight: 600;
}
.updated-time {
color: var(--muted);
font: 12px/1.7 var(--mono);
overflow-wrap: anywhere;
}
.row-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.row-button {
min-height: 32px;
padding: 3px 0;
color: var(--accent);
background: transparent;
border: 0;
font-size: 13px;
font-weight: 600;
}
.row-button:not(:disabled):hover {
text-decoration: underline;
text-underline-offset: 3px;
}
.row-delete {
color: var(--danger);
}
.actions-heading {
text-align: right;
}
.read-only-label {
font-size: 12px;
color: var(--muted);
white-space: nowrap;
}
.empty-state {
display: grid;
justify-items: center;
gap: 10px;
padding: 42px 24px 52px;
text-align: center;
}
.empty-icon {
width: 56px;
height: 56px;
color: var(--muted);
margin-bottom: 4px;
}
.empty-state h3 {
font-size: 17px;
}
.empty-state p {
color: var(--muted);
margin-bottom: 8px;
}
.error-panel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin: 0 24px 16px;
padding: 14px 16px;
border: 1px solid var(--danger);
border-radius: 7px;
color: var(--danger);
background: var(--danger-soft);
overflow-wrap: anywhere;
}
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 16px 24px;
border-top: 1px solid var(--border);
}
.pagination p {
color: var(--muted);
font-size: 13px;
}
.page-controls {
display: flex;
align-items: center;
gap: 9px;
}
.page-size-label {
color: var(--muted);
font-size: 12px;
}
.page-controls select {
width: 79px;
min-height: 38px;
}
.page-controls .button {
min-height: 38px;
padding: 7px 12px;
font-size: 13px;
}
.page-number {
min-width: 34px;
text-align: center;
color: var(--muted);
font: 12px/1.5 var(--mono);
}
input,
textarea,
select {
width: 100%;
border: 1px solid var(--control-border);
border-radius: 6px;
background: var(--surface);
color: var(--ink);
padding: 10px 12px;
}
input::placeholder,
textarea::placeholder {
color: var(--muted);
opacity: 0.85;
}
textarea {
resize: vertical;
min-height: 120px;
}
input:read-only,
textarea:read-only {
background: var(--surface-subtle);
}
.dialog {
width: min(600px, calc(100% - 32px));
max-height: calc(100dvh - 32px);
padding: 0;
border: 1px solid var(--border);
border-radius: 12px;
color: var(--ink);
background: var(--surface);
box-shadow: var(--shadow);
overflow: auto;
}
.dialog::backdrop {
background: var(--scrim);
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
padding: 24px 24px 18px;
border-bottom: 1px solid var(--border);
}
.dialog h2 {
font-size: 21px;
line-height: 1.4;
}
.icon-button {
width: 40px;
min-height: 40px;
display: grid;
place-items: center;
padding: 0;
color: var(--muted);
background: transparent;
border: 1px solid transparent;
border-radius: 7px;
}
.icon-button:not(:disabled):hover {
color: var(--ink);
background: var(--surface-subtle);
border-color: var(--border);
}
.dialog-body {
padding: 22px 24px 24px;
}
fieldset {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.field {
display: grid;
gap: 8px;
margin-bottom: 20px;
}
.field > label,
.field-label label {
font-weight: 600;
}
.field-label {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 16px;
}
.field-count {
color: var(--muted);
font: 11px/1.5 var(--mono);
white-space: nowrap;
}
.field-hint {
color: var(--muted);
font-size: 12px;
}
.required-mark {
color: var(--danger);
}
.field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.field-grid .field {
margin: 0;
}
.form-error {
padding: 12px 14px;
margin-bottom: 18px;
color: var(--danger);
border: 1px solid var(--danger);
background: var(--danger-soft);
border-radius: 6px;
overflow-wrap: anywhere;
}
.inline-notice {
padding: 10px 12px;
color: var(--muted);
background: var(--surface-subtle);
border-radius: 6px;
margin-bottom: 18px;
}
.task-timestamps {
margin-top: 20px;
padding-top: 14px;
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 12px;
}
.dialog-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 18px 24px;
border-top: 1px solid var(--border);
background: var(--surface-subtle);
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-left: auto;
}
.save-state {
color: var(--muted);
font-size: 12px;
}
.delete-dialog {
width: min(460px, calc(100% - 32px));
}
.delete-task-title {
padding: 12px 14px;
margin: 18px 0 12px;
background: var(--surface-subtle);
border-left: 3px solid var(--danger);
border-radius: 0 6px 6px 0;
font-weight: 600;
overflow-wrap: anywhere;
}
.muted-text {
color: var(--muted);
}
.delete-dialog .form-error {
margin-top: 16px;
margin-bottom: 0;
}
@media (max-width: 960px) {
.task-table th:first-child {
width: 37%;
}
.task-table th:nth-child(2),
.task-table th:nth-child(3) {
width: 13%;
}
.task-table th:nth-child(4) {
width: 22%;
}
.task-table th:nth-child(5) {
width: 15%;
}
.task-table th,
.task-table td {
padding-left: 10px;
padding-right: 10px;
}
.pagination {
align-items: flex-start;
flex-direction: column;
}
.page-controls {
width: 100%;
justify-content: flex-end;
}
}
@media (max-width: 640px) {
body {
font-size: 15px;
}
.workspace {
padding-top: 22px;
}
.page-header {
flex-direction: column;
align-items: stretch;
gap: 20px;
margin-bottom: 20px;
}
.page-heading {
gap: 12px;
}
.page-mark {
width: 42px;
height: 42px;
}
h1 {
font-size: 25px;
}
.page-description {
font-size: 13px;
}
.header-actions {
justify-content: space-between;
}
.button,
.filter-button,
.page-controls .button,
.page-controls select,
.row-button {
min-height: 44px;
}
.list-toolbar {
padding: 18px 16px 12px;
}
.list-heading-row {
gap: 6px;
}
.list-heading-row h2 {
font-size: 17px;
}
.list-toolbar .button {
padding: 8px 10px;
font-size: 12px;
}
.filter-row {
padding: 0 16px 16px;
}
.status-filters {
width: 100%;
}
.filter-button {
flex: 1;
padding-left: 10px;
padding-right: 10px;
}
.list-hint {
display: none;
}
.task-table,
.task-table tbody {
display: block;
}
.task-table thead {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.task-table tr {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 14px;
padding: 18px 16px;
border-top: 1px solid var(--border);
}
.task-table td {
display: block;
padding: 0 !important;
border: 0;
}
.task-table td:first-child,
.task-table td:last-child {
grid-column: 1 / -1;
}
.task-table td:nth-child(4) {
grid-column: 1 / -1;
}
.task-table td[data-label]::before {
content: attr(data-label);
display: inline-block;
margin-right: 8px;
color: var(--muted);
font-size: 12px;
}
.task-table .is-priority td:first-child::before {
left: -16px;
top: 0;
bottom: 0;
}
.task-title-button {
font-size: 16px;
min-height: 32px;
}
.row-actions {
gap: 20px;
margin-top: -6px;
}
.row-button {
padding: 6px 4px;
}
.pagination {
padding: 16px;
gap: 12px;
}
.page-controls {
gap: 6px;
justify-content: space-between;
flex-wrap: wrap;
}
.page-size-label {
display: none;
}
.page-controls select {
width: 72px;
padding: 8px;
}
.page-controls .button {
padding: 8px 10px;
}
.page-number {
min-width: 28px;
}
.error-panel {
margin-left: 16px;
margin-right: 16px;
align-items: flex-start;
flex-direction: column;
}
.dialog-header {
padding: 20px 18px 16px;
}
.dialog-body {
padding: 20px 18px;
}
.dialog-footer {
padding: 16px 18px;
flex-wrap: wrap;
}
.field-grid {
gap: 12px;
}
input,
textarea,
select {
font-size: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
}
}