Files
hms/apps/web/src/pages/PluginCRUDPage.tsx
iven 4bcb4beaa5 feat(plugin): P1-P4 审计修复 — 第一批 (Excel/CSV导出 + 市场API + 对账扫描)
1.1 Excel/CSV 导出:
- 后端 export 支持 format 参数 (json/csv/xlsx)
- rust_xlsxwriter 生成带样式 Excel
- 前端导出按钮改为 Dropdown 格式选择 (JSON/CSV/Excel)
- blob 下载支持 CSV/XLSX 二进制格式

1.2 市场后端 API + 前端对接:
- SeaORM Entity: market_entry, market_review
- API: 浏览/详情/一键安装/评论列表/提交评分
- 一键安装: upload → install → enable 一条龙 + 依赖检查
- 前端 PluginMarket 对接真实 API (搜索/分类/安装/评分)

1.3 对账扫描:
- reconcile_references() 扫描跨插件引用悬空 UUID
- POST /plugins/{plugin_id}/reconcile 端点
2026-04-19 14:32:06 +08:00

868 lines
28 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 { useParams } from 'react-router-dom';
import {
Table,
Button,
Space,
Modal,
Form,
Input,
InputNumber,
DatePicker,
Switch,
Select,
Tag,
message,
Popconfirm,
Drawer,
Descriptions,
Segmented,
Timeline,
Upload,
Alert,
Dropdown,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
ReloadOutlined,
EyeOutlined,
DownloadOutlined,
UploadOutlined,
} from '@ant-design/icons';
import {
listPluginData,
createPluginData,
updatePluginData,
deletePluginData,
batchPluginData,
resolveRefLabels,
exportPluginData,
exportPluginDataAsBlob,
importPluginData,
type PluginDataListOptions,
type ImportResult,
} from '../api/pluginData';
import EntitySelect from '../components/EntitySelect';
import {
getPluginSchema,
type PluginFieldSchema,
type PluginEntitySchema,
type PluginPageSchema,
type PluginSectionSchema,
} from '../api/plugins';
import { evaluateVisibleWhen } from '../utils/exprEvaluator';
const { Search } = Input;
const { TextArea } = Input;
interface PluginCRUDPageProps {
/** 如果从 tabs/detail 页面内嵌使用,通过 props 传入配置 */
pluginIdOverride?: string;
entityOverride?: string;
filterField?: string;
filterValue?: string;
enableViews?: string[];
/** detail 页面内嵌时使用 compact 模式 */
compact?: boolean;
}
export default function PluginCRUDPage({
pluginIdOverride,
entityOverride,
filterField,
filterValue,
enableViews: enableViewsProp,
compact,
}: PluginCRUDPageProps = {}) {
const routeParams = useParams<{ pluginId: string; entityName: string }>();
const pluginId = pluginIdOverride || routeParams.pluginId || '';
const entityName = entityOverride || routeParams.entityName || '';
const [records, setRecords] = useState<Record<string, unknown>[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [fields, setFields] = useState<PluginFieldSchema[]>([]);
const [displayName, setDisplayName] = useState(entityName || '');
const [modalOpen, setModalOpen] = useState(false);
const [editRecord, setEditRecord] = useState<Record<string, unknown> | null>(null);
const [form] = Form.useForm();
const [formValues, setFormValues] = useState<Record<string, unknown>>({});
// 筛选/搜索/排序 state
const [searchText, setSearchText] = useState('');
const [filters, setFilters] = useState<Record<string, string>>({});
const [sortBy, setSortBy] = useState<string | undefined>();
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
// 视图切换
const [viewMode, setViewMode] = useState<string>('table');
// 批量选择
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
// 跨插件引用标签解析
const [resolvedLabels, setResolvedLabels] = useState<Record<string, Record<string, string | null>>>({});
const [labelMeta, setLabelMeta] = useState<Record<string, { plugin_installed: boolean }>>({});
// 详情 Drawer
const [detailOpen, setDetailOpen] = useState(false);
const [detailRecord, setDetailRecord] = useState<Record<string, unknown> | null>(null);
const [detailSections, setDetailSections] = useState<PluginSectionSchema[]>([]);
const [allEntities, setAllEntities] = useState<PluginEntitySchema[]>([]);
const [allPages, setAllPages] = useState<PluginPageSchema[]>([]);
// 导入导出
const [entityDef, setEntityDef] = useState<PluginEntitySchema | null>(null);
const [importModalOpen, setImportModalOpen] = useState(false);
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState<ImportResult | null>(null);
const [exporting, setExporting] = useState(false);
// 从 fields 中提取 filterable 字段
const filterableFields = fields.filter((f) => f.filterable);
// 查找是否有 detail 页面
const hasDetailPage = allPages.some(
(p) => p.type === 'detail' && 'entity' in p && p.entity === entityName,
);
// 可用视图
const enableViews = enableViewsProp ||
(() => {
const page = allPages.find(
(p) => p.type === 'crud' && 'entity' in p && p.entity === entityName,
);
return (page as { enable_views?: string[] })?.enable_views || ['table'];
})();
// 加载 schema
useEffect(() => {
if (!pluginId) return;
const abortController = new AbortController();
async function loadSchema() {
try {
const schema = await getPluginSchema(pluginId!);
if (abortController.signal.aborted) return;
const entities: PluginEntitySchema[] = (schema as { entities?: PluginEntitySchema[] }).entities || [];
setAllEntities(entities);
const entity = entities.find((e) => e.name === entityName);
if (entity) {
setFields(entity.fields);
setDisplayName(entity.display_name || entityName || '');
setEntityDef(entity);
}
const ui = (schema as { ui?: { pages: PluginPageSchema[] } }).ui;
if (ui?.pages) {
setAllPages(ui.pages);
const detailPage = ui.pages.find(
(p) => p.type === 'detail' && 'entity' in p && p.entity === entityName,
);
if (detailPage && 'sections' in detailPage) {
setDetailSections(detailPage.sections);
}
}
} catch {
message.warning('Schema 加载失败,部分功能不可用');
}
}
loadSchema();
return () => abortController.abort();
}, [pluginId, entityName]);
const fetchData = useCallback(
async (p = page, overrides?: { search?: string; sort_by?: string; sort_order?: 'asc' | 'desc' }) => {
if (!pluginId || !entityName) return;
setLoading(true);
try {
const options: PluginDataListOptions = {};
const mergedFilters = { ...filters };
if (filterField && filterValue) {
mergedFilters[filterField] = filterValue;
}
if (Object.keys(mergedFilters).length > 0) {
options.filter = mergedFilters;
}
const effectiveSearch = overrides?.search ?? searchText;
if (effectiveSearch) options.search = effectiveSearch;
const effectiveSortBy = overrides?.sort_by ?? sortBy;
const effectiveSortOrder = overrides?.sort_order ?? sortOrder;
if (effectiveSortBy) {
options.sort_by = effectiveSortBy;
options.sort_order = effectiveSortOrder;
}
const result = await listPluginData(pluginId, entityName, p, 20, options);
setRecords(
result.data.map((r) => ({ ...r.data, _id: r.id, _version: r.version })),
);
setTotal(result.total);
} catch {
message.error('加载数据失败');
}
setLoading(false);
},
[pluginId, entityName, page, filters, searchText, sortBy, sortOrder, filterField, filterValue],
);
useEffect(() => {
fetchData();
}, [fetchData]);
// 数据加载后解析跨插件引用标签
useEffect(() => {
if (!pluginId || !entityName || !records.length || !fields.length) return;
const refFields = fields.filter((f) => f.ref_entity);
if (!refFields.length) return;
const fieldUuids: Record<string, string[]> = {};
for (const f of refFields) {
const uuids = [...new Set(
records.map((r) => r[f.name]).filter(Boolean).map(String),
)];
if (uuids.length) fieldUuids[f.name] = uuids;
}
if (!Object.keys(fieldUuids).length) return;
resolveRefLabels(pluginId, entityName, fieldUuids)
.then((result) => {
setResolvedLabels(result.labels);
setLabelMeta(result.meta as Record<string, { plugin_installed: boolean }>);
})
.catch(() => {});
}, [records, fields, pluginId, entityName]);
// 筛选变化
const handleFilterChange = (fieldName: string, value: string | undefined) => {
const newFilters = { ...filters };
if (value) {
newFilters[fieldName] = value;
} else {
delete newFilters[fieldName];
}
setFilters(newFilters);
setPage(1);
// 直接触发重新查询
fetchData(1);
};
const handleSubmit = async (values: Record<string, unknown>) => {
if (!pluginId || !entityName) return;
const { _id, _version, ...data } = values as Record<string, unknown> & {
_id?: string;
_version?: number;
};
try {
if (editRecord) {
await updatePluginData(
pluginId,
entityName,
editRecord._id as string,
data,
editRecord._version as number,
);
message.success('更新成功');
} else {
await createPluginData(pluginId, entityName, data);
message.success('创建成功');
}
setModalOpen(false);
setEditRecord(null);
fetchData();
} catch {
message.error('操作失败');
}
};
const handleDelete = async (record: Record<string, unknown>) => {
if (!pluginId || !entityName) return;
try {
await deletePluginData(pluginId, entityName, record._id as string);
message.success('删除成功');
fetchData();
} catch {
message.error('删除失败');
}
};
const handleBatchDelete = async () => {
if (!pluginId || !entityName || selectedRowKeys.length === 0) return;
try {
await batchPluginData(pluginId, entityName, {
action: 'delete',
ids: selectedRowKeys,
});
message.success(`已删除 ${selectedRowKeys.length} 条记录`);
setSelectedRowKeys([]);
fetchData();
} catch {
message.error('批量删除失败');
}
};
// 动态生成列
const columns = [
...fields.slice(0, 5).map((f) => ({
title: f.display_name || f.name,
dataIndex: f.name,
key: f.name,
ellipsis: true,
sorter: f.sortable ? true : undefined,
render: (val: unknown) => {
if (typeof val === 'boolean') return val ? <Tag color="green"></Tag> : <Tag></Tag>;
// 引用字段 → 显示解析后的标签
if (f.ref_entity) {
const uuid = String(val ?? '');
if (!uuid || uuid === '-') return '-';
const label = resolvedLabels[f.name]?.[uuid];
const installed = labelMeta[f.name]?.plugin_installed !== false;
if (!installed) return <Tag color="default">{f.ref_fallback_label || '外部引用'}</Tag>;
if (label === null) return <Tag color="warning"></Tag>;
if (label) return <Tag color="blue">{label}</Tag>;
}
return String(val ?? '-');
},
})),
{
title: '操作',
key: 'action',
width: hasDetailPage ? 200 : 150,
render: (_: unknown, record: Record<string, unknown>) => (
<Space size="small">
{hasDetailPage && (
<Button
size="small"
icon={<EyeOutlined />}
onClick={() => {
setDetailRecord(record);
setDetailOpen(true);
}}
>
</Button>
)}
<Button
size="small"
icon={<EditOutlined />}
onClick={() => {
setEditRecord(record);
form.setFieldsValue(record);
setFormValues(record);
setModalOpen(true);
}}
>
</Button>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(record)}>
<Button size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
// 动态生成表单字段
const renderFormField = (field: PluginFieldSchema) => {
const widget = field.ui_widget || field.field_type;
switch (widget) {
case 'number':
case 'integer':
case 'float':
case 'decimal':
return <InputNumber style={{ width: '100%' }} />;
case 'boolean':
return <Switch />;
case 'date':
case 'datetime':
return <DatePicker showTime={widget === 'datetime'} style={{ width: '100%' }} />;
case 'select':
return (
<Select>
{(field.options || []).map((opt) => (
<Select.Option key={String(opt.value)} value={opt.value}>
{opt.label}
</Select.Option>
))}
</Select>
);
case 'textarea':
return <TextArea rows={3} />;
case 'entity_select':
return (
<EntitySelect
pluginId={pluginId}
entity={field.ref_entity!}
labelField={field.ref_label_field || 'name'}
searchFields={field.ref_search_fields}
refPlugin={field.ref_plugin}
fallbackLabel={field.ref_fallback_label}
value={formValues[field.name] as string | undefined}
onChange={(v) => form.setFieldValue(field.name, v)}
cascadeFrom={field.cascade_from}
cascadeFilter={field.cascade_filter}
cascadeValue={
field.cascade_from
? (formValues[field.cascade_from] as string | undefined)
: undefined
}
placeholder={field.display_name}
/>
);
default:
return <Input />;
}
};
// Timeline 视图渲染
const renderTimeline = () => {
const dateField = fields.find((f) => f.field_type === 'DateTime' || f.field_type === 'date');
const titleField = fields.find((f) => f.searchable)?.name || fields[1]?.name;
const contentField = fields.find((f) => f.ui_widget === 'textarea')?.name;
return (
<Timeline
items={records.map((record) => ({
children: (
<div>
{titleField && (
<p>
<strong>{String(record[titleField] ?? '-')}</strong>
</p>
)}
{contentField && <p>{String(record[contentField] ?? '-')}</p>}
{dateField && (
<p style={{ color: '#999', fontSize: 12 }}>
{String(record[dateField.name] ?? '-')}
</p>
)}
</div>
),
}))}
/>
);
};
// 详情 Drawer 渲染
const renderDetailDrawer = () => {
if (!detailRecord) return null;
return (
<Drawer
title={displayName + ' 详情'}
open={detailOpen}
onClose={() => {
setDetailOpen(false);
setDetailRecord(null);
}}
width={640}
>
{detailSections.length > 0 ? (
detailSections.map((section, idx) => {
if (section.type === 'fields') {
return (
<div key={idx} style={{ marginBottom: 24 }}>
<h4>{section.label}</h4>
<Descriptions column={2} bordered size="small">
{section.fields.map((fieldName) => {
const fieldDef = fields.find((f) => f.name === fieldName);
const val = detailRecord[fieldName];
return (
<Descriptions.Item
key={fieldName}
label={fieldDef?.display_name || fieldName}
>
{typeof val === 'boolean' ? (
val ? (
<Tag color="green"></Tag>
) : (
<Tag></Tag>
)
) : (
String(val ?? '-')
)}
</Descriptions.Item>
);
})}
</Descriptions>
</div>
);
}
if (section.type === 'crud') {
const secEntity = allEntities.find((e) => e.name === section.entity);
return (
<div key={idx} style={{ marginBottom: 24 }}>
<h4>{section.label}</h4>
{secEntity && (
<PluginCRUDPage
pluginIdOverride={pluginId}
entityOverride={section.entity}
filterField={section.filter_field}
filterValue={String(detailRecord._id ?? '')}
enableViews={section.enable_views}
compact
/>
)}
</div>
);
}
return null;
})
) : (
// 没有 sections 配置时,默认展示所有字段
<Descriptions column={2} bordered size="small">
{fields.map((field) => {
const val = detailRecord[field.name];
return (
<Descriptions.Item key={field.name} label={field.display_name || field.name}>
{typeof val === 'boolean' ? (
val ? (
<Tag color="green"></Tag>
) : (
<Tag></Tag>
)
) : (
String(val ?? '-')
)}
</Descriptions.Item>
);
})}
</Descriptions>
)}
</Drawer>
);
};
return (
<div style={compact ? { padding: 0 } : { padding: 24 }}>
{!compact && (
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<h2 style={{ margin: 0 }}>{displayName}</h2>
<Space>
{enableViews.length > 1 && (
<Segmented
options={enableViews.map((v) => ({
label: v === 'table' ? '表格' : v === 'timeline' ? '时间线' : v,
value: v,
}))}
value={viewMode}
onChange={(val) => setViewMode(val as string)}
/>
)}
<Button
icon={<PlusOutlined />}
type="primary"
onClick={() => {
setEditRecord(null);
form.resetFields();
setFormValues({});
setModalOpen(true);
}}
>
</Button>
<Button icon={<ReloadOutlined />} onClick={() => fetchData()}>
</Button>
{entityDef?.exportable && (
<Dropdown
menu={{
items: [
{ key: 'json', label: 'JSON' },
{ key: 'csv', label: 'CSV' },
{ key: 'xlsx', label: 'Excel (.xlsx)' },
],
onClick: async ({ key }) => {
setExporting(true);
try {
const ts = Date.now();
if (key === 'json') {
const rows = await exportPluginData(pluginId, entityName, {
sort_by: sortBy,
sort_order: sortOrder,
});
const blob = new Blob([JSON.stringify(rows, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${entityName}_export_${ts}.json`;
a.click();
URL.revokeObjectURL(url);
message.success(`导出 ${rows.length} 条记录`);
} else {
const blob = await exportPluginDataAsBlob(
pluginId, entityName, key as 'csv' | 'xlsx',
{ sort_by: sortBy, sort_order: sortOrder },
);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${entityName}_export_${ts}.${key === 'csv' ? 'csv' : 'xlsx'}`;
a.click();
URL.revokeObjectURL(url);
message.success('导出成功');
}
} catch {
message.error('导出失败');
}
setExporting(false);
},
}}
>
<Button icon={<DownloadOutlined />} loading={exporting}>
</Button>
</Dropdown>
)}
{entityDef?.importable && (
<Button
icon={<UploadOutlined />}
onClick={() => {
setImportResult(null);
setImportModalOpen(true);
}}
>
</Button>
)}
</Space>
</div>
)}
{/* 搜索和筛选栏 */}
{!compact && (
<Space style={{ marginBottom: 16 }} wrap>
{fields.some((f) => f.searchable) && (
<Search
placeholder="搜索..."
allowClear
style={{ width: 240 }}
onSearch={(value) => {
setSearchText(value);
setPage(1);
fetchData(1, { search: value });
}}
/>
)}
{filterableFields.map((field) => (
<Select
key={field.name}
placeholder={field.display_name || field.name}
allowClear
style={{ width: 150 }}
options={field.options || []}
onChange={(value) => handleFilterChange(field.name, value)}
/>
))}
</Space>
)}
{/* 批量操作栏 */}
{selectedRowKeys.length > 0 && !compact && (
<div
style={{
marginBottom: 16,
padding: '8px 16px',
background: 'var(--colorBgContainer, #fff)',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
gap: 12,
}}
>
<span> <strong>{selectedRowKeys.length}</strong> </span>
<Popconfirm
title={`确定删除选中的 ${selectedRowKeys.length} 条记录?`}
onConfirm={handleBatchDelete}
>
<Button danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
<Button onClick={() => setSelectedRowKeys([])}>
</Button>
</div>
)}
{viewMode === 'table' || enableViews.length <= 1 ? (
<Table
columns={columns}
dataSource={records}
rowKey="_id"
loading={loading}
size={compact ? 'small' : undefined}
rowSelection={
compact
? undefined
: {
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as string[]),
}
}
onChange={(_pagination, _filters, sorter) => {
if (!Array.isArray(sorter) && sorter.field) {
const newSortBy = String(sorter.field);
const newSortOrder = sorter.order === 'ascend' ? 'asc' as const : 'desc' as const;
setSortBy(newSortBy);
setSortOrder(newSortOrder);
setPage(1);
fetchData(1, { sort_by: newSortBy, sort_order: newSortOrder });
} else if (!sorter || (Array.isArray(sorter) && sorter.length === 0)) {
setSortBy(undefined);
setSortOrder('desc');
setPage(1);
fetchData(1, { sort_by: undefined, sort_order: undefined });
}
}}
pagination={
compact
? { pageSize: 5, showTotal: (t) => `${t}` }
: {
current: page,
total,
pageSize: 20,
onChange: (p) => setPage(p),
showTotal: (t) => `${t}`,
}
}
/>
) : viewMode === 'timeline' ? (
renderTimeline()
) : null}
{/* 新增/编辑弹窗 */}
<Modal
title={editRecord ? '编辑' : '新增'}
open={modalOpen}
onCancel={() => {
setModalOpen(false);
setEditRecord(null);
setFormValues({});
}}
onOk={() => form.submit()}
destroyOnClose
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
onValuesChange={(_, allValues) => setFormValues(allValues)}
>
{fields.map((field) => {
// visible_when 条件显示
const visible = evaluateVisibleWhen(field.visible_when, formValues);
if (!visible) return null;
return (
<Form.Item
key={field.name}
name={field.name}
label={field.display_name || field.name}
rules={
field.required
? [{ required: true, message: `请输入${field.display_name || field.name}` }]
: []
}
valuePropName={field.field_type === 'boolean' ? 'checked' : 'value'}
>
{renderFormField(field)}
</Form.Item>
);
})}
</Form>
</Modal>
{/* 详情 Drawer */}
{renderDetailDrawer()}
{/* 导入弹窗 */}
<Modal
title="导入数据"
open={importModalOpen}
onCancel={() => {
setImportModalOpen(false);
setImportResult(null);
}}
footer={importResult ? (
<Button onClick={() => { setImportModalOpen(false); setImportResult(null); }}>
</Button>
) : null}
destroyOnClose
>
{importResult ? (
<div>
<Alert
type={importResult.error_count > 0 ? 'warning' : 'success'}
message={`导入完成:成功 ${importResult.success_count} 条,失败 ${importResult.error_count}`}
style={{ marginBottom: 16 }}
/>
{importResult.errors.length > 0 && (
<div>
<h4></h4>
{importResult.errors.map((err, i) => (
<Alert
key={i}
type="error"
message={`${err.row + 1}`}
description={err.errors.join('; ')}
style={{ marginBottom: 8 }}
/>
))}
</div>
)}
</div>
) : (
<Upload.Dragger
accept=".json"
maxCount={1}
beforeUpload={(file) => {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const text = e.target?.result as string;
const rows = JSON.parse(text);
if (!Array.isArray(rows)) {
message.error('文件格式错误:需要 JSON 数组');
return;
}
setImporting(true);
const result = await importPluginData(pluginId, entityName, rows);
setImportResult(result);
if (result.success_count > 0) fetchData();
} catch {
message.error('文件解析失败,请确认格式为 JSON 数组');
}
setImporting(false);
};
reader.readAsText(file);
return false;
}}
showUploadList={false}
disabled={importing}
>
<p style={{ fontSize: 16, padding: '24px 0' }}>
{importing ? '导入中...' : '点击或拖拽 JSON 文件到此处'}
</p>
<p style={{ color: '#999' }}> JSON 1000 </p>
</Upload.Dragger>
)}
</Modal>
</div>
);
}