mirror of
https://github.com/insistence/RuoYi-Vue3-FastAPI.git
synced 2026-10-15 05:19:14 +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,33 @@
|
||||
# ASGI 子应用示例
|
||||
|
||||
本示例验证 v2 显式入口、子应用挂载、宿主 Bearer 认证、请求权限及 lifespan。它只提供 API;需要已接入的 PluginFrame、独立打包页面与浏览器会话桥时,参见 [bundle_demo](../bundle_demo/README.md)。
|
||||
|
||||
示例要求 Host API `^1.3.0`。`POST /apps/asgi_demo/api/echo/item-7?tag=first&tag=second` 使用相同的 `asgi_demo:view` 权限,接收 `{"message":"你好"}` JSON 请求体(消息长度 1–200),返回路径参数、多值查询参数、校验后的请求体及 `requestId`。实现通过 `plugin_endpoint(..., body_model=EchoBody)` 演示统一校验;认证方式与 `/api/info` 相同。
|
||||
|
||||
清单、入口与 SDK 约定见[插件开发手册](../../../../docs/plugin_development.md#510-v2-清单与能力组合),资源初始化和关闭方式见[ASGI 生命周期说明](../../../../docs/plugin_development.md#67-asgi-子应用与生命周期)。
|
||||
|
||||
以下命令在后端目录执行,适用于 Windows PowerShell、macOS 和 Linux。先按 [CLI 环境准备](../../../../docs/cli_usage.md#22-安装依赖)激活已安装宿主依赖的 Python 环境。
|
||||
|
||||
1. 将此目录复制到后端 `plugins/asgi_demo`。
|
||||
2. 在已激活的 Python 环境中执行 `ruoyi plugin check asgi_demo --env=dev`。
|
||||
3. 使用现有安装与启用命令执行 `ruoyi plugin install asgi_demo --env=dev --yes` 和 `ruoyi plugin enable asgi_demo --env=dev --yes`。
|
||||
4. 重启后端。按原有角色权限管理授予 `asgi_demo:view`;管理员具有通配权限。
|
||||
5. 请求 `GET /apps/asgi_demo/api/info`,携带当前宿主登录的 `Authorization: Bearer <token>`。若启用了传输加密,仍需遵守宿主加密协议。
|
||||
|
||||
入口函数快速返回 PluginDefinition,资源在 lifespan 中初始化和关闭。API 从 request.state.plugin_context 获取宿主鉴权后的身份,执行具体权限检查。数据库会话通过 host.session_factory 按请求创建,不在应用实例内保存请求会话。
|
||||
|
||||
服务模式仍执行既有生命周期写入限制;示例命令用于开发环境,不应将生产配置切到 dev 以绕过限制。生产变更使用维护部署流程。当前没有热挂载或热卸载。
|
||||
|
||||
## 签名制品交付
|
||||
|
||||
本例是 Python 源码插件,签名不改变源码交付方式。使用[开发手册第 15 节](../../../../docs/plugin_development.md#15-v2-签名制品与维护发布)配置宿主制品存储和外部发布者公钥,并授权 `asgi_demo`。发布者私钥必须在插件目录之外,以下路径需替换为实际的 Ed25519 PKCS8 PEM:
|
||||
|
||||
```bash
|
||||
ruoyi plugin artifact build plugins/examples/python/asgi_demo target/asgi_demo-1.0.0.rpk --key-file ../signing-keys/publisher.pem --key-id publisher --env=dev --output=json
|
||||
ruoyi plugin artifact verify target/asgi_demo-1.0.0.rpk --env=prod --output=json
|
||||
ruoyi plugin artifact import target/asgi_demo-1.0.0.rpk --env=prod --allow-prod --yes --output=json
|
||||
```
|
||||
|
||||
导入只验签、检查并登记不可变目录,不加载子应用、不运行迁移或 Hook。选择制品部署时不能同时保留 `plugins/asgi_demo` 源码目录;如已按上面的开发步骤安装过,需在维护窗口处理目录冲突,并为缺少制品准备证据的既有安装提升版本。
|
||||
|
||||
后续严格使用导入返回的 digest,停止全部 worker 后执行 `release prepare`,从最新状态读取 generation,再执行 `release select` 并重启。每个 worker 分别启动 lifespan,只有实际报告匹配目标并就绪才算发布完成。生产操作不支持热挂载/卸载;代码回滚也不降级数据库。完整命令与签名轮换、中断恢复方式均见开发手册。
|
||||
@@ -0,0 +1,79 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from plugins.core.sdk import PluginDefinition, PluginHostContext, PluginRequestContext, plugin_endpoint
|
||||
|
||||
|
||||
class EchoBody(BaseModel):
|
||||
"""
|
||||
SDK 请求体模型,约束示例消息长度。
|
||||
"""
|
||||
|
||||
message: str = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
async def echo(context: PluginRequestContext) -> dict[str, Any]:
|
||||
return {**context.request.to_payload(), 'requestId': context.request_id}
|
||||
|
||||
|
||||
def create_plugin(host: PluginHostContext) -> PluginDefinition:
|
||||
"""
|
||||
声明子应用工厂,资源初始化交给子应用生命周期。
|
||||
|
||||
:param host: 宿主提供的插件上下文
|
||||
:return: 插件能力声明
|
||||
"""
|
||||
return PluginDefinition(app_factory=create_app)
|
||||
|
||||
|
||||
def create_app(host: PluginHostContext) -> FastAPI:
|
||||
"""
|
||||
为当前 worker 创建独立的 ASGI 子应用。
|
||||
|
||||
:param host: 宿主提供的插件上下文
|
||||
:return: 包含权限检查和生命周期管理的子应用
|
||||
"""
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[dict[str, str], None]:
|
||||
"""
|
||||
演示插件自有资源的启动与关闭。
|
||||
|
||||
:param app: 正在启动或关闭的 ASGI 子应用
|
||||
:return: 可供请求访问的生命周期状态
|
||||
"""
|
||||
host.logger.info('ASGI 示例启动')
|
||||
try:
|
||||
yield {'message': '子应用资源已就绪'}
|
||||
finally:
|
||||
host.logger.info('ASGI 示例关闭')
|
||||
|
||||
app = FastAPI(title='ASGI 插件示例', lifespan=lifespan, docs_url=None, redoc_url=None)
|
||||
|
||||
@app.get(
|
||||
'/api/info',
|
||||
summary='获取ASGI插件信息接口',
|
||||
description='用于获取当前插件标识、宿主API版本和子应用资源状态',
|
||||
)
|
||||
async def info(request: Request) -> dict[str, str]:
|
||||
context = request.state.plugin_context
|
||||
try:
|
||||
context.require_permission('asgi_demo:view')
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
return {'pluginId': host.plugin_id, 'hostApiVersion': host.api_version, 'message': request.state.message}
|
||||
|
||||
app.add_api_route(
|
||||
'/api/echo/{item_id}',
|
||||
plugin_endpoint(echo, permission='asgi_demo:view', body_model=EchoBody),
|
||||
methods=['POST'],
|
||||
summary='验证ASGI插件请求上下文接口',
|
||||
description='返回校验后的请求体、路径参数、多值查询参数与宿主追踪标识',
|
||||
response_model=dict[str, Any],
|
||||
)
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,22 @@
|
||||
manifestVersion: 2
|
||||
id: asgi_demo
|
||||
name: ASGI 子应用示例
|
||||
version: 1.0.0
|
||||
description: 使用显式入口和宿主认证的独立 API 子应用。
|
||||
backend:
|
||||
runtime: python
|
||||
integration: asgi
|
||||
module: plugins.asgi_demo
|
||||
entrypoint: plugins.asgi_demo:create_plugin
|
||||
asgi:
|
||||
mountPath: /apps/asgi_demo
|
||||
lifespan: managed
|
||||
frontend:
|
||||
delivery:
|
||||
type: none
|
||||
permissions:
|
||||
- code: asgi_demo:view
|
||||
name: 查看 ASGI 示例
|
||||
compatibility:
|
||||
hostApiVersion: ^1.3.0
|
||||
pythonVersion: '>=3.10'
|
||||
@@ -0,0 +1,239 @@
|
||||
# 独立打包前端插件示例
|
||||
|
||||
本示例使用 **Python ASGI 后端 + 独立 Vite 前端 bundle**。页面通过宿主 `PluginFrame` 打开,取得宿主主题、语言、时区和路由状态,再通过通信桥调用查询、回显、上传检查、报表下载和 SSE 事件 API。欢迎语展示按需配置读取的用法;业务接口不保存消息或文件内容。
|
||||
|
||||
通用清单与后端入口见[插件开发手册](../../../../docs/plugin_development.md#510-v2-清单与能力组合),浏览器集成协议见[bundle 页面与 SDK](../../../../docs/plugin_development.md#72-v2-独立-bundle-页面)。
|
||||
|
||||
前端交付 `web/dist`,不要求把插件页面加入宿主 Vue 源码或重新构建宿主。后端 `__init__.py` 仍是可读 Python 业务源码;需要保护后端源码时使用 [Rust 原生示例](../../rust/rust_demo/README.md),并按 `native + asgi + bundle` 组合交付。编译前端也不保证浏览器端逻辑不可逆向。
|
||||
|
||||
## 构建前端
|
||||
|
||||
以下命令从仓库的 `ruoyi-fastapi-backend` 目录执行。Python 命令使用已安装宿主依赖的环境,准备步骤见 [CLI 环境准备](../../../../docs/cli_usage.md#22-安装依赖)。通用命令适用于 Windows PowerShell、macOS 和 Linux;Shell 语法不同的操作分别列出。构建端需已安装 Node.js/npm;部署端只使用构建产物,无需 Node.js。示例使用独立 `package.json`,不会把构建依赖写入宿主前端。
|
||||
|
||||
```bash
|
||||
npm --prefix plugins/examples/python/bundle_demo/web install
|
||||
npm --prefix plugins/examples/python/bundle_demo/web run build
|
||||
```
|
||||
|
||||
首次安装会生成此示例自己的锁文件;独立维护插件时应保留锁文件,在 CI 中使用 `npm ci`。示例的 SDK 别名指向本仓库 `ruoyi-fastapi-frontend/src/utils/pluginBridge.js`。迁移到独立插件仓库时,将该模块及相邻的 `pluginBridge.d.ts` 一起复制为自己的版本化源码依赖,并调整 `vite.config.js` 中 `@ruoyi/plugin-bridge` 的别名。TypeScript 项目还应在自己的 `tsconfig.json` 中将同一别名映射到复制后的模块路径。
|
||||
|
||||
如果本仓库宿主前端依赖已安装,也可复用其中的 Vite 可执行文件构建**同一个插件前端项目**:
|
||||
|
||||
```bash
|
||||
node ../ruoyi-fastapi-frontend/node_modules/vite/bin/vite.js build plugins/examples/python/bundle_demo/web --config plugins/examples/python/bundle_demo/web/vite.config.js
|
||||
```
|
||||
|
||||
两种方式均输出 `plugins/examples/python/bundle_demo/web/dist`。配置使用 `base: './'`、关闭 sourcemap;宿主交付 HTML 时注入有效 `<base>` 和运行基址,因此资源和页面深层链接不需写死域名或代理前缀。直接用 `file://` 打开 dist 不会建立宿主身份桥;真实账号交互应从管理平台的插件菜单进入。
|
||||
|
||||
## 本地模拟宿主
|
||||
|
||||
安装示例依赖后,从后端目录运行:
|
||||
|
||||
```bash
|
||||
npm --prefix plugins/examples/python/bundle_demo/web run dev
|
||||
```
|
||||
|
||||
开发服务只监听本机,自动打开 `/dev.html`。模拟宿主使用真实通信桥,提供主题切换、0–5000 毫秒延迟、故障注入、页面重载和退出;上传检查、下载与逐条事件在浏览器内存中完成,不调用实际后端。事件间隔最短 100 毫秒,可停止后按游标继续。刷新整个页面可恢复模拟退出后的会话。
|
||||
|
||||
`web/dev/mockRequest.js` 是可扩展的纯请求适配器,可按业务补充本地响应;真实插件界面位于 `web/src/main.js`。Vite 仅在开发服务为插件入口注入模拟标识,生产构建只包含插件入口和其依赖,不包含开发宿主。模拟环境不能验证真实登录、权限、CSRF、反向代理或传输加密,这些仍需在宿主内验证。
|
||||
|
||||
## 离线目录包
|
||||
|
||||
构建完成后,只打包清单、Python 后端文件和前端构建产物。选择当前平台对应的命令创建新目录;两种写法均在输出已存在时停止,避免覆盖已有发布包。
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
$bundleSource = (Resolve-Path 'plugins/examples/python/bundle_demo').Path
|
||||
$bundleDist = Join-Path $bundleSource 'web/dist'
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $bundleDist 'index.html'))) {
|
||||
throw '请先构建 bundle_demo 前端'
|
||||
}
|
||||
$bundleRelease = Join-Path (Get-Location).Path 'target/bundle-demo-1.0.0'
|
||||
if (Test-Path -LiteralPath $bundleRelease) {
|
||||
throw '输出目录已存在,请选择新的发布目录'
|
||||
}
|
||||
$bundlePackage = Join-Path $bundleRelease 'bundle_demo'
|
||||
New-Item -ItemType Directory -Path (Join-Path $bundlePackage 'web') -Force | Out-Null
|
||||
Copy-Item -LiteralPath (Join-Path $bundleSource 'plugin.yaml') -Destination $bundlePackage
|
||||
Copy-Item -LiteralPath (Join-Path $bundleSource '__init__.py') -Destination $bundlePackage
|
||||
Copy-Item -LiteralPath (Join-Path $bundleSource 'files.py') -Destination $bundlePackage
|
||||
Copy-Item -LiteralPath (Join-Path $bundleSource 'events.py') -Destination $bundlePackage
|
||||
Copy-Item -LiteralPath $bundleDist -Destination (Join-Path $bundlePackage 'web') -Recurse
|
||||
Compress-Archive -LiteralPath $bundlePackage -DestinationPath (Join-Path $bundleRelease 'bundle_demo-1.0.0.zip')
|
||||
```
|
||||
|
||||
macOS / Linux(Bash、Zsh):
|
||||
|
||||
```bash
|
||||
(
|
||||
set -eu
|
||||
bundleSource='plugins/examples/python/bundle_demo'
|
||||
bundleDist="$bundleSource/web/dist"
|
||||
if [ ! -f "$bundleDist/index.html" ]; then
|
||||
printf '%s\n' '请先构建 bundle_demo 前端' >&2
|
||||
exit 1
|
||||
fi
|
||||
bundleRelease='target/bundle-demo-1.0.0'
|
||||
if [ -e "$bundleRelease" ]; then
|
||||
printf '%s\n' '输出目录已存在,请选择新的发布目录' >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p target
|
||||
mkdir "$bundleRelease"
|
||||
bundlePackage="$bundleRelease/bundle_demo"
|
||||
mkdir -p "$bundlePackage/web"
|
||||
cp "$bundleSource/plugin.yaml" "$bundleSource/__init__.py" "$bundleSource/files.py" "$bundleSource/events.py" "$bundlePackage/"
|
||||
cp -R "$bundleDist" "$bundlePackage/web/dist"
|
||||
python -m zipfile -c "$bundleRelease/bundle_demo-1.0.0.zip" "$bundlePackage"
|
||||
)
|
||||
```
|
||||
|
||||
ZIP 内布局:
|
||||
|
||||
```text
|
||||
bundle_demo/
|
||||
plugin.yaml
|
||||
__init__.py
|
||||
files.py
|
||||
events.py
|
||||
web/
|
||||
dist/
|
||||
index.html
|
||||
assets/...
|
||||
```
|
||||
|
||||
包不包含 `web/src`、`node_modules`、Vite 配置或 npm 清单。这里的普通 ZIP 供开发环境复制部署,不是签名 `.rpk`;生产维护发布应继续对准备好的 `bundle_demo` 目录签名,不能直接把这个 ZIP 当作已验签制品。
|
||||
|
||||
## 签名制品与维护发布
|
||||
|
||||
先按[开发手册第 15 节](../../../../docs/plugin_development.md#15-v2-签名制品与维护发布)配置宿主制品功能及外部发布者信任文件,并授权 `bundle_demo`。发布者准备位于插件目录之外的 Ed25519 PKCS8 PEM 私钥,再执行:
|
||||
|
||||
```bash
|
||||
ruoyi plugin artifact build target/bundle-demo-1.0.0/bundle_demo target/bundle_demo-1.0.0.rpk --key-file ../signing-keys/publisher.pem --key-id publisher --env=dev --output=json
|
||||
ruoyi plugin artifact verify target/bundle_demo-1.0.0.rpk --env=prod --output=json
|
||||
ruoyi plugin artifact import target/bundle_demo-1.0.0.rpk --env=prod --allow-prod --yes --output=json
|
||||
```
|
||||
|
||||
签名私钥路径仅作示例;包内公钥不能自行建立信任。输入为已经剔除前端工程的部署目录,不能直接传入包含 `web/src`、`package.json` 或 `node_modules` 的开发目录。本例 Python 后端仍以源码交付,前端以 `web/dist` 交付,签名并不加密这些文件。
|
||||
|
||||
导入成功后,制品保存在 `<store>/bundle_demo/<version>/<digest>/payload/bundle_demo`;验签、现有文件复验和发布者授权按当前宿主信任配置执行。与 `plugins/bundle_demo` 源码目录不能共存,导入也不等于安装或激活。继续使用返回的精确 digest 查看发布计划,停止全部 worker 后准备迁移/Hook,读取最新 generation 并选择目标,最后启动全部 worker 检查状态。相同版本缺少匹配 digest 的准备证据时应提升版本,不允许猜测已安装内容。
|
||||
|
||||
制品目录保持不可变,不手工替换页面资源、入口或 Python 模块。代码回滚要求旧代码兼容现有数据结构,不会回退数据库。相同内容重签不改变 digest,可通过 `artifact rotate-signature` 在维护模式下轮换签名;`artifact inspect/prune/reconcile` 提供预览、受保护清理和中断恢复。上述写操作仍需停止全部 worker,不自动热卸载或停止进程。完整流程和故障处理见开发手册。
|
||||
|
||||
## 在开发环境安装
|
||||
|
||||
1. 首次升级宿主时,构建包含 `PluginFrame` 的宿主前端;后续新增或更新此 bundle 不需要重建宿主。
|
||||
2. 将交付目录中的 `bundle_demo` 复制到后端 `plugins/bundle_demo`,确保 `web/dist/index.html` 已存在。不要把整个开发源码目录当成交付要求。
|
||||
3. 在已激活并安装宿主依赖的 Python 环境、后端目录执行(三种平台通用):
|
||||
|
||||
```bash
|
||||
ruoyi plugin check bundle_demo --env=dev
|
||||
ruoyi plugin install bundle_demo --env=dev --yes
|
||||
ruoyi plugin enable bundle_demo --env=dev --yes
|
||||
```
|
||||
|
||||
4. 重启后端;在现有角色权限管理中授予 `bundle_demo:view`,或使用具有通配权限的管理员。
|
||||
5. 重新取得用户菜单后打开“独立前端示例”。页面会显示实际登录用户、服务端时间,并支持有权限的 POST 回显。
|
||||
|
||||
`plugin.yaml` 使用 Host API `^1.4.0`,供欢迎语接口调用 `host.read_config()`。基础 bundle 协议仍兼容 `^1.2.0`;文件与 SSE 能力由前端握手协商,首次使用时需要更新并构建支持这些能力的宿主前端。菜单组件为 `PluginFrame`,后端根据 `sys_plugin_menu` 的真实关联返回 `meta.pluginId`;不从路径或 `query.pluginId` 推测插件。只有已启用、已激活且用户有权限的 bundle 可以签发浏览器会话。
|
||||
|
||||
生产模式继续遵守现有生命周期写操作限制,应通过维护部署流程更新并重启。不要把生产配置切到 dev 来绕过限制,也不要期待复制目录后自动热挂载。
|
||||
|
||||
## 浏览器 SDK
|
||||
|
||||
实际用例见 [web/src/main.js](web/src/main.js)。`createPluginClient` 只在同源宿主 iframe 中使用,初始化配置由宿主注入:
|
||||
|
||||
```javascript
|
||||
import { createPluginClient } from '@ruoyi/plugin-bridge'
|
||||
|
||||
const config = JSON.parse(document.getElementById('ruoyi-plugin-config').textContent)
|
||||
const client = createPluginClient({ pluginId: config.pluginId })
|
||||
const context = await client.ready
|
||||
// context 包含 theme、language、timeZone、route、uiBase、apiBase。
|
||||
|
||||
const summary = await client.request({ method: 'GET', path: 'summary' })
|
||||
const echo = await client.request({
|
||||
method: 'POST',
|
||||
path: 'echo',
|
||||
data: { message: `你好,${summary.userName}` },
|
||||
})
|
||||
console.log(echo.message)
|
||||
|
||||
const unsubscribe = client.subscribe(({ type, payload }) => {
|
||||
if (type === 'preferences') applyPreferences(payload)
|
||||
if (type === 'route') showPage(payload.route)
|
||||
if (type === 'refresh') refreshData()
|
||||
if (type === 'logout') showLoggedOut()
|
||||
})
|
||||
client.navigate('/details')
|
||||
|
||||
window.addEventListener('pagehide', () => {
|
||||
unsubscribe()
|
||||
client.destroy()
|
||||
}, { once: true })
|
||||
```
|
||||
|
||||
`applyPreferences`、`showPage`、`refreshData`、`showLoggedOut` 由插件实现。路由通知只更新插件页面状态;示例将 `/details` 同步到宿主 `pluginRoute` 查询参数,并由订阅回调切换内容。
|
||||
|
||||
`request` 的 `path` 必须相对于本插件 `apiBase`,例如 `summary`、`reports/month`;不能是完整 URL、以 `/` 开头、包含 `..` 或自行拼接查询串。查询参数使用 `params: { page: 1 }`。方法使用大写 `GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS`;`GET/HEAD` 不允许 `data`。可在第二个参数传入 `{ signal: controller.signal }` 取消请求。
|
||||
|
||||
返回值与宿主请求客户端的 JSON 返回值一致。本例 API 直接返回 JSON 对象;若自己的 API 使用 `{ code, data, msg }`,桥保留该结构,不额外替插件解包 `data`。JSON 请求和文件元数据每条最多 64 KiB,所有请求合计最多 8 个待处理任务,JSON 默认 15 秒、文件默认 120 秒超时。SSE 通过 `stream` 单独接收,不支持任意字节流或 WebSocket 代理。
|
||||
|
||||
```javascript
|
||||
const controller = new AbortController()
|
||||
const options = {
|
||||
signal: controller.signal,
|
||||
onProgress: ({ loaded, total }) => console.log(loaded, total),
|
||||
}
|
||||
const file = document.getElementById('file').files[0]
|
||||
const inspection = await client.upload({ path: 'files/inspect', file }, options)
|
||||
const report = await client.download({ path: 'files/report' }, options)
|
||||
```
|
||||
|
||||
单文件最多 10 MiB;`upload` 接受 File/Blob,宿主重建 multipart,默认字段名 `file`、方法 POST,可使用 `fieldName/filename/fields/params`,其中 `fields` 的值只能是字符串。后端 `files.py` 检查权限及实际字节数,返回 SHA-256 摘要,不保存内容。`download` 返回 Blob,由页面决定保存名称并释放对象 URL。进度的 `total` 可能为 `null`;上传字节数可能包含 multipart 开销,进度到达 100% 后仍应等待 Promise 完成。页面已提供取消按钮、错误反馈和重试入口。旧宿主未公布 `capabilities.files.version=1` 时,SDK 会明确拒绝文件传输。
|
||||
|
||||
## SSE 实时事件
|
||||
|
||||
页面“接收实时事件”通过 `client.stream({ path: 'events', params: { count: 10 }, lastEventId }, { signal, onEvent })` 逐条显示最近 5 条事件;“停止接收”取消连接,“继续接收”传回最后成功处理的 id。正常完成后重新开始,页面重载会清空游标。`events.py` 的 GET `/api/events` 要求 `bundle_demo:view` 权限,默认每 0.5 秒发送一条 `tick`,最多 20 条。游标是事件编号;补发仅演示后续编号,重新生成服务端时间,不代表持久化历史重放。
|
||||
|
||||
`onEvent` 接收 `{ event, data, id }`,其中 `data` 为字符串,可返回 Promise。宿主等待处理完成才发送下一条,回调失败或超过 15 秒会关闭连接。每页最多 2 条流、每条最多 5 分钟,单事件最多 64 KiB、累计最多 10 MiB;超限或异常会拒绝返回的 Promise,正常 EOF 则完成。SDK 不自动重连;按业务实现退避、去重及恢复,并为重试创建新的 AbortController。恢复用 `lastEventId` 只接受最多 1024 个可打印 ASCII 字符。旧宿主未公布 `capabilities.streams.version=1` 时会明确拒绝,需要更新宿主前端。完整调用示例见[浏览器 SDK](../../../../docs/plugin_development.md#73-bundle-浏览器-sdk)。
|
||||
|
||||
## 配置读取与状态
|
||||
|
||||
安装后在管理页修改“页面欢迎语”,保存后回到本插件点击“刷新状态”。查询接口调用 `await context.host.read_config()`,从新的数据库会话取得当前插件的配置快照,并仅向页面返回公开的欢迎语字段;配置中的敏感值不能直接交给浏览器。
|
||||
|
||||
`host.config` 和 `host.config_revision` 始终保留启动快照,按需读取不会修改它们。管理页展示保存版本、各进程启动版本和最近按需读取版本;“待重启”针对启动快照,不否定本示例主动读取的欢迎语已经更新。连接池等启动资源需要插件自行应用或按部署流程重启。状态只有已观测进程的证据,不代表全部 worker 已经一致,详见[配置生效状态](../../../../docs/plugin_development.md#81-启动快照按需读取与生效状态)。
|
||||
|
||||
## 会话、权限与传输加密
|
||||
|
||||
`PluginFrame` 用宿主 Bearer 调用 `POST /plugin/runtime/bundle_demo/session`,取得最长 300 秒的短期会话,浏览器保存路径受限的 `HttpOnly; SameSite=Strict` Cookie,HTTPS 下添加 `Secure`。Redis 条目绑定用户、插件、版本、origin 与主会话摘要,不复制主 token;主登录剩余有效期更短时使用更短期限。多标签续期复用同一会话,不相互替换 CSRF。
|
||||
|
||||
UI、静态文件、API 每次访问均经过宿主门禁并回查主登录和当前权限。退出、主登录替换、插件停用或版本改变会使后续请求失效。具体 API 继续使用 `plugin_endpoint(..., permission='bundle_demo:view')` 或 `request.state.plugin_context.require_permission(...)`;仅隐藏菜单不能代替 API 权限。
|
||||
|
||||
子页面通过 `postMessage` 发送受校验的 JSON、文件或事件消息;父页面的 JSON/文件使用已有 `request` 客户端,SSE 使用增量 fetch 适配器。两者禁用主 Bearer,自动发送插件 Cookie 并添加 `X-Plugin-CSRF`。写请求同时校验精确同源 Origin。主 token、Cookie 和 CSRF 不进入 bridge 消息或插件 URL。Cookie 也不能替代其他宿主接口的 Bearer。
|
||||
|
||||
只读 `/apps/bundle_demo/ui/` 使用普通 HTTPS,HTML 当前 `Cache-Control: no-store`,其他静态文件 `no-cache`;资源仍需认证。`/apps/bundle_demo/api/...` 与会话签发接口继续遵守宿主传输加密,不能把 `/apps` 整段排除。真实加密测试已覆盖 JSON 信封、Cookie、CSRF、完整 API AAD 以及代理前缀。
|
||||
|
||||
文件上传下载不使用 JSON 加密信封,依赖 HTTPS 传输。若启用 `required` 模式,须在已有 `TRANSPORT_CRYPTO_EXCLUDE_PATHS` 列表中追加 `/apps/bundle_demo/api/files/inspect,/apps/bundle_demo/api/files/report` 两个精确端点;后端标准化路径不含代理前缀。保留现有例外,不排除整个插件 API;未经配置的文件请求仍会被拒绝。插件身份、权限、Origin 和 CSRF 校验在例外端点上仍然执行。反向代理也应设置符合业务要求的文件大小限制。
|
||||
|
||||
同源插件属于可信代码。iframe 仅隔离布局和依赖;它不是阻止恶意插件读取同源数据的安全沙箱。现有主 token 在宿主前端可读,“桥不传 token”不等于不可信同源代码无法接触宿主凭证。
|
||||
|
||||
SSE 同样依赖 HTTPS,不支持 JSON 加密信封;当前策略会加密事件端点时,需在既有例外列表中追加精确路径 `/apps/bundle_demo/api/events`(不含代理前缀),保留其他 API 的加密。宿主会先检查策略,拒绝未配置的流请求,不会自动降级。反向代理须关闭该接口的响应缓冲并配置超时,示例同时返回 `X-Accel-Buffering: no`。宿主退出、重载和会话失效会取消当前页面的流。
|
||||
|
||||
ASGI 网关对已建立的 SSE/WebSocket 按 15 秒间隔复核登录、插件启用状态和权限,单次复核设置 5 秒超时,不续期主登录。权限减配、会话失效、查询失败或超时会关闭连接。SSE 的保留事件 `ruoyi.plugin.closed` 由宿主适配器拦截,页面显示“实时连接已由宿主关闭”,不会误报正常完成;已处理游标仍保留,重新连接须通过当前身份和权限检查。业务不要使用该保留事件名。更新后端时应同步更新宿主前端;旧适配器不能识别关闭通知。示例默认 10 条事件约 5 秒即完成,通常不会触发周期复核;长时间运行的业务应按[生命周期说明](../../../../docs/plugin_development.md#67-asgi-子应用与生命周期)处理取消与资源释放。
|
||||
|
||||
## 代理前缀与验证
|
||||
|
||||
默认 `VITE_APP_PLUGIN_BASE` 为空,浏览器从当前源访问 `/apps/` 和 `/plugin/runtime/`。宿主 Vite 已提供这两条保留原路径、Host 的开发代理,Docker Nginx 示例也已更新。
|
||||
|
||||
如果部署使用 `/prefix`,同时设置后端 `APP_ROOT_PATH=/prefix` 和宿主 `VITE_APP_PLUGIN_BASE=/prefix`,代理入口对应为 `/prefix/apps/`、`/prefix/plugin/runtime/`,转发时保留前缀与外部 Host。Cookie Path 和注入的运行基址会包含 `/prefix`;请求 AAD 剥离该前缀后仍保留 `/apps/bundle_demo/api/...`,不能缩短为 `/api/...`。可信代理和 ASGI 服务器须正确传递外部 HTTPS scheme,否则 Origin 或 Secure Cookie 行为会不一致。
|
||||
|
||||
从后端目录可执行定向验证:
|
||||
|
||||
```bash
|
||||
python -m pytest tests/plugins/core/runtime/test_bundle.py tests/plugins/core/runtime/test_browser_session.py tests/plugins/core/runtime/test_bundle_transport.py tests/plugins/core/runtime/test_bundle_files.py tests/plugins/core/runtime/test_bundle_events.py tests/plugins/core/runtime/test_connections.py tests/plugins/core/runtime/test_connection_sessions.py tests/plugins/core/runtime/test_configuration.py tests/module_admin/service/test_login_plugin_routes.py -q
|
||||
npm --prefix ../ruoyi-fastapi-frontend run test:plugin
|
||||
```
|
||||
|
||||
测试覆盖静态边界、Cookie/CSRF/退出、真实 RSA/AES 协议、文件大小、`root_path`、流连接复核及运行时关闭顺序;浏览器会话测试替换真实账号查询,配置读取测试使用隔离 SQLite 验证保存、解密及重启版本。实际角色配置、TLS/Nginx、多 worker 和 ASGI 服务器的优雅停机阶段仍需部署验收。网关的周期关闭与协作式取消不能强制终止忽略取消的 Python 代码,也不提供跨 worker 的即时撤销广播。
|
||||
@@ -0,0 +1,67 @@
|
||||
from fastapi import FastAPI, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from plugins.core.sdk import PluginDefinition, PluginHostContext, PluginRequestContext, plugin_endpoint
|
||||
from utils.time_util import TimezoneUtil
|
||||
|
||||
from .events import register_event_routes
|
||||
from .files import register_file_routes
|
||||
|
||||
|
||||
class EchoInput(BaseModel):
|
||||
"""
|
||||
用于验证 POST 链路的回显请求参数。
|
||||
"""
|
||||
|
||||
message: str = Field(min_length=1, max_length=80)
|
||||
|
||||
|
||||
async def summary(context: PluginRequestContext) -> dict[str, str]:
|
||||
snapshot = await context.host.read_config()
|
||||
return {
|
||||
'pluginId': context.host.plugin_id,
|
||||
'userName': context.user.user.user_name,
|
||||
'serverTime': TimezoneUtil.utc_now().isoformat(),
|
||||
'greeting': str(snapshot.values.get('greeting', '')),
|
||||
'configRevision': snapshot.revision,
|
||||
}
|
||||
|
||||
|
||||
def create_plugin(host: PluginHostContext) -> PluginDefinition:
|
||||
"""
|
||||
声明仅提供相对 API 的子应用,页面资源由宿主交付。
|
||||
|
||||
:param host: 宿主提供的插件上下文
|
||||
:return: 插件能力声明
|
||||
"""
|
||||
|
||||
def create_app(context: PluginHostContext) -> FastAPI:
|
||||
"""
|
||||
创建独立前端示例需要的查询和回显接口。
|
||||
|
||||
:param context: 宿主提供的插件上下文
|
||||
:return: 插件 ASGI 子应用
|
||||
"""
|
||||
app = FastAPI(title='独立前端示例', docs_url=None, redoc_url=None)
|
||||
register_file_routes(app)
|
||||
register_event_routes(app)
|
||||
app.add_api_route(
|
||||
'/api/summary',
|
||||
plugin_endpoint(summary, permission='bundle_demo:view'),
|
||||
summary='获取插件页面概览接口',
|
||||
description='用于获取当前插件标识、登录用户名和服务器时间',
|
||||
response_model=dict[str, str],
|
||||
)
|
||||
|
||||
@app.post(
|
||||
'/api/echo',
|
||||
summary='回显插件页面消息接口',
|
||||
description='用于回显插件页面提交的消息并返回服务器时间',
|
||||
)
|
||||
async def echo(payload: EchoInput, request: Request) -> dict[str, str]:
|
||||
request.state.plugin_context.require_permission('bundle_demo:view')
|
||||
return {'message': payload.message, 'serverTime': TimezoneUtil.utc_now().isoformat()}
|
||||
|
||||
return app
|
||||
|
||||
return PluginDefinition(app_factory=create_app)
|
||||
@@ -0,0 +1,54 @@
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query, Request, status
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from utils.time_util import TimezoneUtil
|
||||
|
||||
MAX_CURSOR_LENGTH = 2
|
||||
|
||||
|
||||
def register_event_routes(app: FastAPI, *, permission: str = 'bundle_demo:view', interval: float = 0.5) -> None:
|
||||
"""
|
||||
注册有限时长的事件流示例,通过数字游标演示显式恢复接收。
|
||||
|
||||
:param app: 当前插件的 ASGI 子应用
|
||||
:param permission: 访问事件流所需的接口权限
|
||||
:param interval: 示例事件发送间隔,单位为秒
|
||||
:return: None
|
||||
"""
|
||||
|
||||
@app.get(
|
||||
'/api/events',
|
||||
summary='接收插件示例实时事件接口',
|
||||
description='返回最多 20 条编号事件;Last-Event-ID 指定最后收到的编号,重连仅补发后续编号',
|
||||
response_class=StreamingResponse,
|
||||
)
|
||||
async def events(request: Request, count: Annotated[int, Query(ge=1, le=20)] = 10) -> StreamingResponse:
|
||||
request.state.plugin_context.require_permission(permission)
|
||||
cursor = request.headers.get('last-event-id', '0')
|
||||
if not cursor.isascii() or not cursor.isdigit() or len(cursor) > MAX_CURSOR_LENGTH or int(cursor) > count:
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, detail='事件游标无效')
|
||||
|
||||
async def generate() -> AsyncIterator[str]:
|
||||
"""
|
||||
逐条生成有界示例事件,由 ASGI 响应负责取消断开的请求。
|
||||
|
||||
:return: 已编码的 SSE 事件片段
|
||||
"""
|
||||
for index in range(int(cursor) + 1, count + 1):
|
||||
await asyncio.sleep(interval)
|
||||
data = json.dumps(
|
||||
{'index': index, 'total': count, 'serverTime': TimezoneUtil.utc_now().isoformat()},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
yield f'id: {index}\nevent: tick\ndata: {data}\n\n'
|
||||
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
media_type='text/event-stream',
|
||||
headers={'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no'},
|
||||
)
|
||||
@@ -0,0 +1,87 @@
|
||||
import hashlib
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel, Field
|
||||
from starlette import status
|
||||
from starlette.datastructures import UploadFile
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
from starlette.formparsers import MultiPartException
|
||||
from starlette.types import Message
|
||||
|
||||
MAX_FILE_BYTES = 10 * 1024 * 1024
|
||||
MAX_FORM_BYTES = 64 * 1024
|
||||
|
||||
|
||||
class FileInspection(BaseModel):
|
||||
"""
|
||||
返回文件元数据与摘要,不保存上传内容。
|
||||
"""
|
||||
|
||||
filename: str = Field(description='客户端提交的文件名,仅作展示')
|
||||
size: int = Field(description='文件实际字节数')
|
||||
sha256: str = Field(description='文件内容的 SHA-256 摘要')
|
||||
|
||||
|
||||
def register_file_routes(app: FastAPI, *, permission: str = 'bundle_demo:view') -> None:
|
||||
"""
|
||||
注册有界上传检查和固定报表下载接口,便于验证文件桥的真实 HTTP 链路。
|
||||
|
||||
:param app: 插件所属的子应用
|
||||
:param permission: 两个文件接口要求的插件权限
|
||||
:return: None
|
||||
"""
|
||||
|
||||
@app.post(
|
||||
'/api/files/inspect',
|
||||
summary='检查插件上传文件接口',
|
||||
description='校验权限后接收最多 10 MiB 的单文件,返回摘要且不持久化内容',
|
||||
)
|
||||
async def inspect_file(request: Request) -> FileInspection:
|
||||
request.state.plugin_context.require_permission(permission)
|
||||
received = 0
|
||||
|
||||
async def limited_receive() -> Message:
|
||||
"""
|
||||
累计实际请求体大小,在解析 multipart 之前执行硬上限检查。
|
||||
|
||||
:return: 当前 ASGI 消息
|
||||
"""
|
||||
nonlocal received
|
||||
message = await request.receive()
|
||||
received += len(message.get('body', b''))
|
||||
if received > MAX_FILE_BYTES + MAX_FORM_BYTES:
|
||||
# 使用解析器异常以关闭已创建的临时文件,再在外层转换为 413。
|
||||
raise MultiPartException('上传请求超过大小限制')
|
||||
return message
|
||||
|
||||
bounded_request = Request(request.scope, limited_receive)
|
||||
try:
|
||||
async with bounded_request.form(max_files=1, max_fields=16, max_part_size=MAX_FORM_BYTES) as form:
|
||||
file = form.get('file')
|
||||
if not isinstance(file, UploadFile):
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, '缺少 file 文件字段')
|
||||
if file.size is None or file.size > MAX_FILE_BYTES:
|
||||
raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, '文件不能超过 10 MiB')
|
||||
digest = hashlib.sha256()
|
||||
while content := await file.read(MAX_FORM_BYTES):
|
||||
digest.update(content)
|
||||
return FileInspection(filename=file.filename or 'upload.bin', size=file.size, sha256=digest.hexdigest())
|
||||
except StarletteHTTPException as exc:
|
||||
if received > MAX_FILE_BYTES + MAX_FORM_BYTES:
|
||||
raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, '上传请求超过大小限制') from exc
|
||||
raise
|
||||
|
||||
@app.get(
|
||||
'/api/files/report',
|
||||
summary='下载插件示例报表接口',
|
||||
description='校验插件权限后下载固定 CSV 示例,响应不包含用户或配置数据',
|
||||
response_class=Response,
|
||||
)
|
||||
async def download_report(request: Request) -> Response:
|
||||
request.state.plugin_context.require_permission(permission)
|
||||
return Response(
|
||||
'\ufeffname,value\nmode,authenticated-plugin\n',
|
||||
media_type='text/csv; charset=utf-8',
|
||||
headers={'Content-Disposition': 'attachment; filename="plugin-report.csv"', 'Cache-Control': 'no-store'},
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
manifestVersion: 2
|
||||
id: bundle_demo
|
||||
name: 独立前端示例
|
||||
version: 1.0.0
|
||||
description: 独立构建前端通过宿主会话桥调用受保护的 ASGI 接口。
|
||||
backend:
|
||||
runtime: python
|
||||
integration: asgi
|
||||
module: plugins.bundle_demo
|
||||
entrypoint: plugins.bundle_demo:create_plugin
|
||||
frontend:
|
||||
delivery:
|
||||
type: bundle
|
||||
bundle:
|
||||
directory: web/dist
|
||||
entry: index.html
|
||||
spaFallback: true
|
||||
menus:
|
||||
- name: 独立前端示例
|
||||
path: bundle_demo
|
||||
component: PluginFrame
|
||||
type: C
|
||||
icon: component
|
||||
perms: bundle_demo:view
|
||||
permissions:
|
||||
- code: bundle_demo:view
|
||||
name: 访问独立前端示例
|
||||
compatibility:
|
||||
hostApiVersion: ^1.4.0
|
||||
pythonVersion: '>=3.10'
|
||||
config:
|
||||
items:
|
||||
- key: greeting
|
||||
label: 页面欢迎语
|
||||
type: string
|
||||
default: 欢迎使用独立前端插件
|
||||
description: 示例查询接口每次主动读取此项,保存后刷新页面即可查看新文案。
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<!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>
|
||||
body {
|
||||
margin: 0;
|
||||
font:
|
||||
14px/1.6 system-ui,
|
||||
sans-serif;
|
||||
color: #243244;
|
||||
background: #f4f6f8;
|
||||
}
|
||||
header {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #cbd5e1;
|
||||
background: white;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
p {
|
||||
margin: 6px 0 12px;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
input[type='number'] {
|
||||
width: 80px;
|
||||
}
|
||||
select,
|
||||
input,
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
button,
|
||||
select {
|
||||
min-height: 36px;
|
||||
}
|
||||
:focus-visible {
|
||||
outline: 3px solid #2563eb;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
iframe {
|
||||
border: 0;
|
||||
width: 100%;
|
||||
min-height: 900px;
|
||||
display: block;
|
||||
}
|
||||
</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="300" min="0" max="5000"
|
||||
/></label>
|
||||
<label><input id="failure" type="checkbox" />模拟请求失败</label>
|
||||
<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,51 @@
|
||||
import { createPluginHostBridge, validatePluginSession } from '@ruoyi/plugin-bridge'
|
||||
import { createMockPluginRequest, createMockPluginStream } from './mockRequest.js'
|
||||
|
||||
const frame = document.getElementById('plugin-frame')
|
||||
let route = '/'
|
||||
const bridge = createPluginHostBridge({
|
||||
pluginId: 'bundle_demo',
|
||||
session: validatePluginSession(
|
||||
{
|
||||
pluginId: 'bundle_demo',
|
||||
bridgeVersion: 1,
|
||||
uiBase: '/apps/bundle_demo/ui/',
|
||||
apiBase: '/apps/bundle_demo/api/',
|
||||
csrfToken: 'local-mock-session-no-credentials',
|
||||
expiresIn: 3600,
|
||||
},
|
||||
'bundle_demo'
|
||||
),
|
||||
getTarget: () => frame.contentWindow,
|
||||
request: createMockPluginRequest({
|
||||
getDelay: () => document.getElementById('delay').value,
|
||||
shouldFail: () => document.getElementById('failure').checked,
|
||||
}),
|
||||
stream: createMockPluginStream({
|
||||
getDelay: () => document.getElementById('delay').value,
|
||||
shouldFail: () => document.getElementById('failure').checked,
|
||||
}),
|
||||
getContext: () => ({
|
||||
theme: { mode: document.getElementById('theme').value },
|
||||
language: 'zh-CN',
|
||||
timeZone: 'Asia/Shanghai',
|
||||
route,
|
||||
}),
|
||||
onRoute: (value) => {
|
||||
route = value
|
||||
bridge.updateRoute(route)
|
||||
},
|
||||
onReady: () => {
|
||||
document.getElementById('status').textContent = '模拟连接已建立'
|
||||
},
|
||||
})
|
||||
frame.src = '/index.html'
|
||||
document.getElementById('theme').addEventListener('change', () => bridge.updatePreferences())
|
||||
document
|
||||
.getElementById('reload')
|
||||
.addEventListener('click', () => frame.contentWindow.location.reload())
|
||||
document.getElementById('logout').addEventListener('click', () => {
|
||||
bridge.destroy({ logout: true })
|
||||
document.getElementById('status').textContent = '模拟会话已退出;刷新整个页面可重新连接'
|
||||
})
|
||||
window.addEventListener('pagehide', () => bridge.destroy(), { once: true })
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 创建仅供本地开发的内存请求适配器,不发送网络请求或保存文件。
|
||||
*
|
||||
* @param {Object} options 延迟和故障注入选项
|
||||
* @returns {Function} 可传入宿主桥的异步请求函数
|
||||
*/
|
||||
export function createMockPluginRequest({ getDelay = () => 300, shouldFail = () => false } = {}) {
|
||||
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 })
|
||||
})
|
||||
return async (config) => {
|
||||
const prefix = '/apps/bundle_demo/api/'
|
||||
if (!config.url.startsWith(prefix)) throw new Error('模拟宿主只处理当前插件 API')
|
||||
const path = config.url.slice(prefix.length)
|
||||
await pause(config.signal)
|
||||
if (shouldFail()) throw new Error('模拟接口失败')
|
||||
if (config.method === 'get' && path === 'summary') {
|
||||
return {
|
||||
pluginId: 'bundle_demo',
|
||||
userName: '本地模拟用户',
|
||||
serverTime: new Date().toISOString(),
|
||||
greeting: '本地模拟连接已就绪,文件仅在浏览器内处理。',
|
||||
}
|
||||
}
|
||||
if (config.method === 'post' && path === 'echo') {
|
||||
return {
|
||||
message: config.data.message,
|
||||
serverTime: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
if (config.method === 'post' && path === 'files/inspect') {
|
||||
const file = config.data.get('file')
|
||||
if (!(file instanceof Blob)) throw new Error('缺少文件')
|
||||
config.onUploadProgress?.({
|
||||
loaded: Math.floor(file.size / 2),
|
||||
total: file.size,
|
||||
})
|
||||
await pause(config.signal)
|
||||
config.onUploadProgress?.({ loaded: file.size, total: file.size })
|
||||
const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer())
|
||||
return {
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
sha256: Array.from(new Uint8Array(digest), (byte) =>
|
||||
byte.toString(16).padStart(2, '0')
|
||||
).join(''),
|
||||
}
|
||||
}
|
||||
if (config.method === 'get' && path === 'files/report') {
|
||||
const report = new Blob(['\uFEFFname,value\nmode,local-mock\n'], {
|
||||
type: 'text/csv;charset=utf-8',
|
||||
})
|
||||
config.onDownloadProgress?.({ loaded: report.size, total: report.size })
|
||||
return report
|
||||
}
|
||||
throw new Error('模拟接口未实现')
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建逐条产出事件的本地适配器,复用延迟和故障开关,支持取消与游标恢复。 */
|
||||
export function createMockPluginStream({ getDelay = () => 300, shouldFail = () => false } = {}) {
|
||||
return async function* (config) {
|
||||
if (config.url !== '/apps/bundle_demo/api/events') throw new Error('模拟实时接口未实现')
|
||||
const count = Number(config.params?.count ?? 10)
|
||||
const cursorText = config.lastEventId || '0'
|
||||
const cursor = Number(cursorText)
|
||||
if (
|
||||
!Number.isInteger(count) ||
|
||||
count < 1 ||
|
||||
count > 20 ||
|
||||
!/^[0-9]{1,2}$/.test(cursorText) ||
|
||||
!Number.isInteger(cursor) ||
|
||||
cursor < 0 ||
|
||||
cursor > count
|
||||
)
|
||||
throw new Error('模拟事件参数无效')
|
||||
for (let index = cursor + 1; index <= count; index++) {
|
||||
await new Promise((resolve, reject) => {
|
||||
if (config.signal.aborted) return reject(new Error('已取消'))
|
||||
const cancel = () => {
|
||||
clearTimeout(timer)
|
||||
reject(new Error('已取消'))
|
||||
}
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
config.signal.removeEventListener('abort', cancel)
|
||||
resolve()
|
||||
},
|
||||
Math.max(100, Math.min(5000, Number(getDelay()) || 0))
|
||||
)
|
||||
config.signal.addEventListener('abort', cancel, { once: true })
|
||||
})
|
||||
if (shouldFail()) throw new Error('模拟实时接口失败')
|
||||
yield {
|
||||
event: 'tick',
|
||||
id: String(index),
|
||||
data: JSON.stringify({ index, total: count, serverTime: new Date().toISOString() }),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>独立前端示例</title>
|
||||
</head>
|
||||
<body>
|
||||
<main aria-busy="true" id="workspace">
|
||||
<header>
|
||||
<div>
|
||||
<p class="eyebrow">插件应用</p>
|
||||
<h1>独立前端示例</h1>
|
||||
<p class="description">查看当前会话,并验证页面与服务端的通信。</p>
|
||||
</div>
|
||||
<button id="refresh" type="button">刷新状态</button>
|
||||
</header>
|
||||
<p id="notice" class="notice" role="status" aria-live="polite">正在连接管理平台…</p>
|
||||
<section class="facts" aria-label="当前会话">
|
||||
<article>
|
||||
<h2>当前用户</h2>
|
||||
<p id="user-name">等待连接</p>
|
||||
</article>
|
||||
<article>
|
||||
<h2>显示时区</h2>
|
||||
<p id="time-zone">等待连接</p>
|
||||
</article>
|
||||
<article>
|
||||
<h2>服务端时间</h2>
|
||||
<p id="server-time">等待连接</p>
|
||||
</article>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<nav aria-label="示例内容">
|
||||
<button id="overview" type="button" aria-current="page">通信测试</button>
|
||||
<button id="details" type="button">接入说明</button>
|
||||
</nav>
|
||||
<div id="overview-panel">
|
||||
<h2>发送一条测试消息</h2>
|
||||
<p class="description">消息仅用于回显,不保存到业务数据库。</p>
|
||||
<form id="echo-form">
|
||||
<label for="message">测试消息</label>
|
||||
<div class="input-row">
|
||||
<input id="message" name="message" maxlength="80" required value="独立页面连接成功" />
|
||||
<button id="send" type="submit">发送测试</button>
|
||||
</div>
|
||||
</form>
|
||||
<p id="echo-result" role="status" aria-live="polite" class="result">
|
||||
发送后将在这里显示结果。
|
||||
</p>
|
||||
<section class="file-panel" aria-labelledby="file-heading">
|
||||
<h2 id="file-heading">文件传输</h2>
|
||||
<p class="description">上传仅检查文件摘要,不保存内容。单文件最多 10 MiB。</p>
|
||||
<form id="file-form">
|
||||
<label for="file">选择文件</label>
|
||||
<div class="input-row">
|
||||
<input id="file" type="file" required />
|
||||
<button id="upload" type="submit">上传检查</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="file-actions">
|
||||
<button id="download" type="button">下载示例报表</button>
|
||||
<button id="cancel-transfer" type="button" disabled>取消传输</button>
|
||||
</div>
|
||||
<progress
|
||||
id="file-progress"
|
||||
max="100"
|
||||
value="0"
|
||||
aria-label="文件传输进度"
|
||||
hidden
|
||||
></progress>
|
||||
<p id="file-result" class="result" role="status" aria-live="polite">
|
||||
等待选择文件或下载报表。
|
||||
</p>
|
||||
</section>
|
||||
<section class="file-panel" aria-labelledby="stream-heading">
|
||||
<h2 id="stream-heading">实时事件</h2>
|
||||
<p class="description">逐条接收服务端状态。停止或断开后,可从上次收到的位置继续。</p>
|
||||
<div class="file-actions">
|
||||
<button id="start-stream" type="button">接收实时事件</button>
|
||||
<button id="stop-stream" type="button" disabled>停止接收</button>
|
||||
</div>
|
||||
<p id="stream-status" class="result" role="status" aria-atomic="true">尚未开始接收。</p>
|
||||
<ol id="stream-events" aria-label="最近收到的事件" class="stream-events"></ol>
|
||||
</section>
|
||||
</div>
|
||||
<div id="details-panel" hidden>
|
||||
<h2>独立构建,统一访问</h2>
|
||||
<p class="description">
|
||||
此页面来自插件自己的构建目录。主题、语言、时区与页面导航通过宿主桥同步;请求沿用宿主的权限和传输加密规则。
|
||||
</p>
|
||||
<p class="description">浏览器会话限定当前插件,退出管理平台后会失效。</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "ruoyi-plugin-bundle-demo",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1 --open /dev.html",
|
||||
"build": "vite build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "6.4.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { createPluginClient } from '@ruoyi/plugin-bridge'
|
||||
import './style.css'
|
||||
|
||||
// 统一按 ID 获取示例页面中的固定元素。
|
||||
const element = (id) => document.getElementById(id)
|
||||
let client
|
||||
let context = {}
|
||||
let transferController
|
||||
let streamController
|
||||
let lastEventId = ''
|
||||
|
||||
/** 逐条显示事件,保留游标供用户主动恢复,并限制页面中的历史数量。 */
|
||||
async function receiveEvents() {
|
||||
if (streamController) return
|
||||
const controller = new AbortController()
|
||||
streamController = controller
|
||||
const start = element('start-stream')
|
||||
const stop = element('stop-stream')
|
||||
const status = element('stream-status')
|
||||
const list = element('stream-events')
|
||||
if (!lastEventId) list.replaceChildren()
|
||||
start.disabled = true
|
||||
stop.disabled = false
|
||||
status.textContent = '正在连接实时事件…'
|
||||
try {
|
||||
await client.stream(
|
||||
{ path: 'events', params: { count: 10 }, lastEventId },
|
||||
{
|
||||
signal: controller.signal,
|
||||
onEvent: (event) => {
|
||||
const value = JSON.parse(event.data)
|
||||
lastEventId = event.id
|
||||
const item = document.createElement('li')
|
||||
item.textContent = `事件 ${value.index} / ${value.total} · ${value.serverTime}`
|
||||
list.append(item)
|
||||
while (list.children.length > 5) list.firstElementChild.remove()
|
||||
status.textContent = `已收到 ${value.index} / ${value.total} 条事件。`
|
||||
},
|
||||
}
|
||||
)
|
||||
status.textContent = '本次事件接收完成,可重新开始。'
|
||||
lastEventId = ''
|
||||
} catch (error) {
|
||||
status.textContent = controller.signal.aborted
|
||||
? '已停止接收,可从上次位置继续。'
|
||||
: `${error.message},可继续接收。`
|
||||
} finally {
|
||||
streamController = null
|
||||
start.textContent = lastEventId ? '继续接收' : '接收实时事件'
|
||||
start.disabled = false
|
||||
stop.disabled = true
|
||||
}
|
||||
}
|
||||
element('start-stream').addEventListener('click', receiveEvents)
|
||||
element('stop-stream').addEventListener('click', () => streamController?.abort())
|
||||
|
||||
/**
|
||||
* 传输期间禁止重复提交,完成或失败后允许重试。
|
||||
*/
|
||||
async function transfer(kind) {
|
||||
if (transferController) return
|
||||
transferController = new AbortController()
|
||||
for (const id of ['upload', 'download', 'file']) element(id).disabled = true
|
||||
element('cancel-transfer').disabled = false
|
||||
const progress = element('file-progress')
|
||||
const resultElement = element('file-result')
|
||||
const controls = ['upload', 'download', 'file'].map(element)
|
||||
const cancelButton = element('cancel-transfer')
|
||||
progress.hidden = false
|
||||
progress.removeAttribute('value')
|
||||
resultElement.textContent = '正在传输…'
|
||||
const options = {
|
||||
signal: transferController.signal,
|
||||
onProgress: ({ loaded, total }) => {
|
||||
if (total) progress.value = Math.round((loaded * 100) / total)
|
||||
else progress.removeAttribute('value')
|
||||
resultElement.textContent = total
|
||||
? `已传输 ${loaded} / ${total} 字节,等待服务端处理…`
|
||||
: `已传输 ${loaded} 字节,等待服务端处理…`
|
||||
},
|
||||
}
|
||||
try {
|
||||
if (kind === 'upload') {
|
||||
const file = element('file').files[0]
|
||||
if (!file) throw new Error('请先选择文件')
|
||||
const result = await client.upload({ path: 'files/inspect', file }, options)
|
||||
resultElement.textContent = `已检查 ${result.filename}(${result.size} 字节),SHA-256:${result.sha256}`
|
||||
} else {
|
||||
const blob = await client.download({ path: 'files/report' }, options)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = Object.assign(document.createElement('a'), {
|
||||
href: url,
|
||||
download: 'plugin-report.csv',
|
||||
})
|
||||
document.body.append(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
resultElement.textContent = `报表已接收(${blob.size} 字节),已交给浏览器保存。`
|
||||
}
|
||||
progress.value = 100
|
||||
} catch (error) {
|
||||
resultElement.textContent = error.message
|
||||
progress.hidden = true
|
||||
} finally {
|
||||
transferController = null
|
||||
for (const control of controls) control.disabled = false
|
||||
cancelButton.disabled = true
|
||||
}
|
||||
}
|
||||
element('file-form').addEventListener('submit', (event) => {
|
||||
event.preventDefault()
|
||||
transfer('upload')
|
||||
})
|
||||
element('download').addEventListener('click', () => transfer('download'))
|
||||
element('cancel-transfer').addEventListener('click', () => transferController?.abort())
|
||||
|
||||
/**
|
||||
* 更新连接提示,并区分错误状态。
|
||||
*/
|
||||
const showNotice = (text, error = false) => {
|
||||
if (!element('notice')) return
|
||||
element('notice').textContent = text
|
||||
element('notice').classList.toggle('error', error)
|
||||
}
|
||||
/**
|
||||
* 同步请求忙碌状态与刷新按钮,供页面和辅助技术读取。
|
||||
*/
|
||||
const setBusy = (busy) => {
|
||||
element('workspace').setAttribute('aria-busy', String(busy))
|
||||
if (element('refresh')) element('refresh').disabled = busy
|
||||
}
|
||||
/**
|
||||
* 应用宿主推送的主题、语言、时区和插件内部路由。
|
||||
*/
|
||||
const applyContext = (next) => {
|
||||
context = { ...context, ...next }
|
||||
document.documentElement.dataset.theme = context.theme?.mode || 'light'
|
||||
document.documentElement.lang = context.language || 'zh-CN'
|
||||
element('time-zone').textContent = context.timeZone || 'UTC'
|
||||
const details = context.route === '/details'
|
||||
element('overview-panel').hidden = details
|
||||
element('details-panel').hidden = !details
|
||||
element('overview').setAttribute('aria-current', details ? 'false' : 'page')
|
||||
element('details').setAttribute('aria-current', details ? 'page' : 'false')
|
||||
}
|
||||
/**
|
||||
* 通过宿主桥接会话查询当前用户和服务端时间。
|
||||
*/
|
||||
const refresh = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const result = await client.request({ method: 'GET', path: 'summary' })
|
||||
element('user-name').textContent = result.userName
|
||||
element('server-time').textContent = new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: context.timeZone || 'UTC',
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'medium',
|
||||
}).format(new Date(result.serverTime))
|
||||
showNotice(result.greeting || '已连接管理平台,当前会话有效。')
|
||||
} catch (error) {
|
||||
showNotice(`${error.message},可点击“刷新状态”重试。`, true)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
element('refresh').addEventListener('click', refresh)
|
||||
element('overview').addEventListener('click', () => client?.navigate('/'))
|
||||
element('details').addEventListener('click', () => client?.navigate('/details'))
|
||||
// POST 请求同样交给宿主桥接层处理身份和传输策略,页面不保存管理员 token。
|
||||
element('echo-form').addEventListener('submit', async (event) => {
|
||||
event.preventDefault()
|
||||
const button = element('send')
|
||||
const result = element('echo-result')
|
||||
button.disabled = true
|
||||
result.textContent = '正在发送…'
|
||||
try {
|
||||
const response = await client.request({
|
||||
method: 'POST',
|
||||
path: 'echo',
|
||||
data: { message: element('message').value },
|
||||
})
|
||||
result.textContent = `服务端已收到:${response.message}`
|
||||
} catch (error) {
|
||||
result.textContent = error.message
|
||||
} finally {
|
||||
button.disabled = false
|
||||
}
|
||||
})
|
||||
/**
|
||||
* 建立宿主会话,并订阅偏好、路由、刷新和退出登录事件。
|
||||
*/
|
||||
async function connect() {
|
||||
try {
|
||||
const config = JSON.parse(element('ruoyi-plugin-config').textContent)
|
||||
client = createPluginClient({ pluginId: config.pluginId })
|
||||
client.subscribe(({ type, payload }) => {
|
||||
if (type === 'preferences' || type === 'route') applyContext(payload)
|
||||
if (type === 'refresh') refresh()
|
||||
if (type === 'logout') {
|
||||
transferController?.abort()
|
||||
streamController?.abort()
|
||||
element('workspace').replaceChildren(
|
||||
Object.assign(document.createElement('p'), {
|
||||
textContent: '登录已结束,请重新登录管理平台。',
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
applyContext(await client.ready)
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
showNotice(error.message || '请从管理平台打开插件页面。', true)
|
||||
setBusy(false)
|
||||
if (element('send')) element('send').disabled = true
|
||||
}
|
||||
}
|
||||
connect()
|
||||
// 页面离开时释放桥接层的事件监听和待处理请求。
|
||||
window.addEventListener('pagehide', () => client?.destroy(), { once: true })
|
||||
@@ -0,0 +1,220 @@
|
||||
:root {
|
||||
font-family: 'Microsoft YaHei', system-ui, sans-serif;
|
||||
color: #303133;
|
||||
background: #f5f7fa;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color-scheme: light;
|
||||
--surface: #fff;
|
||||
--muted: #606266;
|
||||
--border: #dcdfe6;
|
||||
--primary: #2563b9;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
main {
|
||||
max-width: 1120px;
|
||||
margin: auto;
|
||||
padding: 32px;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 26px;
|
||||
line-height: 1.35;
|
||||
margin: 4px 0 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
h2 {
|
||||
font-size: 16px;
|
||||
margin: 0 0 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
.eyebrow {
|
||||
color: var(--primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.description {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
max-width: 760px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.notice {
|
||||
border: 1px solid #b3d8c5;
|
||||
color: #205a3d;
|
||||
background: #eef8f1;
|
||||
border-radius: 5px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 24px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.notice.error {
|
||||
color: #9b3232;
|
||||
background: #fef0f0;
|
||||
border-color: #f4bebe;
|
||||
}
|
||||
.facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
article,
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 24px;
|
||||
}
|
||||
article h2 {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
}
|
||||
article p {
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 17px;
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
nav button {
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
nav button[aria-current='page'] {
|
||||
color: var(--primary);
|
||||
background: #eaf2fd;
|
||||
}
|
||||
button {
|
||||
min-height: 44px;
|
||||
padding: 8px 18px;
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: 5px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
button:hover {
|
||||
filter: brightness(0.93);
|
||||
}
|
||||
button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.6;
|
||||
}
|
||||
button:focus-visible,
|
||||
input:focus-visible {
|
||||
outline: 3px solid #8db7ed;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
margin: 20px 0 8px;
|
||||
}
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
max-width: 700px;
|
||||
}
|
||||
input {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
padding: 10px 12px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: var(--surface);
|
||||
}
|
||||
.result {
|
||||
color: var(--muted);
|
||||
min-height: 48px;
|
||||
padding-top: 20px;
|
||||
font-size: 14px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.file-panel {
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.file-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 16px;
|
||||
}
|
||||
progress {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
.stream-events {
|
||||
padding-left: 24px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
min-height: 120px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
:root[data-theme='dark'] {
|
||||
background: #141414;
|
||||
color: #e5eaf3;
|
||||
--surface: #1d1e1f;
|
||||
--muted: #a3a6ad;
|
||||
--border: #414243;
|
||||
--primary: #75b4ff;
|
||||
color-scheme: dark;
|
||||
}
|
||||
:root[data-theme='dark'] button {
|
||||
color: #141414;
|
||||
}
|
||||
:root[data-theme='dark'] nav button {
|
||||
color: var(--muted);
|
||||
}
|
||||
:root[data-theme='dark'] nav button[aria-current='page'] {
|
||||
background: #203958;
|
||||
color: #b6d7ff;
|
||||
}
|
||||
@media (max-width: 680px) {
|
||||
main {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.facts {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
article,
|
||||
.panel {
|
||||
padding: 20px;
|
||||
}
|
||||
.input-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
// 桥接 SDK 没有外部依赖,独立插件仓库可复制该模块并核对宿主版本兼容性。
|
||||
export default {
|
||||
base: './',
|
||||
plugins: [
|
||||
{
|
||||
name: 'local-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: 'bundle_demo' }),
|
||||
injectTo: 'head',
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@ruoyi/plugin-bridge': fileURLToPath(
|
||||
new URL(
|
||||
'../../../../../../ruoyi-fastapi-frontend/src/utils/pluginBridge.js',
|
||||
import.meta.url
|
||||
)
|
||||
),
|
||||
},
|
||||
},
|
||||
build: { sourcemap: false },
|
||||
}
|
||||
@@ -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