mirror of
https://github.com/insistence/RuoYi-Vue3-FastAPI.git
synced 2026-10-15 21:35:31 +00:00
feat: 支持 v2 插件、独立前端和签名制品发布 (#130)
* feat: 支持 v2 插件、原生扩展与签名发布 * fix: 修复测试 * feat: 增强宿主 SDK、运行观测与制品维护能力 * feat: 增强文件传输、配置观测与本地开发能力 * feat: 支持 SSE 实时事件流并完善开发脚手架 * feat: 完善长连接鉴权复核与关闭回收 * feat: 增强生命周期与任务关闭可靠性 * feat: 收紧健康检查契约并补齐前端与真实网络验收 * feat: 增加SDK溯源与离线更新工具并验证跨版本兼容 * feat: 增加持久化业务示例与受控 Python 交付 * feat: 增加统一运行诊断与多worker状态观测 * feat: 增加固定目标发布等待与自动验收 * fix: 修复多环境插件部署前缀与代理配置不一致 * fix: 修复测试
This commit is contained in:
@@ -0,0 +1,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;
|
||||
+3
@@ -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'));
|
||||
+9
@@ -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'))
|
||||
);
|
||||
+3
@@ -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 },
|
||||
}
|
||||
Reference in New Issue
Block a user