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,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 },
}