mirror of
https://github.com/insistence/RuoYi-Vue3-FastAPI.git
synced 2026-10-15 13:25:45 +00:00
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:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>任务清单 · 本地模拟宿主</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font:
|
||||
14px/1.6 'Segoe UI',
|
||||
'Microsoft YaHei',
|
||||
sans-serif;
|
||||
color: #172b36;
|
||||
background: #f3f6f7;
|
||||
}
|
||||
header {
|
||||
padding: 16px 24px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #879aa4;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
p {
|
||||
margin: 6px 0 12px;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px 16px;
|
||||
}
|
||||
.controls label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
input[type='number'] {
|
||||
width: 78px;
|
||||
}
|
||||
button,
|
||||
select,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
button,
|
||||
select,
|
||||
input[type='number'] {
|
||||
min-height: 36px;
|
||||
border: 1px solid #879aa4;
|
||||
border-radius: 5px;
|
||||
padding: 4px 8px;
|
||||
background: #fff;
|
||||
color: #172b36;
|
||||
}
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
:focus-visible {
|
||||
outline: 3px solid #176b62;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
#status {
|
||||
color: #516574;
|
||||
font-size: 12px;
|
||||
}
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: 1050px;
|
||||
border: 0;
|
||||
display: block;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
header {
|
||||
padding: 16px;
|
||||
}
|
||||
button,
|
||||
select,
|
||||
input[type='number'] {
|
||||
min-height: 44px;
|
||||
}
|
||||
iframe {
|
||||
height: 2200px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>任务清单 · 本地模拟宿主</h1>
|
||||
<p>
|
||||
使用模拟权限与内存数据,不连接真实后端。刷新整个页面会重置任务。此页仅用于开发,不进入生产构建。
|
||||
</p>
|
||||
<div class="controls">
|
||||
<label
|
||||
>主题
|
||||
<select id="theme">
|
||||
<option value="light">浅色</option>
|
||||
<option value="dark">深色</option>
|
||||
</select></label
|
||||
>
|
||||
<label
|
||||
>延迟(毫秒) <input id="delay" type="number" value="250" min="0" max="5000"
|
||||
/></label>
|
||||
<label><input id="failure" type="checkbox" />模拟请求失败</label>
|
||||
<label><input id="read-only" type="checkbox" />只读权限</label>
|
||||
<button id="reset" type="button">恢复示例任务</button>
|
||||
<button id="empty" type="button">清空模拟任务</button>
|
||||
<button id="reload" type="button">重载插件</button>
|
||||
<button id="logout" type="button">模拟退出</button>
|
||||
<span id="status" role="status" aria-live="polite">等待连接</span>
|
||||
</div>
|
||||
</header>
|
||||
<iframe id="plugin-frame" title="任务清单插件"></iframe>
|
||||
<script type="module" src="/dev/host.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createPluginHostBridge, validatePluginSession } from '@ruoyi/plugin-bridge'
|
||||
import { createMockTaskApi } from './mockRequest.js'
|
||||
|
||||
const element = (id) => document.getElementById(id)
|
||||
const frame = element('plugin-frame')
|
||||
const mock = createMockTaskApi({
|
||||
getDelay: () => element('delay').value,
|
||||
shouldFail: () => element('failure').checked,
|
||||
getCanWrite: () => !element('read-only').checked,
|
||||
})
|
||||
const bridge = createPluginHostBridge({
|
||||
pluginId: 'task_demo',
|
||||
session: validatePluginSession(
|
||||
{
|
||||
pluginId: 'task_demo',
|
||||
bridgeVersion: 1,
|
||||
uiBase: '/apps/task_demo/ui/',
|
||||
apiBase: '/apps/task_demo/api/',
|
||||
csrfToken: 'local-mock-session-no-credentials',
|
||||
expiresIn: 3600,
|
||||
},
|
||||
'task_demo'
|
||||
),
|
||||
getTarget: () => frame.contentWindow,
|
||||
request: mock.request,
|
||||
getContext: () => ({
|
||||
theme: { mode: element('theme').value },
|
||||
language: 'zh-CN',
|
||||
timeZone: 'Asia/Shanghai',
|
||||
route: '/',
|
||||
}),
|
||||
onReady: () => {
|
||||
element('status').textContent = '模拟连接已建立'
|
||||
},
|
||||
})
|
||||
frame.src = '/index.html'
|
||||
element('theme').addEventListener('change', () => bridge.updatePreferences())
|
||||
element('read-only').addEventListener('change', () => bridge.refresh())
|
||||
element('reload').addEventListener('click', () => frame.contentWindow.location.reload())
|
||||
element('reset').addEventListener('click', () => {
|
||||
mock.reset()
|
||||
bridge.refresh()
|
||||
})
|
||||
element('empty').addEventListener('click', () => {
|
||||
mock.clear()
|
||||
bridge.refresh()
|
||||
})
|
||||
element('logout').addEventListener('click', () => {
|
||||
bridge.destroy({ logout: true })
|
||||
element('status').textContent = '模拟会话已退出;刷新整个页面可重新连接'
|
||||
})
|
||||
window.addEventListener('pagehide', () => bridge.destroy(), { once: true })
|
||||
@@ -0,0 +1,155 @@
|
||||
const seedTitles = [
|
||||
'确认本周交付范围',
|
||||
'整理需求评审记录',
|
||||
'补充接口使用示例',
|
||||
'核对页面文案',
|
||||
'准备演示环境',
|
||||
'验证异常处理流程',
|
||||
'完善操作说明',
|
||||
'检查移动端布局',
|
||||
'同步项目里程碑',
|
||||
'回顾已完成事项',
|
||||
'确认验收清单',
|
||||
'整理下一阶段待办',
|
||||
]
|
||||
|
||||
function seedTasks() {
|
||||
return seedTitles.map((title, index) => ({
|
||||
id: (index + 1).toString(16).padStart(32, '0'),
|
||||
title,
|
||||
description: index % 3 === 0 ? '与团队核对完成标准,并记录需要继续跟进的事项。' : '',
|
||||
status: index % 4 === 0 ? 'done' : 'todo',
|
||||
priority: index % 3 === 2 ? 'high' : 'normal',
|
||||
createdAt: new Date(Date.UTC(2026, 9, 1, 2, index * 10)).toISOString(),
|
||||
updatedAt: new Date(Date.UTC(2026, 9, 2, 2, index * 10)).toISOString(),
|
||||
}))
|
||||
}
|
||||
|
||||
function validateTask(data) {
|
||||
const title = typeof data?.title === 'string' ? data.title.trim() : ''
|
||||
if (!title || title.length > 120) throw new Error('任务标题需为 1–120 个字符')
|
||||
if (typeof data?.description !== 'string' || data.description.length > 500)
|
||||
throw new Error('任务说明不能超过 500 个字符')
|
||||
if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(title + data.description))
|
||||
throw new Error('文本不能包含不可显示的控制字符')
|
||||
if (!['todo', 'done'].includes(data.status)) throw new Error('任务状态无效')
|
||||
if (!['normal', 'high'].includes(data.priority)) throw new Error('任务优先级无效')
|
||||
return {
|
||||
title,
|
||||
description: data.description,
|
||||
status: data.status,
|
||||
priority: data.priority,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地内存 API:使用与生产接口一致的分页、筛选、详情和写入契约。
|
||||
* 只供 dev.html 使用;重载整个页面会重置数据,不请求真实服务、不保存凭据。
|
||||
*/
|
||||
export function createMockTaskApi({
|
||||
getDelay = () => 300,
|
||||
shouldFail = () => false,
|
||||
getCanWrite = () => true,
|
||||
} = {}) {
|
||||
let tasks = seedTasks()
|
||||
let nextId = tasks.length + 1
|
||||
const pause = (signal) =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) return reject(new Error('请求已取消'))
|
||||
const cancel = () => {
|
||||
clearTimeout(timer)
|
||||
reject(new Error('请求已取消'))
|
||||
}
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
signal?.removeEventListener('abort', cancel)
|
||||
resolve()
|
||||
},
|
||||
Math.max(0, Math.min(5000, Number(getDelay()) || 0))
|
||||
)
|
||||
signal?.addEventListener('abort', cancel, { once: true })
|
||||
})
|
||||
|
||||
async function request(config) {
|
||||
const prefix = '/apps/task_demo/api/'
|
||||
if (!config.url?.startsWith(prefix)) throw new Error('模拟宿主只处理当前插件 API')
|
||||
const path = config.url.slice(prefix.length)
|
||||
const method = String(config.method || 'get').toUpperCase()
|
||||
await pause(config.signal)
|
||||
if (config.signal?.aborted) throw new Error('请求已取消')
|
||||
if (shouldFail()) throw new Error('模拟请求失败,请关闭“模拟请求失败”后重试。')
|
||||
const canWrite = getCanWrite() === true
|
||||
if (method === 'GET' && path === 'info')
|
||||
return { pluginId: 'task_demo', version: '1.1.0', canWrite }
|
||||
if (method === 'GET' && path === 'tasks') {
|
||||
const page = Number(config.params?.page ?? 1)
|
||||
const pageSize = Number(config.params?.pageSize ?? 10)
|
||||
const status = config.params?.status || ''
|
||||
if (
|
||||
!Number.isInteger(page) ||
|
||||
page < 1 ||
|
||||
page > 1000000 ||
|
||||
!Number.isInteger(pageSize) ||
|
||||
pageSize < 1 ||
|
||||
pageSize > 20
|
||||
)
|
||||
throw new Error('分页参数无效')
|
||||
if (status && !['todo', 'done'].includes(status)) throw new Error('筛选状态无效')
|
||||
const filtered = tasks
|
||||
.filter((task) => !status || task.status === status)
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id))
|
||||
return {
|
||||
items: structuredClone(filtered.slice((page - 1) * pageSize, page * pageSize)),
|
||||
total: filtered.length,
|
||||
page,
|
||||
pageSize,
|
||||
canWrite,
|
||||
}
|
||||
}
|
||||
const match = /^tasks\/([0-9a-f]{32})$/.exec(path)
|
||||
const index = match ? tasks.findIndex((task) => task.id === match[1]) : -1
|
||||
if (method === 'GET' && match) {
|
||||
if (index === -1) throw new Error('任务不存在或已被删除')
|
||||
return structuredClone(tasks[index])
|
||||
}
|
||||
if (!['POST', 'PUT', 'DELETE'].includes(method)) throw new Error('模拟接口未实现')
|
||||
// UI 隐藏按钮之外,再次在请求处理层检查写权限。
|
||||
if (!canWrite) throw new Error('当前会话没有写入权限')
|
||||
if (method === 'POST' && path === 'tasks') {
|
||||
const data = validateTask(config.data)
|
||||
const now = new Date().toISOString()
|
||||
const task = {
|
||||
id: (nextId++).toString(16).padStart(32, '0'),
|
||||
...data,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
tasks.push(task)
|
||||
return structuredClone(task)
|
||||
}
|
||||
if (match && (method === 'PUT' || method === 'DELETE')) {
|
||||
if (index === -1) throw new Error('任务不存在或已被删除')
|
||||
if (method === 'DELETE') {
|
||||
tasks.splice(index, 1)
|
||||
return { deleted: true }
|
||||
}
|
||||
tasks[index] = {
|
||||
...tasks[index],
|
||||
...validateTask(config.data),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
return structuredClone(tasks[index])
|
||||
}
|
||||
throw new Error('模拟接口未实现')
|
||||
}
|
||||
return {
|
||||
request,
|
||||
reset() {
|
||||
tasks = seedTasks()
|
||||
nextId = tasks.length + 1
|
||||
},
|
||||
clear() {
|
||||
tasks = []
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>任务清单</title>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#task-list-section">跳到任务列表</a>
|
||||
<main id="workspace" class="workspace">
|
||||
<header class="page-header">
|
||||
<div class="page-heading">
|
||||
<div class="page-mark" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
|
||||
<rect x="4" y="3" width="16" height="18" rx="3" />
|
||||
<path d="m8 9 1.5 1.5L12 8M14 9h3M8 15h9" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="eyebrow">任务工作区</p>
|
||||
<h1>任务清单</h1>
|
||||
<p class="page-description">团队共享任务,一起记录进展、完成待办。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<span id="access-mode" class="access-mode">连接中</span>
|
||||
<button id="create-task" class="button button-primary" type="button" disabled>
|
||||
<svg
|
||||
class="icon"
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
>
|
||||
<path d="M10 4v12M4 10h12" />
|
||||
</svg>
|
||||
新增任务
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p id="read-only-notice" class="read-only-notice" hidden>
|
||||
当前会话仅可查看共享任务。新增、编辑和删除需要写入权限。
|
||||
</p>
|
||||
<p id="notice" class="notice" role="status" aria-live="polite" aria-atomic="true"></p>
|
||||
|
||||
<section
|
||||
id="task-list-section"
|
||||
class="list-panel"
|
||||
aria-labelledby="list-heading"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="list-toolbar">
|
||||
<div class="list-heading-row">
|
||||
<h2 id="list-heading">全部任务</h2>
|
||||
<span id="task-count" class="task-count">等待加载</span>
|
||||
</div>
|
||||
<button id="refresh" class="button button-secondary" type="button" disabled>
|
||||
<svg
|
||||
class="icon"
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
>
|
||||
<path d="M16.5 9a6.5 6.5 0 1 0-1.2 4.8M16.5 4v5h-5" />
|
||||
</svg>
|
||||
刷新列表
|
||||
</button>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<div id="status-filters" class="status-filters" role="group" aria-label="按任务状态筛选">
|
||||
<button class="filter-button" type="button" data-status="" aria-pressed="true" disabled>
|
||||
全部
|
||||
</button>
|
||||
<button
|
||||
class="filter-button"
|
||||
type="button"
|
||||
data-status="todo"
|
||||
aria-pressed="false"
|
||||
disabled
|
||||
>
|
||||
待完成
|
||||
</button>
|
||||
<button
|
||||
class="filter-button"
|
||||
type="button"
|
||||
data-status="done"
|
||||
aria-pressed="false"
|
||||
disabled
|
||||
>
|
||||
已完成
|
||||
</button>
|
||||
</div>
|
||||
<p class="list-hint">点击任务标题查看详情</p>
|
||||
</div>
|
||||
|
||||
<div id="list-error" class="error-panel" role="alert" hidden>
|
||||
<p id="list-error-message"></p>
|
||||
<button id="retry" class="button button-secondary" type="button">重新加载</button>
|
||||
</div>
|
||||
|
||||
<div id="list-region" class="list-region" aria-busy="true">
|
||||
<div id="loading-state" class="loading-state" role="status">
|
||||
<span class="loading-dot" aria-hidden="true"></span>
|
||||
<span id="loading-message">正在连接工作区…</span>
|
||||
</div>
|
||||
<table id="task-table" class="task-table" hidden>
|
||||
<caption class="sr-only">
|
||||
任务清单,可点击任务标题查看详情
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">任务</th>
|
||||
<th scope="col">状态</th>
|
||||
<th scope="col">优先级</th>
|
||||
<th scope="col">更新时间</th>
|
||||
<th scope="col" class="actions-heading">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="task-rows"></tbody>
|
||||
</table>
|
||||
<div id="empty-state" class="empty-state" hidden>
|
||||
<svg
|
||||
class="empty-icon"
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect x="15" y="10" width="34" height="45" rx="6" />
|
||||
<path d="M24 24h16M24 33h16M24 42h9" />
|
||||
<path d="M25 10V7h14v3" />
|
||||
</svg>
|
||||
<h3 id="empty-heading">还没有任务</h3>
|
||||
<p id="empty-description">新增第一条任务,开始记录待办事项。</p>
|
||||
<button id="empty-create" class="button button-primary" type="button" disabled>
|
||||
新增任务
|
||||
</button>
|
||||
<button id="clear-filter" class="button button-secondary" type="button" hidden>
|
||||
查看全部任务
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="pagination" aria-label="任务分页">
|
||||
<p id="page-summary">尚未加载任务</p>
|
||||
<div class="page-controls">
|
||||
<label class="page-size-label" for="page-size">每页</label>
|
||||
<select id="page-size" aria-label="每页任务数量" disabled>
|
||||
<option value="10">10 条</option>
|
||||
<option value="20">20 条</option>
|
||||
</select>
|
||||
<button id="previous-page" class="button button-secondary" type="button" disabled>
|
||||
上一页
|
||||
</button>
|
||||
<span id="page-number" class="page-number" aria-label="当前页码">—</span>
|
||||
<button id="next-page" class="button button-secondary" type="button" disabled>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<dialog id="task-dialog" class="dialog task-dialog" aria-labelledby="dialog-heading">
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<p id="dialog-kicker" class="eyebrow">新任务</p>
|
||||
<h2 id="dialog-heading">新增任务</h2>
|
||||
</div>
|
||||
<button id="close-dialog" class="icon-button" type="button" aria-label="关闭任务详情">
|
||||
<svg
|
||||
class="icon"
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
>
|
||||
<path d="m5 5 10 10M15 5 5 15" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
<form id="task-form">
|
||||
<div class="dialog-body">
|
||||
<p id="detail-loading" class="inline-notice" role="status" hidden>正在读取任务详情…</p>
|
||||
<p id="form-error" class="form-error" role="alert" tabindex="-1" hidden></p>
|
||||
<p id="form-read-only" class="inline-notice" hidden>当前会话仅可查看,不能保存修改。</p>
|
||||
<fieldset id="task-fields">
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<label for="task-title">任务标题 <span class="required-mark">*</span></label
|
||||
><span id="title-count" class="field-count">0 / 120</span>
|
||||
</div>
|
||||
<input
|
||||
id="task-title"
|
||||
name="title"
|
||||
type="text"
|
||||
maxlength="120"
|
||||
required
|
||||
autocomplete="off"
|
||||
aria-describedby="title-hint"
|
||||
placeholder="例如:完成本周交付检查"
|
||||
/>
|
||||
<p id="title-hint" class="field-hint">用一句话描述要完成的事情。</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<label for="task-description">任务说明</label
|
||||
><span id="description-count" class="field-count">0 / 500</span>
|
||||
</div>
|
||||
<textarea
|
||||
id="task-description"
|
||||
name="description"
|
||||
maxlength="500"
|
||||
rows="5"
|
||||
placeholder="补充完成标准、注意事项或相关背景(选填)"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
<div class="field">
|
||||
<label for="task-status">状态</label
|
||||
><select id="task-status" name="status">
|
||||
<option value="todo">待完成</option>
|
||||
<option value="done">已完成</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="task-priority">优先级</label
|
||||
><select id="task-priority" name="priority">
|
||||
<option value="normal">普通</option>
|
||||
<option value="high">优先处理</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<p id="task-timestamps" class="task-timestamps" hidden></p>
|
||||
</div>
|
||||
<footer class="dialog-footer">
|
||||
<p id="save-state" class="save-state" role="status" aria-live="polite"></p>
|
||||
<div class="dialog-actions">
|
||||
<button id="cancel-dialog" class="button button-secondary" type="button">取消</button
|
||||
><button id="save-task" class="button button-primary" type="submit">新增任务</button>
|
||||
</div>
|
||||
</footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog
|
||||
id="delete-dialog"
|
||||
class="dialog delete-dialog"
|
||||
aria-labelledby="delete-heading"
|
||||
aria-describedby="delete-description"
|
||||
>
|
||||
<div class="dialog-body">
|
||||
<p class="eyebrow">删除任务</p>
|
||||
<h2 id="delete-heading">确认删除这条任务?</h2>
|
||||
<p id="delete-title" class="delete-task-title"></p>
|
||||
<p id="delete-description" class="muted-text">删除后无法恢复,请确认这条任务已不再需要。</p>
|
||||
<p id="delete-error" class="form-error" role="alert" tabindex="-1" hidden></p>
|
||||
</div>
|
||||
<footer class="dialog-footer">
|
||||
<div class="dialog-actions">
|
||||
<button id="cancel-delete" class="button button-secondary" type="button" autofocus>
|
||||
保留任务</button
|
||||
><button id="confirm-delete" class="button button-danger" type="button">确认删除</button>
|
||||
</div>
|
||||
</footer>
|
||||
</dialog>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "ruoyi-plugin-task-demo",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1 --open /dev.html",
|
||||
"build": "vite build",
|
||||
"test": "node --test tests/mockRequest.test.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "6.4.3"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { createMockTaskApi } from '../dev/mockRequest.js'
|
||||
|
||||
const draft = {
|
||||
title: '测试任务',
|
||||
description: '保留完整说明',
|
||||
status: 'todo',
|
||||
priority: 'normal',
|
||||
}
|
||||
const call = (api, method, path, extra = {}) =>
|
||||
api.request({
|
||||
method,
|
||||
url: `/apps/task_demo/api/${path}`,
|
||||
...extra,
|
||||
})
|
||||
|
||||
test('内存数据支持分页、筛选、完整 CRUD 和响应副本隔离', async () => {
|
||||
const api = createMockTaskApi({ getDelay: () => 0 })
|
||||
const first = await call(api, 'get', 'tasks', {
|
||||
params: { page: 1, pageSize: 10 },
|
||||
})
|
||||
const second = await call(api, 'get', 'tasks', {
|
||||
params: { page: 2, pageSize: 10 },
|
||||
})
|
||||
assert.equal(first.total, 12)
|
||||
assert.equal(first.items.length, 10)
|
||||
assert.equal(second.items.length, 2)
|
||||
assert.equal(new Set([...first.items, ...second.items].map((task) => task.id)).size, 12)
|
||||
const created = await call(api, 'post', 'tasks', { data: draft })
|
||||
assert.equal(created.title, draft.title)
|
||||
created.title = '修改响应不能污染存储'
|
||||
assert.equal((await call(api, 'get', `tasks/${created.id}`)).title, draft.title)
|
||||
const updated = await call(api, 'put', `tasks/${created.id}`, {
|
||||
data: { ...draft, status: 'done', priority: 'high' },
|
||||
})
|
||||
assert.equal(updated.createdAt, created.createdAt)
|
||||
const done = await call(api, 'get', 'tasks', { params: { status: 'done' } })
|
||||
assert.ok(done.items.every((task) => task.status === 'done'))
|
||||
assert.ok(done.items.some((task) => task.id === created.id && task.priority === 'high'))
|
||||
assert.deepEqual(await call(api, 'delete', `tasks/${created.id}`), {
|
||||
deleted: true,
|
||||
})
|
||||
await assert.rejects(call(api, 'get', `tasks/${created.id}`), /不存在/)
|
||||
})
|
||||
|
||||
test('只读权限在请求层拒绝所有写操作并保留数据', async () => {
|
||||
let writable = true
|
||||
const api = createMockTaskApi({
|
||||
getDelay: () => 0,
|
||||
getCanWrite: () => writable,
|
||||
})
|
||||
const task = await call(api, 'post', 'tasks', { data: draft })
|
||||
writable = false
|
||||
assert.equal((await call(api, 'get', 'info')).canWrite, false)
|
||||
assert.equal((await call(api, 'get', 'tasks')).canWrite, false)
|
||||
for (const [method, path] of [
|
||||
['post', 'tasks'],
|
||||
['put', `tasks/${task.id}`],
|
||||
['delete', `tasks/${task.id}`],
|
||||
]) {
|
||||
await assert.rejects(call(api, method, path, { data: draft }), /写入权限/)
|
||||
}
|
||||
assert.equal((await call(api, 'get', 'tasks')).total, 13)
|
||||
assert.equal((await call(api, 'get', `tasks/${task.id}`)).title, draft.title)
|
||||
})
|
||||
|
||||
test('失败、取消及无效输入不会产生写入副作用', async () => {
|
||||
let fail = true
|
||||
const api = createMockTaskApi({ getDelay: () => 10, shouldFail: () => fail })
|
||||
await assert.rejects(call(api, 'post', 'tasks', { data: draft }), /模拟请求失败/)
|
||||
fail = false
|
||||
const controller = new AbortController()
|
||||
const pending = call(api, 'post', 'tasks', {
|
||||
data: draft,
|
||||
signal: controller.signal,
|
||||
})
|
||||
controller.abort()
|
||||
await assert.rejects(pending, /取消/)
|
||||
for (const data of [
|
||||
{ ...draft, title: ' ' },
|
||||
{ ...draft, description: 'x'.repeat(501) },
|
||||
{ ...draft, status: 'invalid' },
|
||||
]) {
|
||||
await assert.rejects(call(api, 'post', 'tasks', { data }))
|
||||
}
|
||||
assert.equal((await call(api, 'get', 'tasks')).total, 12)
|
||||
api.clear()
|
||||
assert.equal((await call(api, 'get', 'tasks')).total, 0)
|
||||
api.reset()
|
||||
assert.equal((await call(api, 'get', 'tasks')).total, 12)
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
// 开发配置只在本地服务注入,生产页面由宿主注入真实插件标识与部署基址。
|
||||
export default {
|
||||
base: './',
|
||||
plugins: [
|
||||
{
|
||||
name: 'local-task-plugin-config',
|
||||
apply: 'serve',
|
||||
transformIndexHtml(html, context) {
|
||||
if (context.path !== '/index.html' && context.path !== '/') return html
|
||||
return {
|
||||
html,
|
||||
tags: [
|
||||
{
|
||||
tag: 'script',
|
||||
attrs: { id: 'ruoyi-plugin-config', type: 'application/json' },
|
||||
children: JSON.stringify({ pluginId: 'task_demo' }),
|
||||
injectTo: 'head',
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@ruoyi/plugin-bridge': fileURLToPath(
|
||||
new URL(
|
||||
'../../../../../../ruoyi-fastapi-frontend/src/utils/pluginBridge.js',
|
||||
import.meta.url
|
||||
)
|
||||
),
|
||||
},
|
||||
},
|
||||
build: { sourcemap: false },
|
||||
}
|
||||
Reference in New Issue
Block a user