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