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,98 @@
# 持久化任务示例
`task_demo` 是 manifest v2 的 Python ASGI + 独立 bundle 业务示例,当前版本 `1.1.0`。页面提供新增、编辑、删除、状态筛选和分页;数据保存在宿主默认数据源的 `ruoyi_plugin_task_demo` 表中。它演示插件级共享任务清单:有查看权限的用户看到同一组任务,未实现个人、部门或租户数据隔离。
源码位于 `plugins/examples/python/task_demo`,示例目录不参与运行时插件扫描。下面的命令均在后端根目录、已激活并安装宿主依赖的 Python 3.10–3.13 环境执行,适用于 Windows PowerShell、macOS 和 Linux;前端构建使用 Node.js 22。
## 构建与测试
```bash
npm --prefix plugins/examples/python/task_demo/web install --no-audit --no-fund --package-lock=false
npm --prefix plugins/examples/python/task_demo/web test
npm --prefix plugins/examples/python/task_demo/web run build
python plugins/examples/python/task_demo/build_release.py --output target/task-demo-1.1.0
python -m pytest tests/plugins/examples/test_task_demo.py tests/plugins/core/deployment/test_task_delivery.py -q
```
每次使用不存在的新输出目录。交付和签名输入为 `target/task-demo-1.1.0/task_demo`,包含清单、三个 Python 业务模块、MySQL/PostgreSQL 各两份 SQL 和 `web/dist`;不包含开发页面、测试、依赖目录、构建脚本或文件清单。Python 后端仍以源码交付。
`release-files.json` 使用 `schemaVersion: 1` 和显式 `files` 数组列出业务 `.py`、`.sql` 文件;`plugin.yaml` 和已构建的 `web/dist` 自动纳入。新增模块或迁移时同时更新这份清单与 `plugin.yaml` 中的相应声明。构建器不展开通配符、不递归收集 Python 源码,不导入插件、不执行 npm,也不生成签名;缺失文件、非法路径、链接和开发资源会在建立输出目录前被拒绝。旧工程可保留已有构建器;该清单是当前生成脚本的输入,不会自动升级旧脚本。
示例前端通过 Vite 别名引用同一仓库的宿主桥接 SDK。需要把示例移到独立仓库时,按[脚手架与 SDK 溯源说明](../../../../docs/plugin_development.md#161-桥接-sdk-溯源与显式更新)使用 `python-bundle` 工程的 `web/vendor` 及构建配置,再迁入业务代码。
## 本地页面开发
```bash
npm --prefix plugins/examples/python/task_demo/web run dev
```
打开开发服务的 `/dev.html`,可预览任务清单、主题、只读状态和失败反馈。开发宿主使用内存数据,刷新页面会重置,不能验证真实数据库事务或宿主权限。生产构建只包含业务页面,不包含此开发宿主;正式访问应从宿主菜单打开 `PluginFrame`。
## 安装与权限
开发环境可将构建出的 `task_demo` 目录复制到后端 `plugins/task_demo`,然后执行:
```bash
ruoyi plugin check task_demo --env=dev
ruoyi plugin install task_demo --env=dev --yes
ruoyi plugin enable task_demo --env=dev --yes
```
重启后端后,为角色授予 `task_demo:view`,重新获取菜单即可查看任务;需要增删改时另授予 `task_demo:write`。页面根据接口返回的 `canWrite` 显示只读状态,每个业务接口仍独立校验权限。写权限不隐含查看权限,完整页面操作应授予两者。
首次引入 bundle 支持时需构建包含 `PluginFrame` 的宿主前端;之后更新此插件只需重建插件页面。子页面通过 `createPluginClient` 调用自身 API,无独立登录,不读取或传递管理员 token。JSON 请求遵循宿主的插件会话、CSRF 和传输加密策略,无需为本示例增加加密例外。
## 接口与事务
以下路径相对于 `/apps/task_demo/api`。桥接 SDK 中使用 `path: 'tasks'`,查询字段使用 `params`。
| 方法与路径 | 权限 | 返回或行为 |
| --- | --- | --- |
| `GET /info` | `task_demo:view` | 插件版本与 `canWrite` |
| `GET /tasks` | `task_demo:view` | `items/total/page/pageSize/canWrite`;支持 `status=todo/done` |
| `GET /tasks/{id}` | `task_demo:view` | 单条任务,不存在返回 404 |
| `POST /tasks` | `task_demo:write` | 新建任务,返回保存后的任务 |
| `PUT /tasks/{id}` | `task_demo:write` | 替换可编辑字段,返回保存后的任务 |
| `DELETE /tasks/{id}` | `task_demo:write` | 删除任务,返回 `deleted: true` |
请求体包含 `title`(必填、去除两端空白后非空、最长 120 字符)、`description`(默认空、最长 500 字符)、`status`(默认 `todo`,可选 `done`)和 `priority`(默认 `normal`,可选 `high`)。PUT 同样要求标题,未提供的可选字段使用默认值。未知字段和非法输入返回 422,无权限返回 403。ID 由服务器生成;响应时间使用固定毫秒精度的 UTC RFC3339 字符串。数据库列遵循宿主时间规范:MySQL `DATETIME(3)`、PostgreSQL `TIMESTAMP(3) WITH TIME ZONE`,Python 使用公共 `DbUtcDateTime` 类型完成 UTC 转换。
分页默认每页 10 条,最多 20 条;字段长度与页大小共同限制 JSON 体积,适配桥接的 64 KiB 消息上限。列表使用稳定排序。所有读写通过 `context.transaction()` 取得独立会话,成功退出提交,异常或取消回滚;不复用宿主鉴权会话,不访问宿主业务 DAO,不在启动或请求中自动建表。修改采取后写覆盖,不提供版本冲突检测;需要并发协同编辑时应扩展版本字段和冲突处理。
## 迁移与升级
| 版本 | 迁移 | 数据变化 |
| --- | --- | --- |
| `1.0.0` 数据结构 | `001_init.sql` | 建立任务表 |
| `1.1.0` | `002_priority.sql` | 增加 `priority`,既有行默认 `normal` |
清单同时列出两种数据库的脚本,宿主根据默认数据源类型选择本次方言,按声明顺序运行。首次安装执行两次迁移;从已安装的 1.0 数据结构升级仅执行 002,成功迁移的校验值与历史用于防止重复执行。不要修改已经执行过的 001;后续变更应新增文件并提高插件版本。
002 使用 `ALTER TABLE ADD COLUMN`,依靠宿主迁移历史实现只执行一次,不能脱离历史反复手工执行。MySQL DDL 可能隐式提交;中断后应按[迁移恢复手册](../../../../docs/plugin_migration_failure_runbook.md)确认实际结构和历史状态,不能假定失败自动恢复旧结构。代码回滚不撤销数据库迁移。
本地测试执行真实 SQLite SQL 和事务,覆盖 CRUD、读写权限、分页、校验、回滚以及旧行保留;将 SQLite 不支持的 PostgreSQL 时间类型替换为 `DATETIME`,迁移文件与校验值保持原样。交付测试从源码构建目录、签名、导入、维护准备及选择目标,再从签名制品加载当前业务代码。SQLite 仅允许单个写事务,因此本地交付夹具的宿主维护会话使用自动提交,并适配宿主菜单自增主键,业务 CRUD 仍用正常事务;它不验证宿主维护事务的数据库原子性。其 1.0 包是仅用于迁移验收的结构夹具,不提供历史页面或历史业务 API;页面资源在本地交付测试中是最小 HTML 夹具。真实数据库 CI 使用原始 SQL 和正常宿主事务,不进行以上适配。
真实 MySQL/PostgreSQL 交付验收接入 `scripts/plugin_release_integration.py` 和发布集成 CI:先构建本示例页面,再使用隔离数据库和 Redis 执行相同维护流程,验证旧行保留、当前 CRUD 和只读拒绝。它注入已认证请求上下文,通过 ASGI HTTP 适配器访问业务 API;真实浏览器登录、Cookie/CSRF/TLS 由独立网络 smoke 覆盖。配置工作流不等于已通过远端 CI,应查看对应提交的实际运行结果。
## 签名制品与维护发布
先按[签名交付手册](../../../../docs/plugin_development.md#15-v2-签名制品与维护发布)启用制品功能,配置外部信任公钥并授权 `task_demo`。签名私钥应位于源码和交付目录之外,以下路径为示例:
```bash
ruoyi plugin artifact build target/task-demo-1.1.0/task_demo target/task_demo-1.1.0.rpk --key-file ../signing-keys/publisher.pem --key-id publisher --env=dev --output=json
ruoyi plugin artifact verify target/task_demo-1.1.0.rpk --env=prod --output=json
ruoyi plugin artifact import target/task_demo-1.1.0.rpk --env=prod --allow-prod --yes --output=json
ruoyi plugin release plan task_demo DIGEST --env=prod --output=json
```
`DIGEST` 替换为实际导入结果。停止全部宿主 worker,在独立维护进程完成准备,然后读取最新 generation 并选择目标:
```bash
ruoyi plugin release prepare task_demo DIGEST --maintenance --env=prod --allow-prod --yes --output=json
ruoyi plugin release status --plugin-id task_demo --env=prod --output=json
ruoyi plugin release select task_demo DIGEST --expected-generation GENERATION --expected-workers 1 --maintenance --env=prod --allow-prod --yes --output=json
```
`GENERATION` 使用准备后最新 status 中的值,worker 数按实际部署调整。首装需确认启用状态;升级保留已有开关,不会自动启用已停用插件。启用操作及其代际要求见发布手册。重启全部 worker 后检查实际目标、健康状态及业务数据,不能以选择目标成功代替运行验收。
同名 `plugins/task_demo` 目录不能与签名制品共存;维护时先备份并移出目录插件。不可变制品只保存程序与静态资源,业务数据始终保存在数据库。健康检查验证包含新增优先级字段的任务表可读,缺表或缺列不会被报告为健康。
@@ -0,0 +1,133 @@
from types import SimpleNamespace
from typing import Any
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, ValidationError
from sqlalchemy import select
from plugins.core.runtime.health import PluginHealthContext
from plugins.core.sdk import PluginDefinition, PluginHostContext, PluginRequestContext
from plugins.core.sdk.asgi import plugin_endpoint
from . import service
from .models import TaskIdentifier, TaskInput, TaskQuery, tasks
VERSION = '1.1.0'
_runtime = SimpleNamespace(session_factory=None)
def _validate(model: type[BaseModel], values: dict[str, Any]) -> Any:
"""
校验 SDK 请求快照中的路径和查询参数。
:param model: 目标参数模型
:param values: 原始参数字段
:return: 已校验的模型;无效输入返回 HTTP 422
"""
try:
return model.model_validate(values)
except ValidationError as exc:
raise HTTPException(
status_code=422,
detail=exc.errors(include_input=False, include_context=False, include_url=False),
) from exc
def _task_id(context: PluginRequestContext) -> str:
"""
读取 SDK 已提取的任务路径参数。
:param context: 含 HTTP 请求快照的宿主上下文
:return: 已校验的任务标识
"""
return _validate(TaskIdentifier, {'id': context.request.path_params['task_id']}).id
async def info(context: PluginRequestContext) -> dict[str, Any]:
return {'pluginId': context.host.plugin_id, 'version': VERSION, 'canWrite': service.can_write(context)}
async def list_tasks(context: PluginRequestContext) -> dict[str, Any]:
if any(len(values) != 1 for values in context.request.query.values()):
raise HTTPException(status_code=422, detail='查询参数不能重复')
query = _validate(TaskQuery, {key: values[0] for key, values in context.request.query.items()})
return await service.list_tasks(context, query)
async def get_task(context: PluginRequestContext) -> dict[str, Any]:
return await service.get_task(context, _task_id(context))
async def create_task(context: PluginRequestContext) -> dict[str, Any]:
return await service.create_task(context, context.request.body)
async def update_task(context: PluginRequestContext) -> dict[str, Any]:
return await service.update_task(context, _task_id(context), context.request.body)
async def delete_task(context: PluginRequestContext) -> dict[str, bool]:
return await service.delete_task(context, _task_id(context))
def create_plugin(host: PluginHostContext) -> PluginDefinition:
"""
声明使用宿主事务能力的任务插件,不在启动时创建表。
:param host: 宿主提供的插件能力
:return: 仅挂载相对 API 的 ASGI 插件声明
"""
# 仅保存宿主授予的会话工厂,健康检查自行开关会话,不保留请求会话。
_runtime.session_factory = host.session_factory
def create_app(context: PluginHostContext) -> FastAPI:
"""
创建任务管理子应用,各接口独立声明读写权限。
:param context: 宿主提供的插件能力
:return: 任务管理 ASGI 子应用
"""
app = FastAPI(title='持久化任务示例', docs_url=None, redoc_url=None, openapi_url=None)
app.add_api_route('/api/info', plugin_endpoint(info, permission='task_demo:view'), methods=['GET'])
app.add_api_route('/api/tasks', plugin_endpoint(list_tasks, permission='task_demo:view'), methods=['GET'])
app.add_api_route(
'/api/tasks/{task_id}', plugin_endpoint(get_task, permission='task_demo:view'), methods=['GET']
)
app.add_api_route(
'/api/tasks',
plugin_endpoint(create_task, permission='task_demo:write', body_model=TaskInput, max_body_bytes=8192),
methods=['POST'],
status_code=201,
)
app.add_api_route(
'/api/tasks/{task_id}',
plugin_endpoint(update_task, permission='task_demo:write', body_model=TaskInput, max_body_bytes=8192),
methods=['PUT'],
)
app.add_api_route(
'/api/tasks/{task_id}', plugin_endpoint(delete_task, permission='task_demo:write'), methods=['DELETE']
)
return app
return PluginDefinition(app_factory=create_app)
async def health(context: PluginHealthContext) -> dict[str, Any]:
"""
只读验证任务表及当前版本新增字段,不执行 DDL 或修复。
:param context: 宿主健康检查上下文
:return: 明确包含布尔 ok 的健康结果
"""
statement = select(tasks).limit(1)
try:
if context.query_db is not None:
await context.query_db.execute(statement)
else:
if _runtime.session_factory is None:
return {'ok': False, 'status': 'unavailable', 'message': '宿主未提供数据库检查会话'}
async with _runtime.session_factory() as db:
await db.execute(statement)
except Exception:
return {'ok': False, 'status': 'unhealthy', 'message': '任务表或 priority 字段不可读,请检查迁移记录'}
return {'ok': True, 'status': 'healthy', 'message': '任务表及 priority 字段可读'}
@@ -0,0 +1,233 @@
import argparse
import json
import shutil
import stat
import unicodedata
from pathlib import Path, PureWindowsPath
from typing import Any
PLUGIN_ID = 'task_demo'
BUNDLE = True
SOURCE = Path(__file__).resolve().parent
RELEASE_MANIFEST = 'release-files.json'
MANIFEST_VERSION = 1
MAX_MANIFEST_BYTES = 64 * 1024
MAX_SOURCE_FILES = 1024
MAX_PATH_LENGTH = 1024
MAX_PATH_COMPONENTS = 32
MAX_COMPONENT_LENGTH = 255
FIRST_PRINTABLE_CHARACTER = 32
DELETE_CHARACTER = 127
SOURCE_EXTENSIONS = {'.py', '.sql'}
FORBIDDEN_DIRECTORIES = {
'tests',
'test',
'keys',
'vendor',
'node_modules',
'__pycache__',
'build',
'dist',
'target',
'web',
}
FORBIDDEN_FILES = {'build_release.py', 'conftest.py', 'setup.py'}
WEB_EXTENSIONS = {
'.html',
'.js',
'.css',
'.svg',
'.png',
'.jpg',
'.jpeg',
'.gif',
'.webp',
'.ico',
'.woff',
'.woff2',
'.ttf',
}
REPARSE_POINT = getattr(stat, 'FILE_ATTRIBUTE_REPARSE_POINT', 0x400)
def checked_file(path: Path) -> Path:
"""
校验交付文件位于源码目录内,且不含链接、重解析点或硬链接。
:param path: 待交付的源码文件路径
:return: 通过检查的源码文件路径
"""
for part in (path, *path.parents):
if part == SOURCE:
break
metadata = part.lstat()
if part.is_symlink() or getattr(metadata, 'st_file_attributes', 0) & REPARSE_POINT:
raise ValueError(f'交付资源不能是链接或重解析点:{part}')
resolved = path.resolve()
if not resolved.is_relative_to(SOURCE) or not path.is_file():
raise ValueError(f'交付文件必须位于源码目录:{path}')
if path.stat().st_nlink > 1:
raise ValueError(f'交付文件不能是硬链接:{path}')
return path
def checked_relative_path(value: str) -> tuple[str, ...]:
"""
校验明确的跨平台相对路径,不接受通配符、隐藏组件或设备路径。
:param value: 清单或 bundle 中的相对路径
:return: 未经折叠或重写的路径组件
"""
if not isinstance(value, str):
raise ValueError('交付路径必须为字符串')
parts = value.split('/')
if (
not value
or len(value) > MAX_PATH_LENGTH
or len(parts) > MAX_PATH_COMPONENTS
or unicodedata.normalize('NFC', value) != value
or any(
not part
or part.startswith('.')
or part.endswith((' ', '.'))
or len(part) > MAX_COMPONENT_LENGTH
or PureWindowsPath(part).is_reserved()
or any(
char in '<>:"\\|?*[]' or ord(char) < FIRST_PRINTABLE_CHARACTER or ord(char) == DELETE_CHARACTER
for char in part
)
for part in parts
)
):
raise ValueError(f'交付清单包含不安全路径:{value!r}')
return tuple(parts)
def checked_inventory(names: list[str]) -> None:
"""
拒绝重复文件、文件与目录冲突以及任一级目录的大小写歧义。
:param names: 全部交付文件的相对路径
:return: None
"""
spellings: dict[str, str] = {}
files: set[str] = set()
directories: set[str] = set()
for name in names:
parts = checked_relative_path(name)
key = name.casefold()
if key in files:
raise ValueError(f'交付清单包含重复文件或大小写冲突:{name}')
files.add(key)
for count in range(1, len(parts) + 1):
prefix = '/'.join(parts[:count])
if spellings.setdefault(prefix.casefold(), prefix) != prefix:
raise ValueError(f'交付清单包含目录大小写冲突:{name}')
if count < len(parts):
directories.add(prefix.casefold())
if files & directories:
raise ValueError('交付清单存在文件与父目录冲突')
def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
"""
解析 JSON 对象时拒绝重复字段,避免不同工具对同一清单作不同解释。
:param pairs: JSON 对象按原始顺序出现的字段
:return: 不包含重复字段的对象
"""
result = {}
for key, value in pairs:
if key in result:
raise ValueError(f'交付清单包含重复字段:{key}')
result[key] = value
return result
def source_resources() -> list[Path]:
"""
按有大小和数量上限的显式清单读取 Python、SQL 资源,不扫描业务源码。
:return: 已通过路径、类型和链接检查的源码文件列表
"""
path = checked_file(SOURCE / RELEASE_MANIFEST)
with path.open('rb') as source:
content = source.read(MAX_MANIFEST_BYTES + 1)
if len(content) > MAX_MANIFEST_BYTES:
raise ValueError('release-files.json 超过 64 KiB 上限')
manifest = json.loads(content.decode('utf-8-sig'), object_pairs_hook=unique_object)
if (
not isinstance(manifest, dict)
or set(manifest) != {'schemaVersion', 'files'}
or type(manifest['schemaVersion']) is not int
or manifest['schemaVersion'] != MANIFEST_VERSION
):
raise ValueError('release-files.json 必须包含 schemaVersion: 1 和 files')
names = manifest['files']
if not isinstance(names, list) or not 1 <= len(names) <= MAX_SOURCE_FILES:
raise ValueError('交付清单 files 必须为包含 1 至 1024 项的列表')
checked_inventory(names)
for name in names:
parts = name.split('/')
filename = parts[-1].casefold()
if (
Path(name).suffix not in SOURCE_EXTENSIONS
or any(part.casefold() in FORBIDDEN_DIRECTORIES for part in parts[:-1])
or filename in FORBIDDEN_FILES
or filename.startswith('test_')
or filename.endswith('_test.py')
):
raise ValueError(f'仅允许业务 .py/.sql 文件,不能包含测试、密钥或开发资源:{name}')
return [checked_file(SOURCE / name) for name in names]
def bundle_resources() -> list[Path]:
"""
收集已构建 bundle,保留原有静态扩展名、隐藏文件和链接限制。
:return: 已通过检查的 web/dist 文件列表
"""
dist = SOURCE / 'web' / 'dist'
checked_file(dist / 'index.html')
resources = []
for path in sorted(dist.rglob('*')):
if path.is_symlink() or getattr(path.lstat(), 'st_file_attributes', 0) & REPARSE_POINT:
raise ValueError(f'bundle 不允许链接:{path}')
if not path.is_file():
continue
if path.suffix.lower() not in WEB_EXTENSIONS:
raise ValueError(f'bundle 存在非交付文件;不要包含源码、source map 或密钥:{path}')
resources.append(checked_file(path))
if not any(path.relative_to(dist).as_posix() == 'index.html' for path in resources):
raise ValueError('bundle 入口必须使用准确的 index.html 文件名')
return resources
def main() -> None:
"""
按显式文件清单组装新交付目录,不安装依赖、导入插件或生成签名。
:return: None
"""
parser = argparse.ArgumentParser(
description='按 release-files.json 组装交付文件;不安装依赖、不导入插件、不生成签名。'
)
parser.add_argument('--output', type=Path, required=True, help='全新输出目录(其中创建插件ID目录)')
output = parser.parse_args().output.resolve()
if output.exists() or output.is_relative_to(SOURCE) or SOURCE.is_relative_to(output):
parser.error('输出目录必须不存在,且不得与源码目录重叠')
resources = [checked_file(SOURCE / 'plugin.yaml'), *source_resources()]
if BUNDLE:
resources.extend(bundle_resources())
checked_inventory([path.relative_to(SOURCE).as_posix() for path in resources])
plugin = output / PLUGIN_ID
plugin.mkdir(parents=True)
for resource in resources:
target = plugin / resource.relative_to(SOURCE)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(resource, target)
print(json.dumps({'plugin': str(plugin), 'signed': False}, ensure_ascii=False))
if __name__ == '__main__':
main()
@@ -0,0 +1,9 @@
CREATE TABLE ruoyi_plugin_task_demo (
id VARCHAR(32) NOT NULL PRIMARY KEY,
title VARCHAR(120) NOT NULL,
description VARCHAR(500) NOT NULL DEFAULT '',
status VARCHAR(4) NOT NULL DEFAULT 'todo',
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
CONSTRAINT ck_task_demo_status CHECK (status IN ('todo', 'done'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -0,0 +1,3 @@
ALTER TABLE ruoyi_plugin_task_demo
ADD COLUMN priority VARCHAR(6) NOT NULL DEFAULT 'normal',
ADD CONSTRAINT ck_task_demo_priority CHECK (priority IN ('normal', 'high'));
@@ -0,0 +1,9 @@
CREATE TABLE ruoyi_plugin_task_demo (
id VARCHAR(32) NOT NULL PRIMARY KEY,
title VARCHAR(120) NOT NULL,
description VARCHAR(500) NOT NULL DEFAULT '',
status VARCHAR(4) NOT NULL DEFAULT 'todo',
created_at TIMESTAMP(3) WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP(3) WITH TIME ZONE NOT NULL,
CONSTRAINT ck_task_demo_status CHECK (status IN ('todo', 'done'))
);
@@ -0,0 +1,3 @@
ALTER TABLE ruoyi_plugin_task_demo
ADD COLUMN priority VARCHAR(6) NOT NULL DEFAULT 'normal'
CONSTRAINT ck_task_demo_priority CHECK (priority IN ('normal', 'high'));
@@ -0,0 +1,62 @@
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field, StringConstraints, field_validator
from sqlalchemy import CheckConstraint, Column, MetaData, String, Table
from common.types import DbUtcDateTime
MIN_PRINTABLE_CODEPOINT = 32
metadata = MetaData()
tasks = Table(
'ruoyi_plugin_task_demo',
metadata,
Column('id', String(32), primary_key=True),
Column('title', String(120), nullable=False),
Column('description', String(500), nullable=False, server_default=''),
Column('status', String(4), nullable=False, server_default='todo'),
Column('created_at', DbUtcDateTime, nullable=False),
Column('updated_at', DbUtcDateTime, nullable=False),
Column('priority', String(6), nullable=False, server_default='normal'),
CheckConstraint("status IN ('todo', 'done')", name='ck_task_demo_status'),
CheckConstraint("priority IN ('normal', 'high')", name='ck_task_demo_priority'),
)
class TaskInput(BaseModel):
"""任务创建和完整更新的输入字段。"""
model_config = ConfigDict(extra='forbid')
title: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=120)]
description: str = Field(default='', max_length=500)
status: Literal['todo', 'done'] = 'todo'
priority: Literal['normal', 'high'] = 'normal'
@field_validator('title', 'description')
@classmethod
def reject_control_characters(cls, value: str) -> str:
"""
拒绝不可显示控制字符,使最大分页仍处于浏览器桥响应上限内。
:param value: 标题或描述文本
:return: 已校验的文本
"""
if any(ord(character) < MIN_PRINTABLE_CODEPOINT and character not in '\n\r\t' for character in value):
raise ValueError('文本不能包含不可显示的控制字符')
return value
class TaskQuery(BaseModel):
"""限制单次响应体积,并提供稳定的分页和状态筛选。"""
model_config = ConfigDict(extra='forbid')
page: int = Field(default=1, ge=1, le=1_000_000)
page_size: int = Field(default=10, ge=1, le=20, alias='pageSize')
status: Literal['todo', 'done'] | None = None
class TaskIdentifier(BaseModel):
"""用于路径参数的插件任务标识。"""
id: str = Field(pattern=r'^[0-9a-f]{32}$')
@@ -0,0 +1,43 @@
manifestVersion: 2
id: task_demo
name: 持久化任务示例
version: 1.1.0
description: 独立前端与 ASGI 接口结合,演示显式事务、读写分权及无损字段迁移。
backend:
runtime: python
integration: asgi
module: plugins.task_demo
entrypoint: plugins.task_demo:create_plugin
health:
checker: plugins.task_demo:health
migrations:
- migrations/mysql/001_init.sql
- migrations/mysql/002_priority.sql
- migrations/postgresql/001_init.sql
- migrations/postgresql/002_priority.sql
frontend:
delivery:
type: bundle
buildRequired: false
bundle:
directory: web/dist
entry: index.html
spaFallback: true
menus:
- name: 持久化任务示例
path: task_demo
component: PluginFrame
type: C
icon: list
perms: task_demo:view
permissions:
- code: task_demo:view
name: 查看任务
- code: task_demo:write
name: 编辑任务
compatibility:
hostApiVersion: ^1.4.0
pythonVersion: '>=3.10'
databases:
- mysql
- postgresql
@@ -0,0 +1,12 @@
{
"schemaVersion": 1,
"files": [
"__init__.py",
"models.py",
"service.py",
"migrations/mysql/001_init.sql",
"migrations/mysql/002_priority.sql",
"migrations/postgresql/001_init.sql",
"migrations/postgresql/002_priority.sql"
]
}
@@ -0,0 +1,134 @@
from collections.abc import Mapping
from typing import Any
from uuid import uuid4
from fastapi import HTTPException
from sqlalchemy import delete, func, insert, select, update
from plugins.core.sdk import PluginRequestContext
from utils.time_util import TimezoneUtil
from .models import TaskQuery, tasks
def can_write(context: PluginRequestContext) -> bool:
"""
判断当前用户是否能够修改任务。
:param context: 宿主提供的请求身份
:return: 是否具有写权限或管理员通配权限
"""
return bool({'task_demo:write', '*:*:*'} & context.permissions)
def _task_payload(row: Mapping[str, Any]) -> dict[str, Any]:
"""
将数据库任务转换为跨语言接口字段及 UTC 时间。
:param row: 数据库任务记录
:return: 可序列化的任务对象
"""
return {
'id': row['id'],
'title': row['title'],
'description': row['description'],
'status': row['status'],
'priority': row['priority'],
'createdAt': TimezoneUtil.format_rfc3339(row['created_at']),
'updatedAt': TimezoneUtil.format_rfc3339(row['updated_at']),
}
async def list_tasks(context: PluginRequestContext, query: TaskQuery) -> dict[str, Any]:
"""
在独立事务中读取筛选后的总数与当前页。
:param context: 宿主提供的请求身份
:param query: 已校验的分页与状态参数
:return: 任务分页结果和当前用户写权限
"""
filters = [tasks.c.status == query.status] if query.status else []
async with context.transaction() as transaction:
db = transaction.transaction_session()
total = await db.scalar(select(func.count()).select_from(tasks).where(*filters))
result = await db.execute(
select(tasks)
.where(*filters)
.order_by(tasks.c.created_at.desc(), tasks.c.id.desc())
.offset((query.page - 1) * query.page_size)
.limit(query.page_size)
)
items = [_task_payload(row) for row in result.mappings()]
return {
'items': items,
'total': total,
'page': query.page,
'pageSize': query.page_size,
'canWrite': can_write(context),
}
async def get_task(context: PluginRequestContext, task_id: str) -> dict[str, Any]:
"""
在独立事务中读取指定任务。
:param context: 宿主提供的请求身份
:param task_id: 已校验的任务标识
:return: 任务对象;不存在时返回 HTTP 404
"""
async with context.transaction() as transaction:
result = await transaction.transaction_session().execute(select(tasks).where(tasks.c.id == task_id))
row = result.mappings().one_or_none()
if row is None:
raise HTTPException(status_code=404, detail='任务不存在')
return _task_payload(row)
async def create_task(context: PluginRequestContext, values: dict[str, Any]) -> dict[str, Any]:
"""
在独立事务中写入任务,成功提交后返回任务对象。
:param context: 宿主提供的请求身份
:param values: 已校验的任务输入
:return: 新建的任务对象
"""
now = TimezoneUtil.to_utc_milliseconds(TimezoneUtil.utc_now())
row = {**values, 'id': uuid4().hex, 'created_at': now, 'updated_at': now}
async with context.transaction() as transaction:
await transaction.transaction_session().execute(insert(tasks).values(**row))
return _task_payload(row)
async def update_task(context: PluginRequestContext, task_id: str, values: dict[str, Any]) -> dict[str, Any]:
"""
在独立事务中完整更新任务,保留创建时间。
:param context: 宿主提供的请求身份
:param task_id: 已校验的任务标识
:param values: 已校验的任务输入
:return: 更新后的任务对象;不存在时返回 HTTP 404
"""
async with context.transaction() as transaction:
db = transaction.transaction_session()
result = await db.execute(
update(tasks).where(tasks.c.id == task_id).values(**values, updated_at=TimezoneUtil.utc_now())
)
if result.rowcount == 0:
raise HTTPException(status_code=404, detail='任务不存在')
result = await db.execute(select(tasks).where(tasks.c.id == task_id))
return _task_payload(result.mappings().one())
async def delete_task(context: PluginRequestContext, task_id: str) -> dict[str, bool]:
"""
在独立事务中删除指定任务。
:param context: 宿主提供的请求身份
:param task_id: 已校验的任务标识
:return: 删除确认;不存在时返回 HTTP 404
"""
async with context.transaction() as transaction:
result = await transaction.transaction_session().execute(delete(tasks).where(tasks.c.id == task_id))
if result.rowcount == 0:
raise HTTPException(status_code=404, detail='任务不存在')
return {'deleted': True}
@@ -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 },
}