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