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