refactor(web): 前端工程化 — 组件拆分 + 名称缓存统一
- useHealthStore 新增 batchResolvePatientNames/batchResolveDoctorNames 批量解析方法(去重 → 过滤已缓存 → 5 并发批次加载) - PointsOrderList 移除局部 nameCache,改用 useHealthStore 全局缓存 - PluginCRUDPage (871L) 拆分为 usePluginData + DetailDrawer + ImportModal + PluginCRUDPageInner,原文件改为 re-export - PluginGraphPage (765L) 拆分为 useGraphData + useGraphCanvas hooks - StatisticsDashboard (580L) 拆分为 useStatsData + HealthDataCenter
This commit is contained in:
102
apps/web/src/pages/PluginCRUDPage/DetailDrawer.tsx
Normal file
102
apps/web/src/pages/PluginCRUDPage/DetailDrawer.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Drawer, Descriptions, Tag } from 'antd';
|
||||
import type { PluginFieldSchema, PluginEntitySchema, PluginSectionSchema } from '../../api/plugins';
|
||||
import PluginCRUDPageInner from './PluginCRUDPageInner';
|
||||
|
||||
interface DetailDrawerProps {
|
||||
open: boolean;
|
||||
record: Record<string, unknown> | null;
|
||||
displayName: string;
|
||||
fields: PluginFieldSchema[];
|
||||
sections: PluginSectionSchema[];
|
||||
allEntities: PluginEntitySchema[];
|
||||
pluginId: string;
|
||||
entityName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function DetailDrawer({
|
||||
open,
|
||||
record,
|
||||
displayName,
|
||||
fields,
|
||||
sections,
|
||||
allEntities,
|
||||
pluginId,
|
||||
entityName,
|
||||
onClose,
|
||||
}: DetailDrawerProps) {
|
||||
if (!record) return null;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={displayName + ' 详情'}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width={640}
|
||||
>
|
||||
{sections.length > 0 ? (
|
||||
sections.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 = record[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 && (
|
||||
<PluginCRUDPageInner
|
||||
pluginIdOverride={pluginId}
|
||||
entityOverride={section.entity}
|
||||
filterField={section.filter_field}
|
||||
filterValue={String(record._id ?? '')}
|
||||
enableViews={section.enable_views}
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
) : (
|
||||
<Descriptions column={2} bordered size="small">
|
||||
{fields.map((field) => {
|
||||
const val = record[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>
|
||||
);
|
||||
}
|
||||
91
apps/web/src/pages/PluginCRUDPage/ImportModal.tsx
Normal file
91
apps/web/src/pages/PluginCRUDPage/ImportModal.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useState } from 'react';
|
||||
import { Modal, Upload, Alert, Button, message } from 'antd';
|
||||
import { importPluginData, type ImportResult } from '../../api/pluginData';
|
||||
|
||||
interface ImportModalProps {
|
||||
open: boolean;
|
||||
pluginId: string;
|
||||
entityName: string;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export default function ImportModal({ open, pluginId, entityName, onClose, onSuccess }: ImportModalProps) {
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setImportResult(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="导入数据"
|
||||
open={open}
|
||||
onCancel={handleClose}
|
||||
footer={importResult ? (
|
||||
<Button onClick={handleClose}>关闭</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) onSuccess();
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
488
apps/web/src/pages/PluginCRUDPage/PluginCRUDPageInner.tsx
Normal file
488
apps/web/src/pages/PluginCRUDPage/PluginCRUDPageInner.tsx
Normal file
@@ -0,0 +1,488 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
DatePicker,
|
||||
Switch,
|
||||
Select,
|
||||
Tag,
|
||||
message,
|
||||
Popconfirm,
|
||||
Segmented,
|
||||
Timeline,
|
||||
Dropdown,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
ReloadOutlined,
|
||||
EyeOutlined,
|
||||
DownloadOutlined,
|
||||
UploadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
createPluginData,
|
||||
updatePluginData,
|
||||
deletePluginData,
|
||||
batchPluginData,
|
||||
exportPluginData,
|
||||
exportPluginDataAsBlob,
|
||||
} from '../../api/pluginData';
|
||||
import EntitySelect from '../../components/EntitySelect';
|
||||
import type { PluginFieldSchema } from '../../api/plugins';
|
||||
import { evaluateVisibleWhen } from '../../utils/exprEvaluator';
|
||||
import { usePluginData } from './usePluginData';
|
||||
import DetailDrawer from './DetailDrawer';
|
||||
import ImportModal from './ImportModal';
|
||||
|
||||
const { Search } = Input;
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface PluginCRUDPageProps {
|
||||
pluginIdOverride?: string;
|
||||
entityOverride?: string;
|
||||
filterField?: string;
|
||||
filterValue?: string;
|
||||
enableViews?: string[];
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export default function PluginCRUDPageInner({
|
||||
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, total, page, loading, fields, displayName,
|
||||
sortBy, sortOrder,
|
||||
resolvedLabels, labelMeta,
|
||||
entityDef, allEntities, detailSections,
|
||||
hasDetailPage, filterableFields,
|
||||
setPage, setSortBy, setSortOrder,
|
||||
fetchData,
|
||||
} = usePluginData(pluginId, entityName, filterField, filterValue);
|
||||
|
||||
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>>({});
|
||||
|
||||
const [viewMode, setViewMode] = useState<string>('table');
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
||||
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailRecord, setDetailRecord] = useState<Record<string, unknown> | null>(null);
|
||||
|
||||
const [importModalOpen, setImportModalOpen] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const enableViews = enableViewsProp ||
|
||||
(() => {
|
||||
return ['table'];
|
||||
})();
|
||||
|
||||
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 = useMemo(() => [
|
||||
...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>
|
||||
),
|
||||
},
|
||||
], [fields, resolvedLabels, labelMeta, hasDetailPage, handleDelete]);
|
||||
|
||||
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 />;
|
||||
}
|
||||
};
|
||||
|
||||
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={() => setImportModalOpen(true)}>
|
||||
导入
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!compact && (
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
{fields.some((f) => f.searchable) && (
|
||||
<Search
|
||||
placeholder="搜索..."
|
||||
allowClear
|
||||
style={{ width: 240 }}
|
||||
onSearch={(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) => {
|
||||
const newFilters: Record<string, string> = {};
|
||||
if (value) newFilters[field.name] = value;
|
||||
setPage(1);
|
||||
fetchData(1);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</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' ? (
|
||||
<Timeline
|
||||
items={records.map((record) => {
|
||||
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 {
|
||||
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>
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
) : 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) => {
|
||||
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}` }] : []),
|
||||
...(field.validation?.pattern ? [{ pattern: new RegExp(field.validation.pattern), message: field.validation.message || '格式不正确' }] : []),
|
||||
]}
|
||||
valuePropName={field.field_type === 'boolean' ? 'checked' : 'value'}
|
||||
>
|
||||
{renderFormField(field)}
|
||||
</Form.Item>
|
||||
);
|
||||
})}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<DetailDrawer
|
||||
open={detailOpen}
|
||||
record={detailRecord}
|
||||
displayName={displayName}
|
||||
fields={fields}
|
||||
sections={detailSections}
|
||||
allEntities={allEntities}
|
||||
pluginId={pluginId}
|
||||
entityName={entityName}
|
||||
onClose={() => { setDetailOpen(false); setDetailRecord(null); }}
|
||||
/>
|
||||
|
||||
<ImportModal
|
||||
open={importModalOpen}
|
||||
pluginId={pluginId}
|
||||
entityName={entityName}
|
||||
onClose={() => setImportModalOpen(false)}
|
||||
onSuccess={() => fetchData()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
206
apps/web/src/pages/PluginCRUDPage/usePluginData.ts
Normal file
206
apps/web/src/pages/PluginCRUDPage/usePluginData.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { message } from 'antd';
|
||||
import {
|
||||
listPluginData,
|
||||
resolveRefLabels,
|
||||
type PluginDataListOptions,
|
||||
} from '../../api/pluginData';
|
||||
import {
|
||||
getPluginSchema,
|
||||
type PluginFieldSchema,
|
||||
type PluginEntitySchema,
|
||||
type PluginPageSchema,
|
||||
type PluginSectionSchema,
|
||||
} from '../../api/plugins';
|
||||
|
||||
export interface PluginDataState {
|
||||
records: Record<string, unknown>[];
|
||||
total: number;
|
||||
page: number;
|
||||
loading: boolean;
|
||||
fields: PluginFieldSchema[];
|
||||
displayName: string;
|
||||
filters: Record<string, string>;
|
||||
searchText: string;
|
||||
sortBy: string | undefined;
|
||||
sortOrder: 'asc' | 'desc';
|
||||
resolvedLabels: Record<string, Record<string, string | null>>;
|
||||
labelMeta: Record<string, { plugin_installed: boolean }>;
|
||||
entityDef: PluginEntitySchema | null;
|
||||
allEntities: PluginEntitySchema[];
|
||||
allPages: PluginPageSchema[];
|
||||
detailSections: PluginSectionSchema[];
|
||||
hasDetailPage: boolean;
|
||||
filterableFields: PluginFieldSchema[];
|
||||
}
|
||||
|
||||
export interface PluginDataActions {
|
||||
setRecords: React.Dispatch<React.SetStateAction<Record<string, unknown>[]>>;
|
||||
setPage: React.Dispatch<React.SetStateAction<number>>;
|
||||
setFilters: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
||||
setSearchText: React.Dispatch<React.SetStateAction<string>>;
|
||||
setSortBy: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
setSortOrder: React.Dispatch<React.SetStateAction<'asc' | 'desc'>>;
|
||||
fetchData: (p?: number, overrides?: {
|
||||
search?: string;
|
||||
sort_by?: string;
|
||||
sort_order?: 'asc' | 'desc';
|
||||
}) => Promise<void>;
|
||||
handleFilterChange: (fieldName: string, value: string | undefined) => void;
|
||||
}
|
||||
|
||||
export type PluginDataHook = PluginDataState & PluginDataActions;
|
||||
|
||||
export function usePluginData(
|
||||
pluginId: string,
|
||||
entityName: string,
|
||||
filterField?: string,
|
||||
filterValue?: string,
|
||||
): PluginDataHook {
|
||||
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 [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [sortBy, setSortBy] = useState<string | undefined>();
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
const [resolvedLabels, setResolvedLabels] = useState<Record<string, Record<string, string | null>>>({});
|
||||
const [labelMeta, setLabelMeta] = useState<Record<string, { plugin_installed: boolean }>>({});
|
||||
|
||||
const [entityDef, setEntityDef] = useState<PluginEntitySchema | null>(null);
|
||||
const [allEntities, setAllEntities] = useState<PluginEntitySchema[]>([]);
|
||||
const [allPages, setAllPages] = useState<PluginPageSchema[]>([]);
|
||||
const [detailSections, setDetailSections] = useState<PluginSectionSchema[]>([]);
|
||||
|
||||
const filterableFields = fields.filter((f) => f.filterable);
|
||||
const hasDetailPage = allPages.some(
|
||||
(p) => p.type === 'detail' && 'entity' in p && p.entity === entityName,
|
||||
);
|
||||
|
||||
// 加载 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);
|
||||
};
|
||||
|
||||
return {
|
||||
records, total, page, loading, fields, displayName,
|
||||
filters, searchText, sortBy, sortOrder,
|
||||
resolvedLabels, labelMeta,
|
||||
entityDef, allEntities, allPages, detailSections,
|
||||
hasDetailPage, filterableFields,
|
||||
setRecords, setPage, setFilters, setSearchText, setSortBy, setSortOrder,
|
||||
fetchData, handleFilterChange,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user