feat: 支持 v2 插件、独立前端和签名制品发布 (#130)

* feat: 支持 v2 插件、原生扩展与签名发布

* fix: 修复测试

* feat: 增强宿主 SDK、运行观测与制品维护能力

* feat: 增强文件传输、配置观测与本地开发能力

* feat: 支持 SSE 实时事件流并完善开发脚手架

* feat: 完善长连接鉴权复核与关闭回收

* feat: 增强生命周期与任务关闭可靠性

* feat: 收紧健康检查契约并补齐前端与真实网络验收

* feat: 增加SDK溯源与离线更新工具并验证跨版本兼容

* feat: 增加持久化业务示例与受控 Python 交付

* feat: 增加统一运行诊断与多worker状态观测

* feat: 增加固定目标发布等待与自动验收

* fix: 修复多环境插件部署前缀与代理配置不一致

* fix: 修复测试
This commit is contained in:
insistence
2026-10-09 23:49:45 +08:00
committed by GitHub
parent a89aec54aa
commit 4bd83bc7cd
287 changed files with 41945 additions and 331 deletions
@@ -0,0 +1,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;
@@ -0,0 +1,3 @@
ALTER TABLE ruoyi_plugin_task_demo
ADD COLUMN priority VARCHAR(6) NOT NULL DEFAULT 'normal',
ADD CONSTRAINT ck_task_demo_priority CHECK (priority IN ('normal', 'high'));
@@ -0,0 +1,9 @@
CREATE TABLE ruoyi_plugin_task_demo (
id VARCHAR(32) NOT NULL PRIMARY KEY,
title VARCHAR(120) NOT NULL,
description VARCHAR(500) NOT NULL DEFAULT '',
status VARCHAR(4) NOT NULL DEFAULT 'todo',
created_at TIMESTAMP(3) WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP(3) WITH TIME ZONE NOT NULL,
CONSTRAINT ck_task_demo_status CHECK (status IN ('todo', 'done'))
);
@@ -0,0 +1,3 @@
ALTER TABLE ruoyi_plugin_task_demo
ADD COLUMN priority VARCHAR(6) NOT NULL DEFAULT 'normal'
CONSTRAINT ck_task_demo_priority CHECK (priority IN ('normal', 'high'));
@@ -0,0 +1,62 @@
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field, StringConstraints, field_validator
from sqlalchemy import CheckConstraint, Column, MetaData, String, Table
from common.types import DbUtcDateTime
MIN_PRINTABLE_CODEPOINT = 32
metadata = MetaData()
tasks = Table(
'ruoyi_plugin_task_demo',
metadata,
Column('id', String(32), primary_key=True),
Column('title', String(120), nullable=False),
Column('description', String(500), nullable=False, server_default=''),
Column('status', String(4), nullable=False, server_default='todo'),
Column('created_at', DbUtcDateTime, nullable=False),
Column('updated_at', DbUtcDateTime, nullable=False),
Column('priority', String(6), nullable=False, server_default='normal'),
CheckConstraint("status IN ('todo', 'done')", name='ck_task_demo_status'),
CheckConstraint("priority IN ('normal', 'high')", name='ck_task_demo_priority'),
)
class TaskInput(BaseModel):
"""任务创建和完整更新的输入字段。"""
model_config = ConfigDict(extra='forbid')
title: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=120)]
description: str = Field(default='', max_length=500)
status: Literal['todo', 'done'] = 'todo'
priority: Literal['normal', 'high'] = 'normal'
@field_validator('title', 'description')
@classmethod
def reject_control_characters(cls, value: str) -> str:
"""
拒绝不可显示控制字符,使最大分页仍处于浏览器桥响应上限内。
:param value: 标题或描述文本
:return: 已校验的文本
"""
if any(ord(character) < MIN_PRINTABLE_CODEPOINT and character not in '\n\r\t' for character in value):
raise ValueError('文本不能包含不可显示的控制字符')
return value
class TaskQuery(BaseModel):
"""限制单次响应体积,并提供稳定的分页和状态筛选。"""
model_config = ConfigDict(extra='forbid')
page: int = Field(default=1, ge=1, le=1_000_000)
page_size: int = Field(default=10, ge=1, le=20, alias='pageSize')
status: Literal['todo', 'done'] | None = None
class TaskIdentifier(BaseModel):
"""用于路径参数的插件任务标识。"""
id: str = Field(pattern=r'^[0-9a-f]{32}$')
@@ -0,0 +1,43 @@
manifestVersion: 2
id: task_demo
name: 持久化任务示例
version: 1.1.0
description: 独立前端与 ASGI 接口结合,演示显式事务、读写分权及无损字段迁移。
backend:
runtime: python
integration: asgi
module: plugins.task_demo
entrypoint: plugins.task_demo:create_plugin
health:
checker: plugins.task_demo:health
migrations:
- migrations/mysql/001_init.sql
- migrations/mysql/002_priority.sql
- migrations/postgresql/001_init.sql
- migrations/postgresql/002_priority.sql
frontend:
delivery:
type: bundle
buildRequired: false
bundle:
directory: web/dist
entry: index.html
spaFallback: true
menus:
- name: 持久化任务示例
path: task_demo
component: PluginFrame
type: C
icon: list
perms: task_demo:view
permissions:
- code: task_demo:view
name: 查看任务
- code: task_demo:write
name: 编辑任务
compatibility:
hostApiVersion: ^1.4.0
pythonVersion: '>=3.10'
databases:
- mysql
- postgresql
@@ -0,0 +1,12 @@
{
"schemaVersion": 1,
"files": [
"__init__.py",
"models.py",
"service.py",
"migrations/mysql/001_init.sql",
"migrations/mysql/002_priority.sql",
"migrations/postgresql/001_init.sql",
"migrations/postgresql/002_priority.sql"
]
}
@@ -0,0 +1,134 @@
from collections.abc import Mapping
from typing import Any
from uuid import uuid4
from fastapi import HTTPException
from sqlalchemy import delete, func, insert, select, update
from plugins.core.sdk import PluginRequestContext
from utils.time_util import TimezoneUtil
from .models import TaskQuery, tasks
def can_write(context: PluginRequestContext) -> bool:
"""
判断当前用户是否能够修改任务。
:param context: 宿主提供的请求身份
:return: 是否具有写权限或管理员通配权限
"""
return bool({'task_demo:write', '*:*:*'} & context.permissions)
def _task_payload(row: Mapping[str, Any]) -> dict[str, Any]:
"""
将数据库任务转换为跨语言接口字段及 UTC 时间。
:param row: 数据库任务记录
:return: 可序列化的任务对象
"""
return {
'id': row['id'],
'title': row['title'],
'description': row['description'],
'status': row['status'],
'priority': row['priority'],
'createdAt': TimezoneUtil.format_rfc3339(row['created_at']),
'updatedAt': TimezoneUtil.format_rfc3339(row['updated_at']),
}
async def list_tasks(context: PluginRequestContext, query: TaskQuery) -> dict[str, Any]:
"""
在独立事务中读取筛选后的总数与当前页。
:param context: 宿主提供的请求身份
:param query: 已校验的分页与状态参数
:return: 任务分页结果和当前用户写权限
"""
filters = [tasks.c.status == query.status] if query.status else []
async with context.transaction() as transaction:
db = transaction.transaction_session()
total = await db.scalar(select(func.count()).select_from(tasks).where(*filters))
result = await db.execute(
select(tasks)
.where(*filters)
.order_by(tasks.c.created_at.desc(), tasks.c.id.desc())
.offset((query.page - 1) * query.page_size)
.limit(query.page_size)
)
items = [_task_payload(row) for row in result.mappings()]
return {
'items': items,
'total': total,
'page': query.page,
'pageSize': query.page_size,
'canWrite': can_write(context),
}
async def get_task(context: PluginRequestContext, task_id: str) -> dict[str, Any]:
"""
在独立事务中读取指定任务。
:param context: 宿主提供的请求身份
:param task_id: 已校验的任务标识
:return: 任务对象;不存在时返回 HTTP 404
"""
async with context.transaction() as transaction:
result = await transaction.transaction_session().execute(select(tasks).where(tasks.c.id == task_id))
row = result.mappings().one_or_none()
if row is None:
raise HTTPException(status_code=404, detail='任务不存在')
return _task_payload(row)
async def create_task(context: PluginRequestContext, values: dict[str, Any]) -> dict[str, Any]:
"""
在独立事务中写入任务,成功提交后返回任务对象。
:param context: 宿主提供的请求身份
:param values: 已校验的任务输入
:return: 新建的任务对象
"""
now = TimezoneUtil.to_utc_milliseconds(TimezoneUtil.utc_now())
row = {**values, 'id': uuid4().hex, 'created_at': now, 'updated_at': now}
async with context.transaction() as transaction:
await transaction.transaction_session().execute(insert(tasks).values(**row))
return _task_payload(row)
async def update_task(context: PluginRequestContext, task_id: str, values: dict[str, Any]) -> dict[str, Any]:
"""
在独立事务中完整更新任务,保留创建时间。
:param context: 宿主提供的请求身份
:param task_id: 已校验的任务标识
:param values: 已校验的任务输入
:return: 更新后的任务对象;不存在时返回 HTTP 404
"""
async with context.transaction() as transaction:
db = transaction.transaction_session()
result = await db.execute(
update(tasks).where(tasks.c.id == task_id).values(**values, updated_at=TimezoneUtil.utc_now())
)
if result.rowcount == 0:
raise HTTPException(status_code=404, detail='任务不存在')
result = await db.execute(select(tasks).where(tasks.c.id == task_id))
return _task_payload(result.mappings().one())
async def delete_task(context: PluginRequestContext, task_id: str) -> dict[str, bool]:
"""
在独立事务中删除指定任务。
:param context: 宿主提供的请求身份
:param task_id: 已校验的任务标识
:return: 删除确认;不存在时返回 HTTP 404
"""
async with context.transaction() as transaction:
result = await transaction.transaction_session().execute(delete(tasks).where(tasks.c.id == task_id))
if result.rowcount == 0:
raise HTTPException(status_code=404, detail='任务不存在')
return {'deleted': True}
@@ -0,0 +1,3 @@
node_modules/
dist/
.vite/
@@ -0,0 +1,7 @@
{
"printWidth": 100,
"semi": false,
"singleQuote": true,
"trailingComma": "es5",
"endOfLine": "lf"
}
@@ -0,0 +1,122 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>任务清单 · 本地模拟宿主</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
font:
14px/1.6 'Segoe UI',
'Microsoft YaHei',
sans-serif;
color: #172b36;
background: #f3f6f7;
}
header {
padding: 16px 24px;
background: #fff;
border-bottom: 1px solid #879aa4;
}
h1 {
margin: 0;
font-size: 20px;
}
p {
margin: 6px 0 12px;
}
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 16px;
}
.controls label {
display: inline-flex;
align-items: center;
gap: 6px;
}
input[type='number'] {
width: 78px;
}
button,
select,
input {
font: inherit;
}
button,
select,
input[type='number'] {
min-height: 36px;
border: 1px solid #879aa4;
border-radius: 5px;
padding: 4px 8px;
background: #fff;
color: #172b36;
}
button {
cursor: pointer;
}
:focus-visible {
outline: 3px solid #176b62;
outline-offset: 3px;
}
#status {
color: #516574;
font-size: 12px;
}
iframe {
width: 100%;
height: 1050px;
border: 0;
display: block;
}
@media (max-width: 640px) {
header {
padding: 16px;
}
button,
select,
input[type='number'] {
min-height: 44px;
}
iframe {
height: 2200px;
}
}
</style>
</head>
<body>
<header>
<h1>任务清单 · 本地模拟宿主</h1>
<p>
使用模拟权限与内存数据,不连接真实后端。刷新整个页面会重置任务。此页仅用于开发,不进入生产构建。
</p>
<div class="controls">
<label
>主题
<select id="theme">
<option value="light">浅色</option>
<option value="dark">深色</option>
</select></label
>
<label
>延迟(毫秒) <input id="delay" type="number" value="250" min="0" max="5000"
/></label>
<label><input id="failure" type="checkbox" />模拟请求失败</label>
<label><input id="read-only" type="checkbox" />只读权限</label>
<button id="reset" type="button">恢复示例任务</button>
<button id="empty" type="button">清空模拟任务</button>
<button id="reload" type="button">重载插件</button>
<button id="logout" type="button">模拟退出</button>
<span id="status" role="status" aria-live="polite">等待连接</span>
</div>
</header>
<iframe id="plugin-frame" title="任务清单插件"></iframe>
<script type="module" src="/dev/host.js"></script>
</body>
</html>
@@ -0,0 +1,52 @@
import { createPluginHostBridge, validatePluginSession } from '@ruoyi/plugin-bridge'
import { createMockTaskApi } from './mockRequest.js'
const element = (id) => document.getElementById(id)
const frame = element('plugin-frame')
const mock = createMockTaskApi({
getDelay: () => element('delay').value,
shouldFail: () => element('failure').checked,
getCanWrite: () => !element('read-only').checked,
})
const bridge = createPluginHostBridge({
pluginId: 'task_demo',
session: validatePluginSession(
{
pluginId: 'task_demo',
bridgeVersion: 1,
uiBase: '/apps/task_demo/ui/',
apiBase: '/apps/task_demo/api/',
csrfToken: 'local-mock-session-no-credentials',
expiresIn: 3600,
},
'task_demo'
),
getTarget: () => frame.contentWindow,
request: mock.request,
getContext: () => ({
theme: { mode: element('theme').value },
language: 'zh-CN',
timeZone: 'Asia/Shanghai',
route: '/',
}),
onReady: () => {
element('status').textContent = '模拟连接已建立'
},
})
frame.src = '/index.html'
element('theme').addEventListener('change', () => bridge.updatePreferences())
element('read-only').addEventListener('change', () => bridge.refresh())
element('reload').addEventListener('click', () => frame.contentWindow.location.reload())
element('reset').addEventListener('click', () => {
mock.reset()
bridge.refresh()
})
element('empty').addEventListener('click', () => {
mock.clear()
bridge.refresh()
})
element('logout').addEventListener('click', () => {
bridge.destroy({ logout: true })
element('status').textContent = '模拟会话已退出;刷新整个页面可重新连接'
})
window.addEventListener('pagehide', () => bridge.destroy(), { once: true })
@@ -0,0 +1,155 @@
const seedTitles = [
'确认本周交付范围',
'整理需求评审记录',
'补充接口使用示例',
'核对页面文案',
'准备演示环境',
'验证异常处理流程',
'完善操作说明',
'检查移动端布局',
'同步项目里程碑',
'回顾已完成事项',
'确认验收清单',
'整理下一阶段待办',
]
function seedTasks() {
return seedTitles.map((title, index) => ({
id: (index + 1).toString(16).padStart(32, '0'),
title,
description: index % 3 === 0 ? '与团队核对完成标准,并记录需要继续跟进的事项。' : '',
status: index % 4 === 0 ? 'done' : 'todo',
priority: index % 3 === 2 ? 'high' : 'normal',
createdAt: new Date(Date.UTC(2026, 9, 1, 2, index * 10)).toISOString(),
updatedAt: new Date(Date.UTC(2026, 9, 2, 2, index * 10)).toISOString(),
}))
}
function validateTask(data) {
const title = typeof data?.title === 'string' ? data.title.trim() : ''
if (!title || title.length > 120) throw new Error('任务标题需为 1–120 个字符')
if (typeof data?.description !== 'string' || data.description.length > 500)
throw new Error('任务说明不能超过 500 个字符')
if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(title + data.description))
throw new Error('文本不能包含不可显示的控制字符')
if (!['todo', 'done'].includes(data.status)) throw new Error('任务状态无效')
if (!['normal', 'high'].includes(data.priority)) throw new Error('任务优先级无效')
return {
title,
description: data.description,
status: data.status,
priority: data.priority,
}
}
/**
* 本地内存 API:使用与生产接口一致的分页、筛选、详情和写入契约。
* 只供 dev.html 使用;重载整个页面会重置数据,不请求真实服务、不保存凭据。
*/
export function createMockTaskApi({
getDelay = () => 300,
shouldFail = () => false,
getCanWrite = () => true,
} = {}) {
let tasks = seedTasks()
let nextId = tasks.length + 1
const pause = (signal) =>
new Promise((resolve, reject) => {
if (signal?.aborted) return reject(new Error('请求已取消'))
const cancel = () => {
clearTimeout(timer)
reject(new Error('请求已取消'))
}
const timer = setTimeout(
() => {
signal?.removeEventListener('abort', cancel)
resolve()
},
Math.max(0, Math.min(5000, Number(getDelay()) || 0))
)
signal?.addEventListener('abort', cancel, { once: true })
})
async function request(config) {
const prefix = '/apps/task_demo/api/'
if (!config.url?.startsWith(prefix)) throw new Error('模拟宿主只处理当前插件 API')
const path = config.url.slice(prefix.length)
const method = String(config.method || 'get').toUpperCase()
await pause(config.signal)
if (config.signal?.aborted) throw new Error('请求已取消')
if (shouldFail()) throw new Error('模拟请求失败,请关闭“模拟请求失败”后重试。')
const canWrite = getCanWrite() === true
if (method === 'GET' && path === 'info')
return { pluginId: 'task_demo', version: '1.1.0', canWrite }
if (method === 'GET' && path === 'tasks') {
const page = Number(config.params?.page ?? 1)
const pageSize = Number(config.params?.pageSize ?? 10)
const status = config.params?.status || ''
if (
!Number.isInteger(page) ||
page < 1 ||
page > 1000000 ||
!Number.isInteger(pageSize) ||
pageSize < 1 ||
pageSize > 20
)
throw new Error('分页参数无效')
if (status && !['todo', 'done'].includes(status)) throw new Error('筛选状态无效')
const filtered = tasks
.filter((task) => !status || task.status === status)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id))
return {
items: structuredClone(filtered.slice((page - 1) * pageSize, page * pageSize)),
total: filtered.length,
page,
pageSize,
canWrite,
}
}
const match = /^tasks\/([0-9a-f]{32})$/.exec(path)
const index = match ? tasks.findIndex((task) => task.id === match[1]) : -1
if (method === 'GET' && match) {
if (index === -1) throw new Error('任务不存在或已被删除')
return structuredClone(tasks[index])
}
if (!['POST', 'PUT', 'DELETE'].includes(method)) throw new Error('模拟接口未实现')
// UI 隐藏按钮之外,再次在请求处理层检查写权限。
if (!canWrite) throw new Error('当前会话没有写入权限')
if (method === 'POST' && path === 'tasks') {
const data = validateTask(config.data)
const now = new Date().toISOString()
const task = {
id: (nextId++).toString(16).padStart(32, '0'),
...data,
createdAt: now,
updatedAt: now,
}
tasks.push(task)
return structuredClone(task)
}
if (match && (method === 'PUT' || method === 'DELETE')) {
if (index === -1) throw new Error('任务不存在或已被删除')
if (method === 'DELETE') {
tasks.splice(index, 1)
return { deleted: true }
}
tasks[index] = {
...tasks[index],
...validateTask(config.data),
updatedAt: new Date().toISOString(),
}
return structuredClone(tasks[index])
}
throw new Error('模拟接口未实现')
}
return {
request,
reset() {
tasks = seedTasks()
nextId = tasks.length + 1
},
clear() {
tasks = []
},
}
}
@@ -0,0 +1,277 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>任务清单</title>
</head>
<body>
<a class="skip-link" href="#task-list-section">跳到任务列表</a>
<main id="workspace" class="workspace">
<header class="page-header">
<div class="page-heading">
<div class="page-mark" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
<rect x="4" y="3" width="16" height="18" rx="3" />
<path d="m8 9 1.5 1.5L12 8M14 9h3M8 15h9" />
</svg>
</div>
<div>
<p class="eyebrow">任务工作区</p>
<h1>任务清单</h1>
<p class="page-description">团队共享任务,一起记录进展、完成待办。</p>
</div>
</div>
<div class="header-actions">
<span id="access-mode" class="access-mode">连接中</span>
<button id="create-task" class="button button-primary" type="button" disabled>
<svg
class="icon"
aria-hidden="true"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="1.8"
>
<path d="M10 4v12M4 10h12" />
</svg>
新增任务
</button>
</div>
</header>
<p id="read-only-notice" class="read-only-notice" hidden>
当前会话仅可查看共享任务。新增、编辑和删除需要写入权限。
</p>
<p id="notice" class="notice" role="status" aria-live="polite" aria-atomic="true"></p>
<section
id="task-list-section"
class="list-panel"
aria-labelledby="list-heading"
tabindex="-1"
>
<div class="list-toolbar">
<div class="list-heading-row">
<h2 id="list-heading">全部任务</h2>
<span id="task-count" class="task-count">等待加载</span>
</div>
<button id="refresh" class="button button-secondary" type="button" disabled>
<svg
class="icon"
aria-hidden="true"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="1.6"
>
<path d="M16.5 9a6.5 6.5 0 1 0-1.2 4.8M16.5 4v5h-5" />
</svg>
刷新列表
</button>
</div>
<div class="filter-row">
<div id="status-filters" class="status-filters" role="group" aria-label="按任务状态筛选">
<button class="filter-button" type="button" data-status="" aria-pressed="true" disabled>
全部
</button>
<button
class="filter-button"
type="button"
data-status="todo"
aria-pressed="false"
disabled
>
待完成
</button>
<button
class="filter-button"
type="button"
data-status="done"
aria-pressed="false"
disabled
>
已完成
</button>
</div>
<p class="list-hint">点击任务标题查看详情</p>
</div>
<div id="list-error" class="error-panel" role="alert" hidden>
<p id="list-error-message"></p>
<button id="retry" class="button button-secondary" type="button">重新加载</button>
</div>
<div id="list-region" class="list-region" aria-busy="true">
<div id="loading-state" class="loading-state" role="status">
<span class="loading-dot" aria-hidden="true"></span>
<span id="loading-message">正在连接工作区…</span>
</div>
<table id="task-table" class="task-table" hidden>
<caption class="sr-only">
任务清单,可点击任务标题查看详情
</caption>
<thead>
<tr>
<th scope="col">任务</th>
<th scope="col">状态</th>
<th scope="col">优先级</th>
<th scope="col">更新时间</th>
<th scope="col" class="actions-heading">操作</th>
</tr>
</thead>
<tbody id="task-rows"></tbody>
</table>
<div id="empty-state" class="empty-state" hidden>
<svg
class="empty-icon"
aria-hidden="true"
viewBox="0 0 64 64"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="15" y="10" width="34" height="45" rx="6" />
<path d="M24 24h16M24 33h16M24 42h9" />
<path d="M25 10V7h14v3" />
</svg>
<h3 id="empty-heading">还没有任务</h3>
<p id="empty-description">新增第一条任务,开始记录待办事项。</p>
<button id="empty-create" class="button button-primary" type="button" disabled>
新增任务
</button>
<button id="clear-filter" class="button button-secondary" type="button" hidden>
查看全部任务
</button>
</div>
</div>
<footer class="pagination" aria-label="任务分页">
<p id="page-summary">尚未加载任务</p>
<div class="page-controls">
<label class="page-size-label" for="page-size">每页</label>
<select id="page-size" aria-label="每页任务数量" disabled>
<option value="10">10 条</option>
<option value="20">20 条</option>
</select>
<button id="previous-page" class="button button-secondary" type="button" disabled>
上一页
</button>
<span id="page-number" class="page-number" aria-label="当前页码">—</span>
<button id="next-page" class="button button-secondary" type="button" disabled>
下一页
</button>
</div>
</footer>
</section>
</main>
<dialog id="task-dialog" class="dialog task-dialog" aria-labelledby="dialog-heading">
<header class="dialog-header">
<div>
<p id="dialog-kicker" class="eyebrow">新任务</p>
<h2 id="dialog-heading">新增任务</h2>
</div>
<button id="close-dialog" class="icon-button" type="button" aria-label="关闭任务详情">
<svg
class="icon"
aria-hidden="true"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="1.6"
>
<path d="m5 5 10 10M15 5 5 15" />
</svg>
</button>
</header>
<form id="task-form">
<div class="dialog-body">
<p id="detail-loading" class="inline-notice" role="status" hidden>正在读取任务详情…</p>
<p id="form-error" class="form-error" role="alert" tabindex="-1" hidden></p>
<p id="form-read-only" class="inline-notice" hidden>当前会话仅可查看,不能保存修改。</p>
<fieldset id="task-fields">
<div class="field">
<div class="field-label">
<label for="task-title">任务标题 <span class="required-mark">*</span></label
><span id="title-count" class="field-count">0 / 120</span>
</div>
<input
id="task-title"
name="title"
type="text"
maxlength="120"
required
autocomplete="off"
aria-describedby="title-hint"
placeholder="例如:完成本周交付检查"
/>
<p id="title-hint" class="field-hint">用一句话描述要完成的事情。</p>
</div>
<div class="field">
<div class="field-label">
<label for="task-description">任务说明</label
><span id="description-count" class="field-count">0 / 500</span>
</div>
<textarea
id="task-description"
name="description"
maxlength="500"
rows="5"
placeholder="补充完成标准、注意事项或相关背景(选填)"
></textarea>
</div>
<div class="field-grid">
<div class="field">
<label for="task-status">状态</label
><select id="task-status" name="status">
<option value="todo">待完成</option>
<option value="done">已完成</option>
</select>
</div>
<div class="field">
<label for="task-priority">优先级</label
><select id="task-priority" name="priority">
<option value="normal">普通</option>
<option value="high">优先处理</option>
</select>
</div>
</div>
</fieldset>
<p id="task-timestamps" class="task-timestamps" hidden></p>
</div>
<footer class="dialog-footer">
<p id="save-state" class="save-state" role="status" aria-live="polite"></p>
<div class="dialog-actions">
<button id="cancel-dialog" class="button button-secondary" type="button">取消</button
><button id="save-task" class="button button-primary" type="submit">新增任务</button>
</div>
</footer>
</form>
</dialog>
<dialog
id="delete-dialog"
class="dialog delete-dialog"
aria-labelledby="delete-heading"
aria-describedby="delete-description"
>
<div class="dialog-body">
<p class="eyebrow">删除任务</p>
<h2 id="delete-heading">确认删除这条任务?</h2>
<p id="delete-title" class="delete-task-title"></p>
<p id="delete-description" class="muted-text">删除后无法恢复,请确认这条任务已不再需要。</p>
<p id="delete-error" class="form-error" role="alert" tabindex="-1" hidden></p>
</div>
<footer class="dialog-footer">
<div class="dialog-actions">
<button id="cancel-delete" class="button button-secondary" type="button" autofocus>
保留任务</button
><button id="confirm-delete" class="button button-danger" type="button">确认删除</button>
</div>
</footer>
</dialog>
<script type="module" src="/src/main.js"></script>
</body>
</html>
@@ -0,0 +1,14 @@
{
"name": "ruoyi-plugin-task-demo",
"version": "1.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1 --open /dev.html",
"build": "vite build",
"test": "node --test tests/mockRequest.test.js"
},
"devDependencies": {
"vite": "6.4.3"
}
}
@@ -0,0 +1,508 @@
import { createPluginClient } from '@ruoyi/plugin-bridge'
import './style.css'
const element = (id) => document.getElementById(id)
const filters = [...element('status-filters').querySelectorAll('button')]
const state = {
items: [],
total: 0,
page: 1,
pageSize: 10,
status: '',
connected: false,
ended: false,
canWrite: false,
loading: true,
loaded: false,
listError: false,
mutating: false,
editorLoading: false,
editorFailed: false,
editorTask: null,
deleteTask: null,
}
let client
let context = {}
let listController
let detailController
let listGeneration = 0
let detailGeneration = 0
function showNotice(message, error = false) {
element('notice').textContent = message
element('notice').classList.toggle('is-error', error)
}
function formatTime(value) {
const date = new Date(value)
if (!value || Number.isNaN(date.getTime())) return '—'
try {
return new Intl.DateTimeFormat(context.language || 'zh-CN', {
timeZone: context.timeZone || 'UTC',
dateStyle: 'short',
timeStyle: 'short',
}).format(date)
} catch {
return date.toISOString().slice(0, 16).replace('T', ' ') + ' UTC'
}
}
function setEditorControls() {
const writable = state.connected && !state.ended && state.canWrite
const blocked = state.editorLoading || state.editorFailed || state.mutating
element('task-fields').disabled = blocked
element('task-title').readOnly = !writable
element('task-description').readOnly = !writable
element('task-status').disabled = !writable
element('task-priority').disabled = !writable
element('form-read-only').hidden = writable || state.editorLoading
element('save-task').hidden = !writable
element('save-task').disabled = !writable || blocked
element('close-dialog').disabled = state.mutating
element('cancel-dialog').disabled = state.mutating
element('cancel-dialog').textContent = writable ? '取消' : '关闭'
element('dialog-heading').textContent = writable
? state.editorTask
? '编辑任务'
: '新增任务'
: '查看任务'
element('save-task').textContent = state.mutating
? '正在保存…'
: state.editorTask
? '保存修改'
: '新增任务'
}
function updateControls() {
const available = state.connected && !state.ended
const idle = available && !state.loading && !state.mutating
element('access-mode').textContent = state.ended
? '会话已结束'
: !state.connected
? '未连接'
: state.canWrite
? '可编辑 · 共享任务'
: '只读 · 共享任务'
element('read-only-notice').hidden = !available || state.canWrite
element('create-task').disabled = !idle || !state.canWrite
element('empty-create').disabled = !idle || !state.canWrite
element('empty-create').hidden = !state.canWrite || Boolean(state.status)
element('refresh').disabled = !idle
element('retry').disabled = !idle
element('page-size').disabled = !idle
element('previous-page').disabled = !idle || state.page <= 1
element('next-page').disabled = !idle || state.page * state.pageSize >= state.total
for (const button of filters) {
button.disabled = !idle
button.setAttribute('aria-pressed', String(button.dataset.status === state.status))
}
element('list-region').setAttribute('aria-busy', String(state.loading))
element('loading-state').hidden = !state.loading
element('loading-message').textContent = state.connected ? '正在加载任务…' : '正在连接工作区…'
element('task-table').hidden = !state.items.length
element('empty-state').hidden =
state.loading || state.listError || !state.loaded || Boolean(state.items.length)
element('empty-heading').textContent = state.status ? '没有符合条件的任务' : '还没有任务'
element('empty-description').textContent = state.status
? '试试其他状态,或查看全部任务。'
: state.canWrite
? '新增第一条任务,开始记录团队待办。'
: '当前还没有共享任务,请稍后刷新查看。'
element('clear-filter').hidden = !state.status
element('list-heading').textContent =
state.status === 'todo' ? '待完成任务' : state.status === 'done' ? '已完成任务' : '全部任务'
element('task-count').textContent = state.loaded ? `${state.total} 条` : '等待加载'
const start = state.total ? (state.page - 1) * state.pageSize + 1 : 0
const end = Math.min(state.page * state.pageSize, state.total)
element('page-summary').textContent = state.loaded
? `显示 ${start}–${end} 条,共 ${state.total} 条任务`
: '尚未加载任务'
element('page-number').textContent = state.loaded
? `${state.page} / ${Math.max(1, Math.ceil(state.total / state.pageSize))}`
: '—'
for (const button of element('task-rows').querySelectorAll('button')) button.disabled = !idle
element('cancel-delete').disabled = state.mutating
element('confirm-delete').disabled = !available || !state.canWrite || state.mutating
element('confirm-delete').textContent = state.mutating ? '正在删除…' : '确认删除'
setEditorControls()
}
function node(tag, className, text) {
const result = document.createElement(tag)
if (className) result.className = className
if (text !== undefined) result.textContent = text
return result
}
// 用户内容始终写入文本节点,任务标题和说明不会作为 HTML 执行。
function renderRows() {
const rows = state.items.map((task) => {
const row = node('tr', task.priority === 'high' ? 'is-priority' : '')
const titleCell = node('td')
const title = node('button', 'task-title-button', task.title)
title.type = 'button'
title.addEventListener('click', () => openEditor(task.id))
titleCell.append(title)
if (task.description) titleCell.append(node('p', 'task-description', task.description))
titleCell.append(node('span', 'task-id', `#${task.id}`))
const status = node('td')
status.dataset.label = '状态'
status.append(
node(
'span',
`status-badge status-${task.status}`,
task.status === 'done' ? '已完成' : '待完成'
)
)
const priority = node('td')
priority.dataset.label = '优先级'
priority.append(
node(
'span',
task.priority === 'high' ? 'priority-label priority-high' : 'priority-label',
task.priority === 'high' ? '优先处理' : '普通'
)
)
const updated = node('td')
updated.dataset.label = '更新时间'
updated.append(node('span', 'updated-time', formatTime(task.updatedAt)))
const actions = node('td')
const group = node('div', 'row-actions')
if (state.canWrite) {
const edit = node('button', 'row-button', '编辑')
edit.type = 'button'
edit.setAttribute('aria-label', `编辑任务:${task.title}`)
edit.addEventListener('click', () => openEditor(task.id))
const remove = node('button', 'row-button row-delete', '删除')
remove.type = 'button'
remove.setAttribute('aria-label', `删除任务:${task.title}`)
remove.addEventListener('click', () => openDelete(task))
group.append(edit, remove)
} else group.append(node('span', 'read-only-label', '仅查看'))
actions.append(group)
row.append(titleCell, status, priority, updated, actions)
return row
})
element('task-rows').replaceChildren(...rows)
}
async function loadTasks({ reloadInfo = false } = {}) {
if (!state.connected || state.ended) return
listController?.abort()
const controller = new AbortController()
listController = controller
const generation = ++listGeneration
state.loading = true
state.listError = false
element('list-error').hidden = true
updateControls()
try {
if (reloadInfo) {
const info = await client.request(
{ method: 'GET', path: 'info' },
{ signal: controller.signal }
)
if (generation !== listGeneration || state.ended) return
state.canWrite = info.canWrite === true
renderRows()
updateControls()
}
const params = { page: state.page, pageSize: state.pageSize }
if (state.status) params.status = state.status
const result = await client.request(
{ method: 'GET', path: 'tasks', params },
{ signal: controller.signal }
)
if (generation !== listGeneration || state.ended) return
state.canWrite = result.canWrite === true
state.total = result.total
const lastPage = Math.max(1, Math.ceil(result.total / state.pageSize))
if (state.page > lastPage) {
state.page = lastPage
return await loadTasks()
}
state.items = result.items
state.loaded = true
renderRows()
} catch (error) {
if (controller.signal.aborted || generation !== listGeneration || state.ended) return
state.listError = true
element('list-error-message').textContent = `任务加载失败。${error.message || '请稍后重试。'}`
element('list-error').hidden = false
} finally {
if (generation === listGeneration) {
state.loading = false
listController = null
updateControls()
}
}
}
function updateCounts() {
element('title-count').textContent = `${element('task-title').value.length} / 120`
element('description-count').textContent = `${element('task-description').value.length} / 500`
element('task-title').setCustomValidity('')
}
function fillEditor(task = {}) {
element('task-title').value = task.title || ''
element('task-description').value = task.description || ''
element('task-status').value = task.status || 'todo'
element('task-priority').value = task.priority || 'normal'
element('task-timestamps').hidden = !task.id
element('task-timestamps').textContent = task.id
? `创建于 ${formatTime(task.createdAt)} · 更新于 ${formatTime(task.updatedAt)}`
: ''
updateCounts()
}
async function openEditor(id) {
if (!state.connected || state.ended || state.mutating || (!id && !state.canWrite)) return
detailController?.abort()
const generation = ++detailGeneration
state.editorTask = id ? { id } : null
state.editorLoading = Boolean(id)
state.editorFailed = false
element('dialog-kicker').textContent = id ? `共享任务 #${id}` : '新任务'
element('form-error').hidden = true
element('save-state').textContent = ''
element('detail-loading').hidden = !id
fillEditor()
setEditorControls()
element('task-dialog').showModal()
if (!id) {
element('task-title').focus()
return
}
const controller = new AbortController()
detailController = controller
try {
const task = await client.request(
{ method: 'GET', path: `tasks/${id}` },
{ signal: controller.signal }
)
if (controller.signal.aborted || generation !== detailGeneration || state.ended) return
state.editorTask = task
fillEditor(task)
} catch (error) {
if (controller.signal.aborted || generation !== detailGeneration || state.ended) return
state.editorFailed = true
element('form-error').textContent = `任务详情加载失败。${error.message || '请关闭后重试。'}`
element('form-error').hidden = false
element('form-error').focus()
} finally {
if (generation === detailGeneration) {
state.editorLoading = false
element('detail-loading').hidden = true
setEditorControls()
}
}
}
function closeEditor() {
if (state.mutating) return
detailController?.abort()
detailGeneration++
element('task-dialog').close()
}
async function checkWriteAccess() {
try {
const info = await client.request({ method: 'GET', path: 'info' })
if (!state.ended) {
state.canWrite = info.canWrite === true
renderRows()
}
} catch {
/* 保存原始错误;后续刷新仍可重新读取权限。 */
}
}
async function saveTask(event) {
event.preventDefault()
if (state.mutating || state.editorLoading || state.editorFailed || !state.canWrite || state.ended)
return
const title = element('task-title').value.trim()
if (!title) element('task-title').setCustomValidity('请输入任务标题,不能只包含空格。')
if (!element('task-form').reportValidity()) return
const data = {
title,
description: element('task-description').value,
status: element('task-status').value,
priority: element('task-priority').value,
}
const id = state.editorTask?.id
state.mutating = true
element('form-error').hidden = true
element('save-state').textContent = '正在保存,请稍候…'
updateControls()
let saved = false
try {
await client.request({
method: id ? 'PUT' : 'POST',
path: id ? `tasks/${id}` : 'tasks',
data,
})
if (state.ended) return
saved = true
element('task-dialog').close()
const filteredOut = state.status && state.status !== data.status
showNotice(
`${id ? '任务已更新。' : '任务已新增。'}${filteredOut ? '该任务不在当前状态筛选中,可切换到“全部”查看。' : ''}`
)
if (!id) state.page = 1
} catch (error) {
if (state.ended) return
element('form-error').textContent =
`保存失败,填写内容已保留。${error.message || '请稍后重试。'}`
element('form-error').hidden = false
element('form-error').focus()
await checkWriteAccess()
} finally {
state.mutating = false
element('save-state').textContent = ''
updateControls()
}
if (saved) await loadTasks()
}
function openDelete(task) {
if (!state.canWrite || state.mutating || state.ended) return
state.deleteTask = task
element('delete-title').textContent = task.title
element('delete-error').hidden = true
element('delete-dialog').showModal()
element('cancel-delete').focus()
}
async function deleteTask() {
if (!state.deleteTask || !state.canWrite || state.mutating || state.ended) return
state.mutating = true
element('delete-error').hidden = true
updateControls()
let deleted = false
try {
await client.request({
method: 'DELETE',
path: `tasks/${state.deleteTask.id}`,
})
if (state.ended) return
deleted = true
element('delete-dialog').close()
showNotice('任务已删除。')
} catch (error) {
if (state.ended) return
element('delete-error').textContent = `删除失败。${error.message || '请稍后重试。'}`
element('delete-error').hidden = false
element('delete-error').focus()
await checkWriteAccess()
} finally {
state.mutating = false
updateControls()
}
if (deleted) await loadTasks()
}
function setStatus(status) {
state.status = status
state.page = 1
reloadQuery()
}
function reloadQuery() {
// 切换查询条件后清除旧结果,避免把上一种筛选误认为当前列表。
state.items = []
state.loaded = false
renderRows()
loadTasks()
}
function applyContext(next) {
context = { ...context, ...next }
document.documentElement.dataset.theme = context.theme?.mode === 'dark' ? 'dark' : 'light'
document.documentElement.lang = context.language || 'zh-CN'
renderRows()
updateControls()
}
function endSession() {
state.ended = true
state.canWrite = false
state.loading = false
listController?.abort()
detailController?.abort()
listGeneration++
detailGeneration++
element('task-dialog').close()
element('delete-dialog').close()
showNotice('会话已结束,请重新登录管理平台后打开任务清单。', true)
renderRows()
updateControls()
}
for (const button of filters)
button.addEventListener('click', () => setStatus(button.dataset.status))
element('clear-filter').addEventListener('click', () => setStatus(''))
for (const id of ['create-task', 'empty-create'])
element(id).addEventListener('click', () => openEditor())
for (const id of ['refresh', 'retry'])
element(id).addEventListener('click', () => loadTasks({ reloadInfo: true }))
element('previous-page').addEventListener('click', () => {
state.page--
reloadQuery()
})
element('next-page').addEventListener('click', () => {
state.page++
reloadQuery()
})
element('page-size').addEventListener('change', () => {
state.pageSize = Number(element('page-size').value)
state.page = 1
reloadQuery()
})
for (const id of ['close-dialog', 'cancel-dialog'])
element(id).addEventListener('click', closeEditor)
element('task-dialog').addEventListener('cancel', (event) => {
event.preventDefault()
closeEditor()
})
element('delete-dialog').addEventListener('cancel', (event) => {
if (state.mutating) event.preventDefault()
})
element('cancel-delete').addEventListener('click', () => {
if (!state.mutating) element('delete-dialog').close()
})
element('confirm-delete').addEventListener('click', deleteTask)
element('task-form').addEventListener('submit', saveTask)
for (const id of ['task-title', 'task-description'])
element(id).addEventListener('input', updateCounts)
async function connect() {
try {
const config = JSON.parse(element('ruoyi-plugin-config')?.textContent || '{}')
if (config.pluginId !== 'task_demo') throw new Error('请从管理平台或本地模拟宿主打开任务清单。')
client = createPluginClient({ pluginId: config.pluginId })
client.subscribe(({ type, payload }) => {
if (type === 'preferences') applyContext(payload)
if (type === 'refresh') loadTasks({ reloadInfo: true })
if (type === 'logout') endSession()
})
applyContext(await client.ready)
state.connected = true
await loadTasks({ reloadInfo: true })
} catch (error) {
state.loading = false
showNotice(error.message || '连接失败,请重新打开任务清单。', true)
updateControls()
}
}
connect()
window.addEventListener(
'pagehide',
() => {
listController?.abort()
detailController?.abort()
client?.destroy()
},
{ once: true }
)
@@ -0,0 +1,952 @@
:root {
font-family: 'Segoe UI', 'Microsoft YaHei', 'PingFang SC', sans-serif;
color: var(--ink);
background: var(--canvas);
color-scheme: light;
--canvas: #f3f6f7;
--surface: #ffffff;
--surface-subtle: #f7f9fa;
--ink: #172b36;
--muted: #516574;
--border: #d6e0e4;
--control-border: #879aa4;
--accent: #176b62;
--accent-hover: #10564e;
--on-accent: #ffffff;
--accent-soft: #e6f3f0;
--pending: #805300;
--pending-soft: #fff3d0;
--complete: #136849;
--complete-soft: #e0f3e9;
--danger: #b42318;
--danger-hover: #8f1d14;
--danger-soft: #fff0ee;
--on-danger: #ffffff;
--scrim: rgb(10 25 35 / 48%);
--shadow: 0 18px 60px rgb(18 41 53 / 18%);
--mono: 'Cascadia Mono', Consolas, monospace;
}
:root[data-theme='dark'] {
color-scheme: dark;
--canvas: #111c25;
--surface: #172631;
--surface-subtle: #1c2e3a;
--ink: #e8f0f5;
--muted: #aec0c9;
--border: #344854;
--control-border: #6c8390;
--accent: #79d8c6;
--accent-hover: #a5e7db;
--on-accent: #0d3028;
--accent-soft: #193c38;
--pending: #f4cd79;
--pending-soft: #43391e;
--complete: #8ce0b8;
--complete-soft: #1e3d32;
--danger: #ffad9f;
--danger-hover: #ffccbf;
--danger-soft: #432a29;
--on-danger: #431b15;
--scrim: rgb(3 10 15 / 68%);
--shadow: 0 18px 60px rgb(0 0 0 / 35%);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-size: 14px;
line-height: 1.6;
}
button,
input,
textarea,
select {
font: inherit;
}
button,
select {
cursor: pointer;
touch-action: manipulation;
}
button {
color: inherit;
}
button:disabled,
select:disabled {
cursor: not-allowed;
opacity: 0.55;
}
button,
input,
textarea,
select {
transition:
background-color 160ms ease,
border-color 160ms ease,
color 160ms ease;
}
:focus-visible {
outline: 3px solid var(--accent);
outline-offset: 3px;
}
[hidden] {
display: none !important;
}
h1,
h2,
h3,
p {
margin: 0;
}
svg {
display: block;
flex: 0 0 auto;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.skip-link {
position: fixed;
top: 8px;
left: 16px;
z-index: 5;
padding: 10px 16px;
transform: translateY(-180%);
color: var(--on-accent);
background: var(--accent);
border-radius: 6px;
}
.skip-link:focus {
transform: translateY(0);
}
.workspace {
width: min(100%, 1280px);
margin: 0 auto;
padding: 32px clamp(16px, 3vw, 40px) 40px;
}
.page-header {
display: flex;
gap: 24px;
justify-content: space-between;
align-items: center;
margin-bottom: 28px;
}
.page-heading {
display: flex;
align-items: flex-start;
gap: 16px;
}
.page-mark {
display: grid;
place-items: center;
width: 48px;
height: 48px;
border-radius: 12px;
background: var(--accent-soft);
color: var(--accent);
margin-top: 6px;
}
.page-mark svg {
width: 28px;
height: 28px;
}
.eyebrow {
margin-bottom: 4px;
color: var(--muted);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
}
h1 {
font-family: 'Microsoft YaHei UI', 'PingFang SC', sans-serif;
font-size: 28px;
line-height: 1.35;
font-weight: 650;
letter-spacing: 0.01em;
}
.page-description {
margin-top: 8px;
color: var(--muted);
}
.header-actions {
display: flex;
align-items: center;
gap: 18px;
flex-shrink: 0;
}
.access-mode {
color: var(--muted);
font-size: 13px;
white-space: nowrap;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
min-height: 42px;
padding: 9px 16px;
border: 1px solid transparent;
border-radius: 7px;
font-weight: 600;
white-space: nowrap;
}
.button-primary {
color: var(--on-accent);
background: var(--accent);
border-color: var(--accent);
}
.button-primary:not(:disabled):hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.button-secondary {
color: var(--ink);
background: var(--surface);
border-color: var(--control-border);
}
.button-secondary:not(:disabled):hover {
background: var(--surface-subtle);
border-color: var(--accent);
}
.button-danger {
background: var(--danger);
color: var(--on-danger);
}
.button-danger:not(:disabled):hover {
background: var(--danger-hover);
}
.icon {
width: 18px;
height: 18px;
}
.read-only-notice {
margin-bottom: 16px;
padding: 12px 16px;
color: var(--pending);
background: var(--pending-soft);
border-radius: 7px;
}
.notice {
margin-bottom: 16px;
padding: 12px 16px;
color: var(--accent);
background: var(--accent-soft);
border-radius: 7px;
overflow-wrap: anywhere;
}
.notice:empty {
display: none;
}
.notice.is-error {
color: var(--danger);
background: var(--danger-soft);
}
.list-panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.list-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 22px 24px 16px;
}
.list-heading-row {
display: flex;
align-items: center;
gap: 10px;
}
.list-heading-row h2 {
font-size: 18px;
font-weight: 650;
}
.task-count {
font-family: var(--mono);
color: var(--muted);
font-size: 12px;
padding: 2px 7px;
background: var(--surface-subtle);
border: 1px solid var(--border);
border-radius: 5px;
white-space: nowrap;
}
.filter-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0 24px 16px;
}
.status-filters {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 4px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface-subtle);
}
.filter-button {
min-height: 36px;
padding: 6px 17px;
border: 1px solid transparent;
border-radius: 5px;
background: transparent;
color: var(--muted);
white-space: nowrap;
}
.filter-button[aria-pressed='true'] {
color: var(--accent);
background: var(--surface);
border-color: var(--border);
font-weight: 650;
}
.filter-button:not(:disabled):hover {
color: var(--accent);
}
.list-hint {
color: var(--muted);
font-size: 12px;
}
.list-region {
min-height: 240px;
position: relative;
}
.loading-state {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
padding: 30px 20px;
color: var(--muted);
}
.loading-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
animation: loading-pulse 1s ease-in-out infinite alternate;
}
@keyframes loading-pulse {
from {
opacity: 0.3;
}
to {
opacity: 1;
}
}
.list-region[aria-busy='true'] .task-table {
opacity: 0.55;
}
.task-table {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
text-align: left;
}
.task-table th {
padding: 12px 16px;
background: var(--surface-subtle);
color: var(--muted);
font-size: 12px;
font-weight: 600;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
.task-table th:first-child {
width: 43%;
padding-left: 24px;
}
.task-table th:nth-child(2) {
width: 12%;
}
.task-table th:nth-child(3) {
width: 12%;
}
.task-table th:nth-child(4) {
width: 19%;
}
.task-table th:nth-child(5) {
width: 14%;
}
.task-table td {
padding: 18px 16px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.task-table td:first-child {
padding-left: 24px;
position: relative;
}
.task-table tr:last-child td {
border-bottom: 0;
}
.task-table tbody tr:hover {
background: var(--surface-subtle);
}
.task-table .is-priority td:first-child::before {
content: '';
position: absolute;
left: 0;
top: 18px;
bottom: 18px;
width: 3px;
border-radius: 0 3px 3px 0;
background: var(--pending);
}
.task-title-button {
display: block;
max-width: 100%;
padding: 0;
border: 0;
background: transparent;
color: var(--ink);
font-size: 15px;
font-weight: 600;
line-height: 1.6;
text-align: left;
overflow-wrap: anywhere;
}
.task-title-button:not(:disabled):hover {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 3px;
}
.task-description {
margin-top: 4px;
color: var(--muted);
font-size: 13px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
overflow-wrap: anywhere;
}
.task-id {
display: inline-block;
margin-top: 6px;
font: 11px/1.5 var(--mono);
color: var(--muted);
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
font-size: 12px;
font-weight: 600;
border-radius: 5px;
white-space: nowrap;
}
.status-badge::before {
content: '';
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
}
.status-todo {
color: var(--pending);
background: var(--pending-soft);
}
.status-done {
color: var(--complete);
background: var(--complete-soft);
}
.priority-label {
color: var(--muted);
font-size: 13px;
white-space: nowrap;
}
.priority-high {
color: var(--pending);
font-weight: 600;
}
.updated-time {
color: var(--muted);
font: 12px/1.7 var(--mono);
overflow-wrap: anywhere;
}
.row-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.row-button {
min-height: 32px;
padding: 3px 0;
color: var(--accent);
background: transparent;
border: 0;
font-size: 13px;
font-weight: 600;
}
.row-button:not(:disabled):hover {
text-decoration: underline;
text-underline-offset: 3px;
}
.row-delete {
color: var(--danger);
}
.actions-heading {
text-align: right;
}
.read-only-label {
font-size: 12px;
color: var(--muted);
white-space: nowrap;
}
.empty-state {
display: grid;
justify-items: center;
gap: 10px;
padding: 42px 24px 52px;
text-align: center;
}
.empty-icon {
width: 56px;
height: 56px;
color: var(--muted);
margin-bottom: 4px;
}
.empty-state h3 {
font-size: 17px;
}
.empty-state p {
color: var(--muted);
margin-bottom: 8px;
}
.error-panel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin: 0 24px 16px;
padding: 14px 16px;
border: 1px solid var(--danger);
border-radius: 7px;
color: var(--danger);
background: var(--danger-soft);
overflow-wrap: anywhere;
}
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 16px 24px;
border-top: 1px solid var(--border);
}
.pagination p {
color: var(--muted);
font-size: 13px;
}
.page-controls {
display: flex;
align-items: center;
gap: 9px;
}
.page-size-label {
color: var(--muted);
font-size: 12px;
}
.page-controls select {
width: 79px;
min-height: 38px;
}
.page-controls .button {
min-height: 38px;
padding: 7px 12px;
font-size: 13px;
}
.page-number {
min-width: 34px;
text-align: center;
color: var(--muted);
font: 12px/1.5 var(--mono);
}
input,
textarea,
select {
width: 100%;
border: 1px solid var(--control-border);
border-radius: 6px;
background: var(--surface);
color: var(--ink);
padding: 10px 12px;
}
input::placeholder,
textarea::placeholder {
color: var(--muted);
opacity: 0.85;
}
textarea {
resize: vertical;
min-height: 120px;
}
input:read-only,
textarea:read-only {
background: var(--surface-subtle);
}
.dialog {
width: min(600px, calc(100% - 32px));
max-height: calc(100dvh - 32px);
padding: 0;
border: 1px solid var(--border);
border-radius: 12px;
color: var(--ink);
background: var(--surface);
box-shadow: var(--shadow);
overflow: auto;
}
.dialog::backdrop {
background: var(--scrim);
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
padding: 24px 24px 18px;
border-bottom: 1px solid var(--border);
}
.dialog h2 {
font-size: 21px;
line-height: 1.4;
}
.icon-button {
width: 40px;
min-height: 40px;
display: grid;
place-items: center;
padding: 0;
color: var(--muted);
background: transparent;
border: 1px solid transparent;
border-radius: 7px;
}
.icon-button:not(:disabled):hover {
color: var(--ink);
background: var(--surface-subtle);
border-color: var(--border);
}
.dialog-body {
padding: 22px 24px 24px;
}
fieldset {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.field {
display: grid;
gap: 8px;
margin-bottom: 20px;
}
.field > label,
.field-label label {
font-weight: 600;
}
.field-label {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 16px;
}
.field-count {
color: var(--muted);
font: 11px/1.5 var(--mono);
white-space: nowrap;
}
.field-hint {
color: var(--muted);
font-size: 12px;
}
.required-mark {
color: var(--danger);
}
.field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.field-grid .field {
margin: 0;
}
.form-error {
padding: 12px 14px;
margin-bottom: 18px;
color: var(--danger);
border: 1px solid var(--danger);
background: var(--danger-soft);
border-radius: 6px;
overflow-wrap: anywhere;
}
.inline-notice {
padding: 10px 12px;
color: var(--muted);
background: var(--surface-subtle);
border-radius: 6px;
margin-bottom: 18px;
}
.task-timestamps {
margin-top: 20px;
padding-top: 14px;
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 12px;
}
.dialog-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 18px 24px;
border-top: 1px solid var(--border);
background: var(--surface-subtle);
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-left: auto;
}
.save-state {
color: var(--muted);
font-size: 12px;
}
.delete-dialog {
width: min(460px, calc(100% - 32px));
}
.delete-task-title {
padding: 12px 14px;
margin: 18px 0 12px;
background: var(--surface-subtle);
border-left: 3px solid var(--danger);
border-radius: 0 6px 6px 0;
font-weight: 600;
overflow-wrap: anywhere;
}
.muted-text {
color: var(--muted);
}
.delete-dialog .form-error {
margin-top: 16px;
margin-bottom: 0;
}
@media (max-width: 960px) {
.task-table th:first-child {
width: 37%;
}
.task-table th:nth-child(2),
.task-table th:nth-child(3) {
width: 13%;
}
.task-table th:nth-child(4) {
width: 22%;
}
.task-table th:nth-child(5) {
width: 15%;
}
.task-table th,
.task-table td {
padding-left: 10px;
padding-right: 10px;
}
.pagination {
align-items: flex-start;
flex-direction: column;
}
.page-controls {
width: 100%;
justify-content: flex-end;
}
}
@media (max-width: 640px) {
body {
font-size: 15px;
}
.workspace {
padding-top: 22px;
}
.page-header {
flex-direction: column;
align-items: stretch;
gap: 20px;
margin-bottom: 20px;
}
.page-heading {
gap: 12px;
}
.page-mark {
width: 42px;
height: 42px;
}
h1 {
font-size: 25px;
}
.page-description {
font-size: 13px;
}
.header-actions {
justify-content: space-between;
}
.button,
.filter-button,
.page-controls .button,
.page-controls select,
.row-button {
min-height: 44px;
}
.list-toolbar {
padding: 18px 16px 12px;
}
.list-heading-row {
gap: 6px;
}
.list-heading-row h2 {
font-size: 17px;
}
.list-toolbar .button {
padding: 8px 10px;
font-size: 12px;
}
.filter-row {
padding: 0 16px 16px;
}
.status-filters {
width: 100%;
}
.filter-button {
flex: 1;
padding-left: 10px;
padding-right: 10px;
}
.list-hint {
display: none;
}
.task-table,
.task-table tbody {
display: block;
}
.task-table thead {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.task-table tr {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 14px;
padding: 18px 16px;
border-top: 1px solid var(--border);
}
.task-table td {
display: block;
padding: 0 !important;
border: 0;
}
.task-table td:first-child,
.task-table td:last-child {
grid-column: 1 / -1;
}
.task-table td:nth-child(4) {
grid-column: 1 / -1;
}
.task-table td[data-label]::before {
content: attr(data-label);
display: inline-block;
margin-right: 8px;
color: var(--muted);
font-size: 12px;
}
.task-table .is-priority td:first-child::before {
left: -16px;
top: 0;
bottom: 0;
}
.task-title-button {
font-size: 16px;
min-height: 32px;
}
.row-actions {
gap: 20px;
margin-top: -6px;
}
.row-button {
padding: 6px 4px;
}
.pagination {
padding: 16px;
gap: 12px;
}
.page-controls {
gap: 6px;
justify-content: space-between;
flex-wrap: wrap;
}
.page-size-label {
display: none;
}
.page-controls select {
width: 72px;
padding: 8px;
}
.page-controls .button {
padding: 8px 10px;
}
.page-number {
min-width: 28px;
}
.error-panel {
margin-left: 16px;
margin-right: 16px;
align-items: flex-start;
flex-direction: column;
}
.dialog-header {
padding: 20px 18px 16px;
}
.dialog-body {
padding: 20px 18px;
}
.dialog-footer {
padding: 16px 18px;
flex-wrap: wrap;
}
.field-grid {
gap: 12px;
}
input,
textarea,
select {
font-size: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
}
}
@@ -0,0 +1,92 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { createMockTaskApi } from '../dev/mockRequest.js'
const draft = {
title: '测试任务',
description: '保留完整说明',
status: 'todo',
priority: 'normal',
}
const call = (api, method, path, extra = {}) =>
api.request({
method,
url: `/apps/task_demo/api/${path}`,
...extra,
})
test('内存数据支持分页、筛选、完整 CRUD 和响应副本隔离', async () => {
const api = createMockTaskApi({ getDelay: () => 0 })
const first = await call(api, 'get', 'tasks', {
params: { page: 1, pageSize: 10 },
})
const second = await call(api, 'get', 'tasks', {
params: { page: 2, pageSize: 10 },
})
assert.equal(first.total, 12)
assert.equal(first.items.length, 10)
assert.equal(second.items.length, 2)
assert.equal(new Set([...first.items, ...second.items].map((task) => task.id)).size, 12)
const created = await call(api, 'post', 'tasks', { data: draft })
assert.equal(created.title, draft.title)
created.title = '修改响应不能污染存储'
assert.equal((await call(api, 'get', `tasks/${created.id}`)).title, draft.title)
const updated = await call(api, 'put', `tasks/${created.id}`, {
data: { ...draft, status: 'done', priority: 'high' },
})
assert.equal(updated.createdAt, created.createdAt)
const done = await call(api, 'get', 'tasks', { params: { status: 'done' } })
assert.ok(done.items.every((task) => task.status === 'done'))
assert.ok(done.items.some((task) => task.id === created.id && task.priority === 'high'))
assert.deepEqual(await call(api, 'delete', `tasks/${created.id}`), {
deleted: true,
})
await assert.rejects(call(api, 'get', `tasks/${created.id}`), /不存在/)
})
test('只读权限在请求层拒绝所有写操作并保留数据', async () => {
let writable = true
const api = createMockTaskApi({
getDelay: () => 0,
getCanWrite: () => writable,
})
const task = await call(api, 'post', 'tasks', { data: draft })
writable = false
assert.equal((await call(api, 'get', 'info')).canWrite, false)
assert.equal((await call(api, 'get', 'tasks')).canWrite, false)
for (const [method, path] of [
['post', 'tasks'],
['put', `tasks/${task.id}`],
['delete', `tasks/${task.id}`],
]) {
await assert.rejects(call(api, method, path, { data: draft }), /写入权限/)
}
assert.equal((await call(api, 'get', 'tasks')).total, 13)
assert.equal((await call(api, 'get', `tasks/${task.id}`)).title, draft.title)
})
test('失败、取消及无效输入不会产生写入副作用', async () => {
let fail = true
const api = createMockTaskApi({ getDelay: () => 10, shouldFail: () => fail })
await assert.rejects(call(api, 'post', 'tasks', { data: draft }), /模拟请求失败/)
fail = false
const controller = new AbortController()
const pending = call(api, 'post', 'tasks', {
data: draft,
signal: controller.signal,
})
controller.abort()
await assert.rejects(pending, /取消/)
for (const data of [
{ ...draft, title: ' ' },
{ ...draft, description: 'x'.repeat(501) },
{ ...draft, status: 'invalid' },
]) {
await assert.rejects(call(api, 'post', 'tasks', { data }))
}
assert.equal((await call(api, 'get', 'tasks')).total, 12)
api.clear()
assert.equal((await call(api, 'get', 'tasks')).total, 0)
api.reset()
assert.equal((await call(api, 'get', 'tasks')).total, 12)
})
@@ -0,0 +1,37 @@
import { fileURLToPath, URL } from 'node:url'
// 开发配置只在本地服务注入,生产页面由宿主注入真实插件标识与部署基址。
export default {
base: './',
plugins: [
{
name: 'local-task-plugin-config',
apply: 'serve',
transformIndexHtml(html, context) {
if (context.path !== '/index.html' && context.path !== '/') return html
return {
html,
tags: [
{
tag: 'script',
attrs: { id: 'ruoyi-plugin-config', type: 'application/json' },
children: JSON.stringify({ pluginId: 'task_demo' }),
injectTo: 'head',
},
],
}
},
},
],
resolve: {
alias: {
'@ruoyi/plugin-bridge': fileURLToPath(
new URL(
'../../../../../../ruoyi-fastapi-frontend/src/utils/pluginBridge.js',
import.meta.url
)
),
},
},
build: { sourcemap: false },
}