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 时间范围校验
410 lines
13 KiB
TypeScript
410 lines
13 KiB
TypeScript
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 = "my-plugin"
|
||
name = "我的插件"
|
||
version = "0.1.0""
|
||
/>
|
||
</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>
|
||
);
|
||
}
|