From eead9ed7f2d27d9e1eff289064d5abc354d141aa Mon Sep 17 00:00:00 2001 From: biss Date: Sun, 4 Oct 2026 08:35:50 +0800 Subject: [PATCH] feat(web): migrate plugin management --- web-react/MIGRATION_MATRIX.md | 1 + web-react/README.md | 1 + web-react/src/app/App.tsx | 2 ++ web-react/src/layout/AppShell.tsx | 2 +- web-react/src/pages/PluginsPage.tsx | 22 ++++++++++++++++++++++ web-react/src/pages/plugins.css | 1 + 6 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 web-react/src/pages/PluginsPage.tsx create mode 100644 web-react/src/pages/plugins.css diff --git a/web-react/MIGRATION_MATRIX.md b/web-react/MIGRATION_MATRIX.md index 8819b58..e688873 100644 --- a/web-react/MIGRATION_MATRIX.md +++ b/web-react/MIGRATION_MATRIX.md @@ -57,6 +57,7 @@ | `/classroom-repairs` | 教室报修与维修 | SuperAdmin、AcademicAdmin、MaintenanceAdmin、MaintenanceStaff | 工单筛选、分派/开工/完工、详情、设备档案与扫码报修二维码 | verification | | `/grades/:sheetId/statistics` | 课程成绩统计 | 具备该成绩单数据范围的已登录用户 | 班级/专业/学院/全校分数分布、对比、课程讨论与互动 | verification | | `/event-analytics` | 运行数据分析 | SuperAdmin、AcademicAdmin、CollegeAdmin、Leader | ClickHouse 考勤、成绩与全校范围访问审计只读聚合 | verification | +| `/plugins` | 统一插件平台 | SuperAdmin | 运行状态、签名包暂存/激活/回滚、无数据删除的安全停用 | verification | | `/activate` | 学生账号激活 | 公开 | 学籍选项、身份匹配、强密码和一次性激活 | verification | | `/verify/:verificationCode?` | 电子凭证验真 | 公开 | 唯一编号、实时状态、签发信息和 PDF 哈希 | verification | | `/classroom-repairs/report/:classroomId` | 教室扫码报修 | 公开 | 教室设备、故障类别、联系方式和工单提交 | verification | diff --git a/web-react/README.md b/web-react/README.md index 842dc8f..0ff420c 100644 --- a/web-react/README.md +++ b/web-react/README.md @@ -55,6 +55,7 @@ npm.cmd run build - 迁移场地信息展牌管理,支持展牌链接、批量导出与定向公告投放。 - 迁移教室报修与维修后台,覆盖工单分派、维修状态闭环、设备档案与场地报修码。 - 迁移课程成绩统计与运行数据分析,保留范围对比、课程讨论互动和 ClickHouse 只读聚合。 +- 迁移统一插件平台,支持运行状态管理及签名包暂存、激活与回滚。 ## 迁移纪律 diff --git a/web-react/src/app/App.tsx b/web-react/src/app/App.tsx index f223c56..46c09ef 100644 --- a/web-react/src/app/App.tsx +++ b/web-react/src/app/App.tsx @@ -55,6 +55,7 @@ const VenueDisplaysPage = lazy(() => import('../pages/VenueDisplaysPage')) const ClassroomRepairsPage = lazy(() => import('../pages/ClassroomRepairsPage')) const CourseGradeStatisticsPage = lazy(() => import('../pages/CourseGradeStatisticsPage')) const EventAnalyticsPage = lazy(() => import('../pages/EventAnalyticsPage')) +const PluginsPage = lazy(() => import('../pages/PluginsPage')) const AccountActivationPage = lazy(() => import('../pages/AccountActivationPage')) const OfficialDocumentVerifyPage = lazy(() => import('../pages/OfficialDocumentVerifyPage')) const ClassroomRepairReportPage = lazy(() => import('../pages/ClassroomRepairReportPage')) @@ -158,6 +159,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> diff --git a/web-react/src/layout/AppShell.tsx b/web-react/src/layout/AppShell.tsx index b603357..1c28068 100644 --- a/web-react/src/layout/AppShell.tsx +++ b/web-react/src/layout/AppShell.tsx @@ -141,7 +141,7 @@ export default function AppShell() { { key: '/students', icon: , label: '学生档案' }, ] : []), ...(user?.roles.includes('SuperAdmin') - ? [{ key: '/users', icon: , label: '用户与权限' }] + ? [{ key: '/users', icon: , label: '用户与权限' }, { key: '/plugins', icon: , label: '插件平台' }] : []), { key: 'legacy', icon: , label: '返回现有系统' }, ], [isStudentHome, isTeacherHome, user?.roles]) diff --git a/web-react/src/pages/PluginsPage.tsx b/web-react/src/pages/PluginsPage.tsx new file mode 100644 index 0000000..532117f --- /dev/null +++ b/web-react/src/pages/PluginsPage.tsx @@ -0,0 +1,22 @@ +import { useMemo, useState } from 'react' +import { AppstoreAddOutlined, LockOutlined, PoweroffOutlined, ReloadOutlined, UploadOutlined } from '@ant-design/icons' +import { Alert, App, Button, Empty, Popconfirm, Skeleton, Switch, Table, Tag, Upload } from 'antd' +import type { UploadFile } from 'antd' +import { useQuery } from '@tanstack/react-query' +import http, { apiErrorMessage } from '../api/http' +import PageHeader from '../components/PageHeader' +import './plugins.css' +interface Plugin { id: string; name: string; description: string; version: string; category: string; isEnabled: boolean; navigationPaths: string[]; capabilities: string[]; stateChangedAt?: string; source: 'built-in' | 'package'; status: string; requiresRestart: boolean } +interface Release { id: string; pluginId: string; name: string; version: string; publisherId: string; hostApiVersion: string; sha256: string; packageSize: number; status: 'Staged' | 'PendingActivation' | 'Active' | 'Superseded' | 'Failed'; createdAt: string; failureMessage?: string } +const status: Record = { Staged: '已暂存', PendingActivation: '待重启激活', Active: '活动版本', Superseded: '历史版本', Failed: '装载失败' } +const bytes = (value: number) => value < 1024 * 1024 ? `${(value / 1024).toFixed(1)} KB` : `${(value / 1024 / 1024).toFixed(1)} MB` +export default function PluginsPage() { + const { message, modal } = App.useApp(); const [busy, setBusy] = useState(''); const [packageFile, setPackageFile] = useState(); const [signatureFile, setSignatureFile] = useState(); const runtime = useQuery({ queryKey: ['plugins-admin'], queryFn: async () => (await http.get('/plugins')).data }); const packages = useQuery({ queryKey: ['plugin-packages'], queryFn: async () => (await http.get('/plugins/packages')).data }); const groups = useMemo(() => Object.entries((runtime.data ?? []).reduce>((all, item) => { (all[item.category] ??= []).push(item); return all }, {})), [runtime.data]); const refresh = () => Promise.all([runtime.refetch(), packages.refetch()]); + async function state(item: Plugin, enabled: boolean) { modal.confirm({ title: `${enabled ? '启用' : '停用'}${item.name}`, content: enabled ? `启用后,具备原有角色权限的用户将重新看到并可使用“${item.name}”。` : `停用后,菜单、页面和 API 都会关闭;已有数据不会删除。`, okText: `确认${enabled ? '启用' : '停用'}`, okButtonProps: enabled ? undefined : { danger: true }, onOk: async () => { try { setBusy(item.id); await http.put(`/plugins/${encodeURIComponent(item.id)}/state`, { isEnabled: enabled }); message.success(`${item.name}已${enabled ? '启用' : '停用'}。`); await runtime.refetch() } catch (error) { message.error(apiErrorMessage(error)); throw error } finally { setBusy('') } } }) } + async function upload() { const packageRaw = packageFile?.originFileObj; const signatureRaw = signatureFile?.originFileObj; if (!packageRaw || !signatureRaw) return message.warning('请选择插件 ZIP 包和对应签名文件。'); try { setBusy('upload'); const body = new FormData(); body.append('package', packageRaw); body.append('signature', signatureRaw); await http.post('/plugins/packages', body); setPackageFile(undefined); setSignatureFile(undefined); message.success('签名校验通过,插件包已进入暂存区。'); await refresh() } catch (error) { message.error(apiErrorMessage(error)) } finally { setBusy('') } } + function activate(item: Release) { modal.confirm({ title: `激活 ${item.name}`, content: `版本 ${item.version} 将在下次服务重启时激活;当前活动版本会保留,可用于回滚。`, okText: '标记并等待重启', onOk: async () => { try { await http.post(`/plugins/packages/${item.id}/activate`); message.success('已标记为待激活,请按维护流程重启服务。'); await packages.refetch() } catch (error) { message.error(apiErrorMessage(error)); throw error } } }) } + async function remove(item: Release) { try { await http.delete(`/plugins/packages/${item.id}`); message.success('插件包记录已删除。'); await packages.refetch() } catch (error) { message.error(apiErrorMessage(error)) } } + if (runtime.isLoading || packages.isLoading) return
; if (runtime.isError || packages.isError) return
+ const enabled = runtime.data!.filter((item) => item.isEnabled).length + return
} onClick={() => void refresh()}>刷新} aside={
{enabled}/ {runtime.data!.length} 个插件运行中
} />} type="info" message="停用不会删除数据" description="状态变更立即作用于菜单、路由和 API;角色权限与数据范围仍由原业务接口负责。" />
SIGNED PACKAGE PIPELINE

签名插件包

上传只进入暂存区;校验通过后仍需标记激活并重启服务。

false} fileList={packageFile ? [packageFile] : []} onChange={({ fileList }) => setPackageFile(fileList[0])}> false} fileList={signatureFile ? [signatureFile] : []} onChange={({ fileList }) => setSignatureFile(fileList[0])}>
rowKey="id" dataSource={packages.data} pagination={false} scroll={{ x: 850 }} locale={{ emptyText: }} columns={[{ title: '插件 / 版本', render: (_, row) =>
{row.name}{row.pluginId} · v{row.version}
}, { title: '发布者', dataIndex: 'publisherId' }, { title: '校验', render: (_, row) =>
API {row.hostApiVersion} · {bytes(row.packageSize)}SHA-256 {row.sha256.slice(0, 12)}…
}, { title: '状态', render: (_, row) => {status[row.status]} }, { title: '操作', fixed: 'right', render: (_, row) => <>{!['Active', 'PendingActivation'].includes(row.status) && }{['Staged', 'Superseded'].includes(row.status) && remove(row)}>} }]} />
{groups.map(([category, items]) =>
{category}{items.length}
{items.map((item) =>
{item.requiresRestart ? '等待重启' : item.isEnabled ? '运行中' : '已停用'} state(item, value)} />

{item.name}

{item.id} · v{item.version}

{item.description}

{item.capabilities.map((capability) => {capability})}
)}
)}
+} diff --git a/web-react/src/pages/plugins.css b/web-react/src/pages/plugins.css new file mode 100644 index 0000000..f2bc630 --- /dev/null +++ b/web-react/src/pages/plugins.css @@ -0,0 +1 @@ +.plugins-page{gap:16px}.plugins-summary strong{font:700 32px var(--font-data);color:var(--teal)}.plugins-summary span{color:var(--ink-muted);font-size:12px}.package-panel,.plugin-group>div{border:1px solid var(--line);background:#fff}.package-panel>header{display:flex;justify-content:space-between;gap:16px;padding:16px}.package-panel header span,.plugin-group>header span{color:var(--teal);font:700 10px var(--font-data)}.package-panel h3{margin:5px 0;color:var(--brand-deep)}.package-panel p{margin:0;color:var(--ink-muted);font-size:12px}.package-upload{display:flex;gap:7px;align-items:center;flex-wrap:wrap}.package-panel small{display:block;color:var(--ink-muted)}.plugin-group>header{display:flex;gap:8px;align-items:center;margin:18px 0 9px}.plugin-group>div{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1px;background:var(--line)}.plugin-group article{padding:16px;background:#fff;border-top:3px solid var(--teal)}.plugin-group article.disabled{border-top-color:#a8b0b8;opacity:.7}.plugin-state{display:flex;gap:9px;align-items:center;color:var(--teal);font-size:12px}.plugin-state .ant-switch{margin-left:auto}.plugin-group h3{margin:17px 0 4px;color:var(--brand-deep)}.plugin-group code,.plugin-group p,.plugin-group footer{color:var(--ink-muted);font-size:12px}.plugin-caps{display:flex;gap:4px;flex-wrap:wrap}.plugin-group footer{margin-top:16px;padding-top:10px;border-top:1px solid var(--line)}@media(max-width:720px){.package-panel>header{display:block}.package-upload{margin-top:12px}.plugin-group>div{grid-template-columns:1fr}}