Files
hms/apps/web/src/pages/PluginAdmin.tsx
iven b05b7c27a0
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
feat: 审计修复 Phase 6-7 — SSE 推送/工作流补全/消息群发/前端收尾
Phase 6 功能补全:
- P1-3: 消息 SSE 实时推送端点 + 前端 EventSource 连接
- P1-6: ServiceTask HTTP 调用能力 (reqwest GET/POST)
- P1-7: user.deleted 事件处理 — 终止相关流程实例
- P1-8: 任务认领 (claim) 端点 + handler
- P1-9: 超时检查器发布 task.timeout 事件
- P1-15: 组织/部门名称唯一性校验 (create + update)
- P1-18: 消息群发 fan-out (role/department/all 批量投递)

Phase 7 P3-P4 收尾:
- PluginAdmin purge 按钮状态修复
- ChangePassword 最小 8 字符 + 新旧密码不同验证
- AuditLogViewer 用户名缓存 + 扩展资源类型
- InstanceMonitor 通过 definition 缓存解析 node_name
- NotificationPreferences DND 时间范围校验
2026-04-26 19:44:04 +08:00

410 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, useCallback } from 'react';
import {
Table,
Button,
Space,
Tag,
message,
Upload,
Modal,
Input,
Drawer,
Descriptions,
Popconfirm,
Form,
Tabs,
theme,
} from 'antd';
import {
UploadOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
CloudDownloadOutlined,
DeleteOutlined,
ReloadOutlined,
HeartOutlined,
SettingOutlined,
} from '@ant-design/icons';
import type { PluginInfo, PluginStatus, PluginSchemaResponse } from '../api/plugins';
import {
listPlugins,
uploadPlugin,
installPlugin,
enablePlugin,
disablePlugin,
uninstallPlugin,
purgePlugin,
getPluginHealth,
getPluginSchema,
updatePluginConfig,
} from '../api/plugins';
import PluginSettingsForm from '../components/PluginSettingsForm';
const STATUS_CONFIG: Record<PluginStatus, { color: string; label: string }> = {
uploaded: { color: '#475569', label: '已上传' },
installed: { color: '#2563EB', label: '已安装' },
enabled: { color: '#059669', label: '已启用' },
running: { color: '#059669', label: '运行中' },
disabled: { color: '#dc2626', label: '已禁用' },
uninstalled: { color: '#9333EA', label: '已卸载' },
};
export default function PluginAdmin() {
const [plugins, setPlugins] = useState<PluginInfo[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [manifestText, setManifestText] = useState('');
const [wasmFile, setWasmFile] = useState<File | null>(null);
const [detailPlugin, setDetailPlugin] = useState<PluginInfo | null>(null);
const [schemaData, setSchemaData] = useState<PluginSchemaResponse | null>(null);
const [healthDetail, setHealthDetail] = useState<Record<string, unknown> | null>(null);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const { token } = theme.useToken();
const fetchPlugins = useCallback(async (p = page) => {
setLoading(true);
try {
const result = await listPlugins(p);
setPlugins(result.data);
setTotal(result.total);
} catch {
message.error('加载插件列表失败');
}
setLoading(false);
}, [page]);
useEffect(() => {
fetchPlugins();
}, [fetchPlugins]);
// 打开详情时加载 schema含 settings
useEffect(() => {
if (!detailPlugin) {
setSchemaData(null);
return;
}
getPluginSchema(detailPlugin.id)
.then(setSchemaData)
.catch(() => setSchemaData(null));
}, [detailPlugin]);
const handleUpload = async () => {
if (!wasmFile || !manifestText.trim()) {
message.warning('请选择 WASM 文件并填写 Manifest');
return;
}
try {
await uploadPlugin(wasmFile, manifestText);
message.success('插件上传成功');
setUploadModalOpen(false);
setWasmFile(null);
setManifestText('');
fetchPlugins();
} catch {
message.error('插件上传失败');
}
};
const handleAction = async (id: string, action: () => Promise<PluginInfo>, label: string) => {
setActionLoading(id);
try {
await action();
message.success(`${label}成功`);
fetchPlugins();
if (detailPlugin?.id === id) {
setDetailPlugin(null);
}
} catch {
message.error(`${label}失败`);
}
setActionLoading(null);
};
const handleHealthCheck = async (id: string) => {
try {
const result = await getPluginHealth(id);
setHealthDetail(result.details);
} catch {
message.error('健康检查失败');
}
};
const getActions = (record: PluginInfo) => {
const id = record.id;
const btns: React.ReactNode[] = [];
switch (record.status) {
case 'uploaded':
btns.push(
<Button
key="install"
size="small"
icon={<CloudDownloadOutlined />}
loading={actionLoading === id}
onClick={() => handleAction(id, () => installPlugin(id), '安装')}
>
</Button>,
);
break;
case 'installed':
btns.push(
<Button
key="enable"
size="small"
type="primary"
icon={<PlayCircleOutlined />}
loading={actionLoading === id}
onClick={() => handleAction(id, () => enablePlugin(id), '启用')}
>
</Button>,
);
break;
case 'enabled':
case 'running':
btns.push(
<Button
key="disable"
size="small"
danger
icon={<PauseCircleOutlined />}
loading={actionLoading === id}
onClick={() => handleAction(id, () => disablePlugin(id), '停用')}
>
</Button>,
);
break;
case 'disabled':
btns.push(
<Button
key="enable"
size="small"
type="primary"
icon={<PlayCircleOutlined />}
loading={actionLoading === id}
onClick={() => handleAction(id, () => enablePlugin(id), '启用')}
>
</Button>,
<Button
key="uninstall"
size="small"
icon={<DeleteOutlined />}
loading={actionLoading === id}
onClick={() => handleAction(id, () => uninstallPlugin(id), '卸载')}
>
</Button>,
);
break;
}
return btns;
};
const columns = [
{ title: '名称', dataIndex: 'name', key: 'name', width: 180 },
{ title: '版本', dataIndex: 'version', key: 'version', width: 80 },
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: PluginStatus) => {
const cfg = STATUS_CONFIG[status] || { color: '#475569', label: status };
return <Tag color={cfg.color}>{cfg.label}</Tag>;
},
},
{ title: '作者', dataIndex: 'author', key: 'author', width: 120 },
{
title: '描述',
dataIndex: 'description',
key: 'description',
ellipsis: true,
},
{
title: '操作',
key: 'action',
width: 320,
render: (_: unknown, record: PluginInfo) => (
<Space size="small">
{getActions(record)}
<Button size="small" onClick={() => setDetailPlugin(record)}>
</Button>
<Popconfirm
title="确定要清除该插件记录吗?"
onConfirm={() => handleAction(record.id, async () => { await purgePlugin(record.id); return record; }, '清除')}
>
<Button size="small" danger disabled={!['uninstalled', 'disabled', 'uploaded', 'installed'].includes(record.status)}>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<Space>
<Button icon={<UploadOutlined />} type="primary" onClick={() => setUploadModalOpen(true)}>
</Button>
<Button icon={<ReloadOutlined />} onClick={() => fetchPlugins()}>
</Button>
</Space>
</div>
<Table
columns={columns}
dataSource={plugins}
rowKey="id"
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => setPage(p),
showTotal: (t) => `${t} 个插件`,
}}
/>
<Modal
title="上传插件"
open={uploadModalOpen}
onOk={handleUpload}
onCancel={() => setUploadModalOpen(false)}
okText="上传"
width={600}
>
<Form layout="vertical">
<Form.Item label="WASM 文件" required>
<Upload
beforeUpload={(file) => {
setWasmFile(file);
return false;
}}
maxCount={1}
accept=".wasm"
fileList={[]}
onRemove={() => setWasmFile(null)}
>
<Button icon={<UploadOutlined />}> WASM </Button>
</Upload>
</Form.Item>
<Form.Item label="Manifest (TOML)" required>
<Input.TextArea
rows={12}
value={manifestText}
onChange={(e) => setManifestText(e.target.value)}
placeholder="[metadata]
id = &quot;my-plugin&quot;
name = &quot;我的插件&quot;
version = &quot;0.1.0&quot;"
/>
</Form.Item>
</Form>
</Modal>
<Drawer
title={detailPlugin ? `插件详情: ${detailPlugin.name}` : '插件详情'}
open={!!detailPlugin}
onClose={() => {
setDetailPlugin(null);
setHealthDetail(null);
setSchemaData(null);
}}
width={500}
>
{detailPlugin && (
<Tabs
defaultActiveKey="info"
items={[
{
key: 'info',
label: '基本信息',
children: (
<>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="ID">{detailPlugin.id}</Descriptions.Item>
<Descriptions.Item label="名称">{detailPlugin.name}</Descriptions.Item>
<Descriptions.Item label="版本">{detailPlugin.version}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={STATUS_CONFIG[detailPlugin.status]?.color}>
{STATUS_CONFIG[detailPlugin.status]?.label || detailPlugin.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="作者">{detailPlugin.author || '-'}</Descriptions.Item>
<Descriptions.Item label="描述">{detailPlugin.description || '-'}</Descriptions.Item>
<Descriptions.Item label="安装时间">{detailPlugin.installed_at || '-'}</Descriptions.Item>
<Descriptions.Item label="启用时间">{detailPlugin.enabled_at || '-'}</Descriptions.Item>
<Descriptions.Item label="实体数量">{detailPlugin.entities.length}</Descriptions.Item>
</Descriptions>
<div style={{ marginTop: 16 }}>
<Button
icon={<HeartOutlined />}
onClick={() => handleHealthCheck(detailPlugin.id)}
style={{ marginBottom: 8 }}
>
</Button>
{healthDetail && (
<pre
style={{
background: token.colorBgContainer,
padding: 12,
borderRadius: 6,
fontSize: 12,
overflow: 'auto',
}}
>
{JSON.stringify(healthDetail, null, 2)}
</pre>
)}
</div>
</>
),
},
...(schemaData?.settings
? [
{
key: 'settings',
label: (
<span>
<SettingOutlined />
</span>
),
children: (
<PluginSettingsForm
fields={schemaData.settings.fields}
values={detailPlugin.config as Record<string, unknown>}
recordVersion={detailPlugin.record_version}
onSave={async (config, version) => {
const updated = await updatePluginConfig(
detailPlugin.id,
config,
version,
);
setDetailPlugin({ ...detailPlugin, ...updated });
}}
readOnly={detailPlugin.status !== 'enabled' && detailPlugin.status !== 'running'}
/>
),
},
]
: []),
]}
/>
)}
</Drawer>
</div>
);
}