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