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:
@@ -1,871 +1 @@
|
|||||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
export { default } from './PluginCRUDPage/PluginCRUDPageInner';
|
||||||
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('批量删除失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 动态生成列(memo 化避免输入搜索时重建)
|
|
||||||
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 />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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}` }]
|
|
||||||
: []),
|
|
||||||
...(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>
|
|
||||||
|
|
||||||
{/* 详情 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
Col,
|
Col,
|
||||||
Tag,
|
Tag,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
message,
|
|
||||||
theme,
|
theme,
|
||||||
Typography,
|
Typography,
|
||||||
Divider,
|
Divider,
|
||||||
@@ -26,56 +25,24 @@ import {
|
|||||||
InfoCircleOutlined,
|
InfoCircleOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { listPluginData } from '../api/pluginData';
|
import type { GraphNode } from './graph/graphTypes';
|
||||||
import {
|
import { getNodeDegree } from './graph/graphRenderer';
|
||||||
getPluginSchema,
|
import { getRelColor, getEdgeTypeLabel } from './graph/graphRenderer';
|
||||||
type PluginFieldSchema,
|
import { useGraphData } from './PluginGraphPage/useGraphData';
|
||||||
type PluginSchemaResponse,
|
import { useGraphCanvas } from './PluginGraphPage/useGraphCanvas';
|
||||||
} from '../api/plugins';
|
|
||||||
|
|
||||||
import type { GraphNode, GraphEdge, GraphConfig, NodePosition, HoverState } from './graph/graphTypes';
|
|
||||||
import { computeCircularLayout } from './graph/graphLayout';
|
|
||||||
import {
|
|
||||||
getEdgeColor,
|
|
||||||
NODE_HOVER_SCALE,
|
|
||||||
getRelColor,
|
|
||||||
getEdgeTypeLabel,
|
|
||||||
getNodeDegree,
|
|
||||||
degreeToRadius,
|
|
||||||
drawCurvedEdge,
|
|
||||||
drawNode,
|
|
||||||
drawEdgeLabel,
|
|
||||||
drawNodeLabel,
|
|
||||||
} from './graph/graphRenderer';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
/**
|
|
||||||
* 插件关系图谱页面 — 通过路由参数自加载 schema
|
|
||||||
* 路由: /plugins/:pluginId/graph/:entityName
|
|
||||||
*/
|
|
||||||
export function PluginGraphPage() {
|
export function PluginGraphPage() {
|
||||||
const { pluginId, entityName } = useParams<{ pluginId: string; entityName: string }>();
|
const { pluginId, entityName } = useParams<{ pluginId: string; entityName: string }>();
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const nodePositionsRef = useRef<Map<string, NodePosition>>(new Map());
|
|
||||||
const visibleNodesRef = useRef<GraphNode[]>([]);
|
|
||||||
const visibleEdgesRef = useRef<GraphEdge[]>([]);
|
|
||||||
|
|
||||||
const [customers, setCustomers] = useState<GraphNode[]>([]);
|
const { customers, relationships, loading, fields, relTypes } = useGraphData(pluginId, entityName);
|
||||||
const [relationships, setRelationships] = useState<GraphEdge[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [selectedCenter, setSelectedCenter] = useState<string | null>(null);
|
const [selectedCenter, setSelectedCenter] = useState<string | null>(null);
|
||||||
const [relTypes, setRelTypes] = useState<string[]>([]);
|
|
||||||
const [relFilter, setRelFilter] = useState<string | undefined>();
|
const [relFilter, setRelFilter] = useState<string | undefined>();
|
||||||
const [graphConfig, setGraphConfig] = useState<GraphConfig | null>(null);
|
|
||||||
const [fields, setFields] = useState<PluginFieldSchema[]>([]);
|
|
||||||
const [hoverState, setHoverState] = useState<HoverState>({ nodeId: null, x: 0, y: 0 });
|
|
||||||
const [canvasSize, setCanvasSize] = useState({ width: 800, height: 600 });
|
const [canvasSize, setCanvasSize] = useState({ width: 800, height: 600 });
|
||||||
|
|
||||||
// ── Computed stats ──
|
|
||||||
|
|
||||||
const filteredRels = relFilter
|
const filteredRels = relFilter
|
||||||
? relationships.filter((r) => r.label === relFilter)
|
? relationships.filter((r) => r.label === relFilter)
|
||||||
: relationships;
|
: relationships;
|
||||||
@@ -87,10 +54,7 @@ export function PluginGraphPage() {
|
|||||||
const visibleNodeIds = new Set<string>();
|
const visibleNodeIds = new Set<string>();
|
||||||
if (selectedCenter) {
|
if (selectedCenter) {
|
||||||
visibleNodeIds.add(selectedCenter);
|
visibleNodeIds.add(selectedCenter);
|
||||||
for (const e of visibleEdges) {
|
for (const e of visibleEdges) { visibleNodeIds.add(e.source); visibleNodeIds.add(e.target); }
|
||||||
visibleNodeIds.add(e.source);
|
|
||||||
visibleNodeIds.add(e.target);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const visibleNodes = selectedCenter
|
const visibleNodes = selectedCenter
|
||||||
? customers.filter((n) => visibleNodeIds.has(n.id))
|
? customers.filter((n) => visibleNodeIds.has(n.id))
|
||||||
@@ -99,375 +63,40 @@ export function PluginGraphPage() {
|
|||||||
const centerNode = customers.find((c) => c.id === selectedCenter);
|
const centerNode = customers.find((c) => c.id === selectedCenter);
|
||||||
const centerDegree = selectedCenter ? getNodeDegree(selectedCenter, visibleEdges) : 0;
|
const centerDegree = selectedCenter ? getNodeDegree(selectedCenter, visibleEdges) : 0;
|
||||||
|
|
||||||
// ── Schema loading ──
|
const {
|
||||||
|
canvasRef, containerRef, hoverState,
|
||||||
useEffect(() => {
|
handleCanvasMouseMove, handleCanvasMouseLeave, handleCanvasClick,
|
||||||
if (!pluginId || !entityName) return;
|
} = useGraphCanvas({
|
||||||
const abortController = new AbortController();
|
token,
|
||||||
|
canvasSize,
|
||||||
async function loadSchema() {
|
selectedCenter,
|
||||||
try {
|
visibleNodes,
|
||||||
const schema: PluginSchemaResponse = await getPluginSchema(pluginId!);
|
visibleEdges,
|
||||||
if (abortController.signal.aborted) return;
|
});
|
||||||
|
|
||||||
const pages = schema.ui?.pages || [];
|
|
||||||
const graphPage = pages.find(
|
|
||||||
(p): p is typeof p & GraphConfig & { type: 'graph' } =>
|
|
||||||
p.type === 'graph' && p.entity === entityName,
|
|
||||||
);
|
|
||||||
if (graphPage) {
|
|
||||||
setGraphConfig({
|
|
||||||
entity: graphPage.entity,
|
|
||||||
relationshipEntity: graphPage.relationship_entity,
|
|
||||||
sourceField: graphPage.source_field,
|
|
||||||
targetField: graphPage.target_field,
|
|
||||||
edgeLabelField: graphPage.edge_label_field,
|
|
||||||
nodeLabelField: graphPage.node_label_field,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const entity = schema.entities?.find((e) => e.name === entityName);
|
|
||||||
if (entity) setFields(entity.fields);
|
|
||||||
} catch {
|
|
||||||
message.warning('Schema 加载失败,部分功能不可用');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadSchema();
|
|
||||||
return () => abortController.abort();
|
|
||||||
}, [pluginId, entityName]);
|
|
||||||
|
|
||||||
// ── Data loading ──
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!pluginId || !graphConfig) return;
|
|
||||||
const abortController = new AbortController();
|
|
||||||
const gc = graphConfig;
|
|
||||||
const labelField = fields.find((f) => f.name === gc.nodeLabelField)?.name || fields[1]?.name || 'name';
|
|
||||||
|
|
||||||
async function loadData() {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
let allCustomers: GraphNode[] = [];
|
|
||||||
let page = 1;
|
|
||||||
let hasMore = true;
|
|
||||||
while (hasMore) {
|
|
||||||
if (abortController.signal.aborted) return;
|
|
||||||
const result = await listPluginData(pluginId!, gc.entity, page, 100);
|
|
||||||
allCustomers = [
|
|
||||||
...allCustomers,
|
|
||||||
...result.data.map((r) => ({
|
|
||||||
id: r.id,
|
|
||||||
label: String(r.data[labelField] || '未命名'),
|
|
||||||
data: r.data,
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
hasMore = result.data.length === 100 && allCustomers.length < result.total;
|
|
||||||
page++;
|
|
||||||
}
|
|
||||||
if (abortController.signal.aborted) return;
|
|
||||||
setCustomers(allCustomers);
|
|
||||||
|
|
||||||
let allRels: GraphEdge[] = [];
|
|
||||||
page = 1;
|
|
||||||
hasMore = true;
|
|
||||||
const types = new Set<string>();
|
|
||||||
while (hasMore) {
|
|
||||||
if (abortController.signal.aborted) return;
|
|
||||||
const result = await listPluginData(pluginId!, gc.relationshipEntity, page, 100);
|
|
||||||
for (const r of result.data) {
|
|
||||||
const relType = String(r.data[gc.edgeLabelField] || '');
|
|
||||||
types.add(relType);
|
|
||||||
allRels.push({
|
|
||||||
source: String(r.data[gc.sourceField] || ''),
|
|
||||||
target: String(r.data[gc.targetField] || ''),
|
|
||||||
label: relType,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
hasMore = result.data.length === 100 && allRels.length < result.total;
|
|
||||||
page++;
|
|
||||||
}
|
|
||||||
if (abortController.signal.aborted) return;
|
|
||||||
setRelationships(allRels);
|
|
||||||
setRelTypes(Array.from(types));
|
|
||||||
} catch {
|
|
||||||
message.warning('数据加载失败');
|
|
||||||
}
|
|
||||||
if (!abortController.signal.aborted) setLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadData();
|
|
||||||
return () => abortController.abort();
|
|
||||||
}, [pluginId, graphConfig, fields]);
|
|
||||||
|
|
||||||
// ── Canvas resize observer ──
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const observer = new ResizeObserver((entries) => {
|
const observer = new ResizeObserver((entries) => {
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const { width } = entry.contentRect;
|
const { width } = entry.contentRect;
|
||||||
if (width > 0) {
|
if (width > 0) setCanvasSize({ width, height: Math.max(500, Math.min(700, width * 0.65)) });
|
||||||
setCanvasSize({ width, height: Math.max(500, Math.min(700, width * 0.65)) });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
observer.observe(container);
|
observer.observe(container);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── Update refs for animation loop ──
|
const onCanvasClick = useCallback(
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
visibleNodesRef.current = visibleNodes;
|
|
||||||
visibleEdgesRef.current = visibleEdges;
|
|
||||||
}, [visibleNodes, visibleEdges]);
|
|
||||||
|
|
||||||
// ── Main canvas drawing with requestAnimationFrame ──
|
|
||||||
|
|
||||||
const drawGraph = useCallback(() => {
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
if (!canvas) return;
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
const nodes = visibleNodesRef.current;
|
|
||||||
const edges = visibleEdgesRef.current;
|
|
||||||
|
|
||||||
const width = canvasSize.width;
|
|
||||||
const height = canvasSize.height;
|
|
||||||
|
|
||||||
// High DPI support
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
canvas.width = width * dpr;
|
|
||||||
canvas.height = height * dpr;
|
|
||||||
canvas.style.width = `${width}px`;
|
|
||||||
canvas.style.height = `${height}px`;
|
|
||||||
ctx.scale(dpr, dpr);
|
|
||||||
|
|
||||||
// Theme-aware colors
|
|
||||||
const textColor = token.colorText;
|
|
||||||
const bgColor = token.colorBgContainer;
|
|
||||||
|
|
||||||
// Clear canvas
|
|
||||||
ctx.clearRect(0, 0, width, height);
|
|
||||||
|
|
||||||
// Background
|
|
||||||
ctx.fillStyle = bgColor;
|
|
||||||
ctx.fillRect(0, 0, width, height);
|
|
||||||
|
|
||||||
if (nodes.length === 0) return;
|
|
||||||
|
|
||||||
// Compute layout
|
|
||||||
const centerX = width / 2;
|
|
||||||
const centerY = height / 2;
|
|
||||||
const radius = Math.min(width, height) * 0.36;
|
|
||||||
|
|
||||||
const positions = computeCircularLayout(nodes, centerX, centerY, radius);
|
|
||||||
nodePositionsRef.current = positions;
|
|
||||||
|
|
||||||
// Precompute degrees for node sizing
|
|
||||||
const degreeMap = new Map<string, number>();
|
|
||||||
for (const node of nodes) {
|
|
||||||
degreeMap.set(node.id, getNodeDegree(node.id, edges));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Draw edges first (behind nodes) ──
|
|
||||||
|
|
||||||
for (const edge of edges) {
|
|
||||||
const from = positions.get(edge.source);
|
|
||||||
const to = positions.get(edge.target);
|
|
||||||
if (!from || !to) continue;
|
|
||||||
|
|
||||||
const colors = getRelColor(edge.label);
|
|
||||||
const isHighlighted =
|
|
||||||
hoverState.nodeId === edge.source || hoverState.nodeId === edge.target;
|
|
||||||
const alpha = hoverState.nodeId ? (isHighlighted ? 1 : 0.15) : 0.7;
|
|
||||||
const lw = isHighlighted ? 2.5 : 1.5;
|
|
||||||
|
|
||||||
const labelPos = drawCurvedEdge(
|
|
||||||
ctx, from.x, from.y, to.x, to.y,
|
|
||||||
colors.base, lw, isHighlighted, alpha,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Edge label
|
|
||||||
if (edge.label && labelPos) {
|
|
||||||
const labelAlpha = hoverState.nodeId ? (isHighlighted ? 1 : 0.1) : 0.9;
|
|
||||||
drawEdgeLabel(ctx, labelPos.labelX, labelPos.labelY - 10, edge.label, colors.base, labelAlpha);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Draw nodes ──
|
|
||||||
|
|
||||||
for (const node of nodes) {
|
|
||||||
const pos = positions.get(node.id);
|
|
||||||
if (!pos) continue;
|
|
||||||
|
|
||||||
const isCenter = node.id === selectedCenter;
|
|
||||||
const isHovered = node.id === hoverState.nodeId;
|
|
||||||
const degree = degreeMap.get(node.id) || 0;
|
|
||||||
const r = degreeToRadius(degree, isCenter);
|
|
||||||
|
|
||||||
// Determine node color from its most common edge type, or default palette
|
|
||||||
let nodeColorBase = '#2563eb';
|
|
||||||
let nodeColorLight = '#60a5fa';
|
|
||||||
let nodeColorGlow = 'rgba(79,70,229,0.3)';
|
|
||||||
|
|
||||||
if (isCenter) {
|
|
||||||
const firstEdge = edges.find((e) => e.source === node.id || e.target === node.id);
|
|
||||||
if (firstEdge) {
|
|
||||||
const rc = getRelColor(firstEdge.label);
|
|
||||||
nodeColorBase = rc.base;
|
|
||||||
nodeColorLight = rc.light;
|
|
||||||
nodeColorGlow = rc.glow;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const idx = nodes.indexOf(node);
|
|
||||||
const pick = getEdgeColor(`_node_${idx}`);
|
|
||||||
nodeColorBase = pick.base;
|
|
||||||
nodeColorLight = pick.light;
|
|
||||||
nodeColorGlow = pick.glow;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodeAlpha = hoverState.nodeId
|
|
||||||
? (isHovered || (hoverState.nodeId && edges.some(
|
|
||||||
(e) => (e.source === hoverState.nodeId && e.target === node.id) ||
|
|
||||||
(e.target === hoverState.nodeId && e.source === node.id),
|
|
||||||
)) ? 1 : 0.2)
|
|
||||||
: 1;
|
|
||||||
|
|
||||||
drawNode(ctx, pos.x, pos.y, r, nodeColorBase, nodeColorLight, nodeColorGlow, isCenter, isHovered, nodeAlpha);
|
|
||||||
drawNodeLabel(ctx, pos.x, pos.y, r, node.label, textColor, isCenter, isHovered);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Hover tooltip ──
|
|
||||||
|
|
||||||
if (hoverState.nodeId) {
|
|
||||||
const hoveredNode = nodes.find((n) => n.id === hoverState.nodeId);
|
|
||||||
if (hoveredNode) {
|
|
||||||
const degree = degreeMap.get(hoverState.nodeId) || 0;
|
|
||||||
const tooltipText = `${hoveredNode.label} (${degree} 条关系)`;
|
|
||||||
ctx.save();
|
|
||||||
ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
|
||||||
const metrics = ctx.measureText(tooltipText);
|
|
||||||
const tw = metrics.width + 16;
|
|
||||||
const th = 28;
|
|
||||||
const tx = hoverState.x - tw / 2;
|
|
||||||
const ty = hoverState.y - 40;
|
|
||||||
|
|
||||||
ctx.fillStyle = token.colorBgElevated;
|
|
||||||
ctx.shadowColor = 'rgba(0,0,0,0.15)';
|
|
||||||
ctx.shadowBlur = 8;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.roundRect(tx, ty, tw, th, 6);
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
ctx.shadowBlur = 0;
|
|
||||||
ctx.fillStyle = token.colorText;
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
ctx.fillText(tooltipText, hoverState.x, ty + th / 2);
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [canvasSize, selectedCenter, hoverState, token]);
|
|
||||||
|
|
||||||
// ── On-demand redraw: data changes + resize ──
|
|
||||||
|
|
||||||
// 数据变更时触发单次重绘
|
|
||||||
useEffect(() => {
|
|
||||||
drawGraph();
|
|
||||||
}, [drawGraph]);
|
|
||||||
|
|
||||||
// 容器大小变化时触发重绘
|
|
||||||
useEffect(() => {
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
const observer = new ResizeObserver(() => {
|
|
||||||
drawGraph();
|
|
||||||
});
|
|
||||||
observer.observe(container);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [drawGraph]);
|
|
||||||
|
|
||||||
// ── Mouse interaction handlers ──
|
|
||||||
|
|
||||||
const handleCanvasMouseMove = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
const canvas = canvasRef.current;
|
const result = handleCanvasClick(e);
|
||||||
if (!canvas) return;
|
if (result.clicked) {
|
||||||
|
setSelectedCenter((prev) => (prev === result.clicked ? null : result.clicked));
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const x = e.clientX - rect.left;
|
|
||||||
const y = e.clientY - rect.top;
|
|
||||||
const positions = nodePositionsRef.current;
|
|
||||||
const nodes = visibleNodesRef.current;
|
|
||||||
const edges = visibleEdgesRef.current;
|
|
||||||
|
|
||||||
let foundId: string | null = null;
|
|
||||||
for (const node of nodes) {
|
|
||||||
const pos = positions.get(node.id);
|
|
||||||
if (!pos) continue;
|
|
||||||
const degree = getNodeDegree(node.id, edges);
|
|
||||||
const r = degreeToRadius(degree, node.id === selectedCenter) * NODE_HOVER_SCALE;
|
|
||||||
const dx = x - pos.x;
|
|
||||||
const dy = y - pos.y;
|
|
||||||
if (dx * dx + dy * dy < r * r) {
|
|
||||||
foundId = node.id;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.style.cursor = foundId ? 'pointer' : 'default';
|
|
||||||
setHoverState((prev) => {
|
|
||||||
if (prev.nodeId === foundId) return prev;
|
|
||||||
return { nodeId: foundId, x, y };
|
|
||||||
});
|
|
||||||
|
|
||||||
if (foundId) {
|
|
||||||
setHoverState({ nodeId: foundId, x, y });
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[selectedCenter],
|
[handleCanvasClick],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCanvasMouseLeave = useCallback(() => {
|
|
||||||
setHoverState({ nodeId: null, x: 0, y: 0 });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCanvasClick = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
if (!canvas) return;
|
|
||||||
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const x = e.clientX - rect.left;
|
|
||||||
const y = e.clientY - rect.top;
|
|
||||||
const positions = nodePositionsRef.current;
|
|
||||||
const nodes = visibleNodesRef.current;
|
|
||||||
const edges = visibleEdgesRef.current;
|
|
||||||
|
|
||||||
for (const node of nodes) {
|
|
||||||
const pos = positions.get(node.id);
|
|
||||||
if (!pos) continue;
|
|
||||||
const degree = getNodeDegree(node.id, edges);
|
|
||||||
const r = degreeToRadius(degree, node.id === selectedCenter);
|
|
||||||
const dx = x - pos.x;
|
|
||||||
const dy = y - pos.y;
|
|
||||||
if (dx * dx + dy * dy < r * r) {
|
|
||||||
setSelectedCenter((prev) => (prev === node.id ? null : node.id));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[selectedCenter],
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Legend data ──
|
|
||||||
|
|
||||||
const legendItems = relTypes.map((type) => ({
|
const legendItems = relTypes.map((type) => ({
|
||||||
label: getEdgeTypeLabel(type),
|
label: getEdgeTypeLabel(type),
|
||||||
rawLabel: type,
|
rawLabel: type,
|
||||||
@@ -475,8 +104,6 @@ export function PluginGraphPage() {
|
|||||||
count: relationships.filter((r) => r.label === type).length,
|
count: relationships.filter((r) => r.label === type).length,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ── Render ──
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24, textAlign: 'center' }}>
|
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||||
@@ -487,87 +114,43 @@ export function PluginGraphPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24 }}>
|
<div style={{ padding: 24 }}>
|
||||||
{/* Stats Row */}
|
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||||
<Col xs={24} sm={8}>
|
<Col xs={24} sm={8}>
|
||||||
<Card
|
<Card size="small" style={{ borderLeft: `3px solid ${token.colorPrimary}` }}>
|
||||||
size="small"
|
|
||||||
style={{ borderLeft: `3px solid ${token.colorPrimary}` }}
|
|
||||||
>
|
|
||||||
<Statistic
|
<Statistic
|
||||||
title={
|
title={<Text type="secondary" style={{ fontSize: 12 }}><TeamOutlined style={{ marginRight: 4 }} />客户总数</Text>}
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
<TeamOutlined style={{ marginRight: 4 }} />
|
|
||||||
客户总数
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
value={customers.length}
|
value={customers.length}
|
||||||
styles={{ content: { color: token.colorPrimary, fontWeight: 600 } }}
|
styles={{ content: { color: token.colorPrimary, fontWeight: 600 } }}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={8}>
|
<Col xs={24} sm={8}>
|
||||||
<Card
|
<Card size="small" style={{ borderLeft: `3px solid ${token.colorSuccess}` }}>
|
||||||
size="small"
|
|
||||||
style={{ borderLeft: `3px solid ${token.colorSuccess}` }}
|
|
||||||
>
|
|
||||||
<Statistic
|
<Statistic
|
||||||
title={
|
title={<Text type="secondary" style={{ fontSize: 12 }}><NodeIndexOutlined style={{ marginRight: 4 }} />关系总数</Text>}
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
<NodeIndexOutlined style={{ marginRight: 4 }} />
|
|
||||||
关系总数
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
value={relationships.length}
|
value={relationships.length}
|
||||||
styles={{ content: { color: token.colorSuccess, fontWeight: 600 } }}
|
styles={{ content: { color: token.colorSuccess, fontWeight: 600 } }}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={8}>
|
<Col xs={24} sm={8}>
|
||||||
<Card
|
<Card size="small" style={{ borderLeft: `3px solid ${token.colorWarning}` }}>
|
||||||
size="small"
|
|
||||||
style={{ borderLeft: `3px solid ${token.colorWarning}` }}
|
|
||||||
>
|
|
||||||
<Statistic
|
<Statistic
|
||||||
title={
|
title={<Text type="secondary" style={{ fontSize: 12 }}><AimOutlined style={{ marginRight: 4 }} />当前中心</Text>}
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
<AimOutlined style={{ marginRight: 4 }} />
|
|
||||||
当前中心
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
value={centerNode?.label || '未选择'}
|
value={centerNode?.label || '未选择'}
|
||||||
styles={{
|
styles={{ content: { fontSize: 20, color: centerNode ? token.colorWarning : token.colorTextDisabled, fontWeight: 600 } }}
|
||||||
content: {
|
|
||||||
fontSize: 20,
|
|
||||||
color: centerNode ? token.colorWarning : token.colorTextDisabled,
|
|
||||||
fontWeight: 600,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{selectedCenter && (
|
{selectedCenter && <Text type="secondary" style={{ fontSize: 11 }}>{centerDegree} 条直接关系</Text>}
|
||||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
|
||||||
{centerDegree} 条直接关系
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{/* Main Graph Card */}
|
|
||||||
<Card
|
<Card
|
||||||
title={
|
title={
|
||||||
<Space>
|
<Space>
|
||||||
<ApartmentOutlined />
|
<ApartmentOutlined />
|
||||||
<span>客户关系图谱</span>
|
<span>客户关系图谱</span>
|
||||||
{relFilter && (
|
{relFilter && <Tag color="blue" closable onClose={() => setRelFilter(undefined)}>{getEdgeTypeLabel(relFilter)}</Tag>}
|
||||||
<Tag
|
|
||||||
color="blue"
|
|
||||||
closable
|
|
||||||
onClose={() => setRelFilter(undefined)}
|
|
||||||
>
|
|
||||||
{getEdgeTypeLabel(relFilter)}
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
size="small"
|
size="small"
|
||||||
@@ -581,19 +164,9 @@ export function PluginGraphPage() {
|
|||||||
options={relTypes.map((t) => ({
|
options={relTypes.map((t) => ({
|
||||||
label: (
|
label: (
|
||||||
<Space>
|
<Space>
|
||||||
<span
|
<span style={{ display: 'inline-block', width: 10, height: 10, borderRadius: '50%', backgroundColor: getRelColor(t).base }} />
|
||||||
style={{
|
|
||||||
display: 'inline-block',
|
|
||||||
width: 10,
|
|
||||||
height: 10,
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: getRelColor(t).base,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{getEdgeTypeLabel(t)}
|
{getEdgeTypeLabel(t)}
|
||||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
<Text type="secondary" style={{ fontSize: 11 }}>({relationships.filter((r) => r.label === t).length})</Text>
|
||||||
({relationships.filter((r) => r.label === t).length})
|
|
||||||
</Text>
|
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
value: t,
|
value: t,
|
||||||
@@ -607,73 +180,42 @@ export function PluginGraphPage() {
|
|||||||
style={{ width: 200 }}
|
style={{ width: 200 }}
|
||||||
optionFilterProp="label"
|
optionFilterProp="label"
|
||||||
value={selectedCenter || undefined}
|
value={selectedCenter || undefined}
|
||||||
options={customers.map((c) => ({
|
options={customers.map((c) => ({ label: c.label, value: c.id }))}
|
||||||
label: c.label,
|
|
||||||
value: c.id,
|
|
||||||
}))}
|
|
||||||
onChange={(v) => setSelectedCenter(v || null)}
|
onChange={(v) => setSelectedCenter(v || null)}
|
||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{customers.length === 0 ? (
|
{customers.length === 0 ? (
|
||||||
<Empty
|
<Empty description="暂无客户数据" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
description="暂无客户数据"
|
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div ref={containerRef} style={{ position: 'relative' }}>
|
<div ref={containerRef} style={{ position: 'relative' }}>
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
onMouseMove={handleCanvasMouseMove}
|
onMouseMove={handleCanvasMouseMove}
|
||||||
onMouseLeave={handleCanvasMouseLeave}
|
onMouseLeave={handleCanvasMouseLeave}
|
||||||
onClick={handleCanvasClick}
|
onClick={onCanvasClick}
|
||||||
style={{
|
style={{ width: '100%', height: canvasSize.height, borderRadius: 8, border: `1px solid ${token.colorBorderSecondary}`, display: 'block' }}
|
||||||
width: '100%',
|
|
||||||
height: canvasSize.height,
|
|
||||||
borderRadius: 8,
|
|
||||||
border: `1px solid ${token.colorBorderSecondary}`,
|
|
||||||
display: 'block',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Legend overlay */}
|
|
||||||
{legendItems.length > 0 && (
|
{legendItems.length > 0 && (
|
||||||
<div
|
<div style={{
|
||||||
style={{
|
position: 'absolute', bottom: 12, left: 12,
|
||||||
position: 'absolute',
|
background: token.colorBgElevated, border: `1px solid ${token.colorBorderSecondary}`,
|
||||||
bottom: 12,
|
borderRadius: 8, padding: '8px 12px', boxShadow: token.boxShadowSecondary, maxWidth: 220,
|
||||||
left: 12,
|
}}>
|
||||||
background: token.colorBgElevated,
|
<Text strong style={{ fontSize: 11, color: token.colorTextSecondary, display: 'block', marginBottom: 4 }}>关系类型图例</Text>
|
||||||
border: `1px solid ${token.colorBorderSecondary}`,
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: '8px 12px',
|
|
||||||
boxShadow: token.boxShadowSecondary,
|
|
||||||
maxWidth: 220,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
strong
|
|
||||||
style={{ fontSize: 11, color: token.colorTextSecondary, display: 'block', marginBottom: 4 }}
|
|
||||||
>
|
|
||||||
关系类型图例
|
|
||||||
</Text>
|
|
||||||
<Flex wrap="wrap" gap={6}>
|
<Flex wrap="wrap" gap={6}>
|
||||||
{legendItems.map((item) => (
|
{legendItems.map((item) => (
|
||||||
<Tag
|
<Tag
|
||||||
key={item.rawLabel}
|
key={item.rawLabel}
|
||||||
color={item.color}
|
color={item.color}
|
||||||
style={{
|
style={{
|
||||||
margin: 0,
|
margin: 0, fontSize: 11,
|
||||||
fontSize: 11,
|
|
||||||
cursor: relFilter === item.rawLabel ? 'default' : 'pointer',
|
cursor: relFilter === item.rawLabel ? 'default' : 'pointer',
|
||||||
opacity: relFilter && relFilter !== item.rawLabel ? 0.4 : 1,
|
opacity: relFilter && relFilter !== item.rawLabel ? 0.4 : 1,
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => setRelFilter((prev) => prev === item.rawLabel ? undefined : item.rawLabel)}
|
||||||
setRelFilter((prev) =>
|
|
||||||
prev === item.rawLabel ? undefined : item.rawLabel,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{item.label} ({item.count})
|
{item.label} ({item.count})
|
||||||
</Tag>
|
</Tag>
|
||||||
@@ -682,29 +224,17 @@ export function PluginGraphPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Info overlay */}
|
|
||||||
{hoverState.nodeId && (
|
{hoverState.nodeId && (
|
||||||
<div
|
<div style={{
|
||||||
style={{
|
position: 'absolute', top: 12, right: 12,
|
||||||
position: 'absolute',
|
background: token.colorBgElevated, border: `1px solid ${token.colorBorderSecondary}`,
|
||||||
top: 12,
|
borderRadius: 8, padding: '8px 12px', boxShadow: token.boxShadowSecondary, maxWidth: 280,
|
||||||
right: 12,
|
transition: 'opacity 0.15s ease',
|
||||||
background: token.colorBgElevated,
|
}}>
|
||||||
border: `1px solid ${token.colorBorderSecondary}`,
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: '8px 12px',
|
|
||||||
boxShadow: token.boxShadowSecondary,
|
|
||||||
maxWidth: 280,
|
|
||||||
transition: 'opacity 0.15s ease',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Space direction="vertical" size={4}>
|
<Space direction="vertical" size={4}>
|
||||||
<Text strong>
|
<Text strong>{visibleNodes.find((n) => n.id === hoverState.nodeId)?.label}</Text>
|
||||||
{visibleNodes.find((n) => n.id === hoverState.nodeId)?.label}
|
|
||||||
</Text>
|
|
||||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
<InfoCircleOutlined style={{ marginRight: 4 }} />
|
<InfoCircleOutlined style={{ marginRight: 4 }} />点击节点设为中心 / 再次点击取消
|
||||||
点击节点设为中心 / 再次点击取消
|
|
||||||
</Text>
|
</Text>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
@@ -713,7 +243,6 @@ export function PluginGraphPage() {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Selected node detail panel */}
|
|
||||||
{selectedCenter && centerNode && (
|
{selectedCenter && centerNode && (
|
||||||
<Card
|
<Card
|
||||||
size="small"
|
size="small"
|
||||||
@@ -727,13 +256,8 @@ export function PluginGraphPage() {
|
|||||||
}
|
}
|
||||||
extra={
|
extra={
|
||||||
<Tooltip title="取消选中">
|
<Tooltip title="取消选中">
|
||||||
<Text
|
<Text type="secondary" style={{ cursor: 'pointer', fontSize: 12 }} onClick={() => setSelectedCenter(null)}>
|
||||||
type="secondary"
|
<ReloadOutlined style={{ marginRight: 4 }} />重置视图
|
||||||
style={{ cursor: 'pointer', fontSize: 12 }}
|
|
||||||
onClick={() => setSelectedCenter(null)}
|
|
||||||
>
|
|
||||||
<ReloadOutlined style={{ marginRight: 4 }} />
|
|
||||||
重置视图
|
|
||||||
</Text>
|
</Text>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
@@ -742,12 +266,9 @@ export function PluginGraphPage() {
|
|||||||
{Object.entries(centerNode.data).map(([key, value]) => {
|
{Object.entries(centerNode.data).map(([key, value]) => {
|
||||||
if (value == null || value === '') return null;
|
if (value == null || value === '') return null;
|
||||||
const fieldSchema = fields.find((f) => f.name === key);
|
const fieldSchema = fields.find((f) => f.name === key);
|
||||||
const displayName = fieldSchema?.display_name || key;
|
|
||||||
return (
|
return (
|
||||||
<Col xs={12} sm={8} md={6} key={key}>
|
<Col xs={12} sm={8} md={6} key={key}>
|
||||||
<Text type="secondary" style={{ fontSize: 11, display: 'block' }}>
|
<Text type="secondary" style={{ fontSize: 11, display: 'block' }}>{fieldSchema?.display_name || key}</Text>
|
||||||
{displayName}
|
|
||||||
</Text>
|
|
||||||
<Text style={{ fontSize: 13 }}>{String(value)}</Text>
|
<Text style={{ fontSize: 13 }}>{String(value)}</Text>
|
||||||
</Col>
|
</Col>
|
||||||
);
|
);
|
||||||
@@ -755,8 +276,7 @@ export function PluginGraphPage() {
|
|||||||
</Row>
|
</Row>
|
||||||
<Divider style={{ margin: '12px 0 8px' }} />
|
<Divider style={{ margin: '12px 0 8px' }} />
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
直接关系: {centerDegree} 条 —
|
直接关系: {centerDegree} 条 — 显示 {visibleNodes.length} 个节点、{visibleEdges.length} 条边
|
||||||
显示 {visibleNodes.length} 个节点、{visibleEdges.length} 条边
|
|
||||||
</Text>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
250
apps/web/src/pages/PluginGraphPage/useGraphCanvas.ts
Normal file
250
apps/web/src/pages/PluginGraphPage/useGraphCanvas.ts
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import { useRef, useState, useCallback, useEffect } from 'react';
|
||||||
|
import type { GlobalToken } from 'antd/es/theme/interface';
|
||||||
|
import type { GraphNode, GraphEdge, NodePosition, HoverState } from '../graph/graphTypes';
|
||||||
|
import { computeCircularLayout } from '../graph/graphLayout';
|
||||||
|
import {
|
||||||
|
getEdgeColor,
|
||||||
|
NODE_HOVER_SCALE,
|
||||||
|
getRelColor,
|
||||||
|
getEdgeTypeLabel,
|
||||||
|
getNodeDegree,
|
||||||
|
degreeToRadius,
|
||||||
|
drawCurvedEdge,
|
||||||
|
drawNode,
|
||||||
|
drawEdgeLabel,
|
||||||
|
drawNodeLabel,
|
||||||
|
} from '../graph/graphRenderer';
|
||||||
|
|
||||||
|
interface UseGraphCanvasParams {
|
||||||
|
token: GlobalToken;
|
||||||
|
canvasSize: { width: number; height: number };
|
||||||
|
selectedCenter: string | null;
|
||||||
|
visibleNodes: GraphNode[];
|
||||||
|
visibleEdges: GraphEdge[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGraphCanvas({
|
||||||
|
token,
|
||||||
|
canvasSize,
|
||||||
|
selectedCenter,
|
||||||
|
visibleNodes,
|
||||||
|
visibleEdges,
|
||||||
|
}: UseGraphCanvasParams) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const nodePositionsRef = useRef<Map<string, NodePosition>>(new Map());
|
||||||
|
const visibleNodesRef = useRef<GraphNode[]>(visibleNodes);
|
||||||
|
const visibleEdgesRef = useRef<GraphEdge[]>(visibleEdges);
|
||||||
|
const [hoverState, setHoverState] = useState<HoverState>({ nodeId: null, x: 0, y: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
visibleNodesRef.current = visibleNodes;
|
||||||
|
visibleEdgesRef.current = visibleEdges;
|
||||||
|
}, [visibleNodes, visibleEdges]);
|
||||||
|
|
||||||
|
const drawGraph = useCallback(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const nodes = visibleNodesRef.current;
|
||||||
|
const edges = visibleEdgesRef.current;
|
||||||
|
const width = canvasSize.width;
|
||||||
|
const height = canvasSize.height;
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
canvas.width = width * dpr;
|
||||||
|
canvas.height = height * dpr;
|
||||||
|
canvas.style.width = `${width}px`;
|
||||||
|
canvas.style.height = `${height}px`;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
const textColor = token.colorText;
|
||||||
|
const bgColor = token.colorBgContainer;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
ctx.fillStyle = bgColor;
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
if (nodes.length === 0) return;
|
||||||
|
|
||||||
|
const centerX = width / 2;
|
||||||
|
const centerY = height / 2;
|
||||||
|
const radius = Math.min(width, height) * 0.36;
|
||||||
|
const positions = computeCircularLayout(nodes, centerX, centerY, radius);
|
||||||
|
nodePositionsRef.current = positions;
|
||||||
|
|
||||||
|
const degreeMap = new Map<string, number>();
|
||||||
|
for (const node of nodes) {
|
||||||
|
degreeMap.set(node.id, getNodeDegree(node.id, edges));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const edge of edges) {
|
||||||
|
const from = positions.get(edge.source);
|
||||||
|
const to = positions.get(edge.target);
|
||||||
|
if (!from || !to) continue;
|
||||||
|
|
||||||
|
const colors = getRelColor(edge.label);
|
||||||
|
const isHighlighted = hoverState.nodeId === edge.source || hoverState.nodeId === edge.target;
|
||||||
|
const alpha = hoverState.nodeId ? (isHighlighted ? 1 : 0.15) : 0.7;
|
||||||
|
const lw = isHighlighted ? 2.5 : 1.5;
|
||||||
|
|
||||||
|
const labelPos = drawCurvedEdge(ctx, from.x, from.y, to.x, to.y, colors.base, lw, isHighlighted, alpha);
|
||||||
|
if (edge.label && labelPos) {
|
||||||
|
const labelAlpha = hoverState.nodeId ? (isHighlighted ? 1 : 0.1) : 0.9;
|
||||||
|
drawEdgeLabel(ctx, labelPos.labelX, labelPos.labelY - 10, edge.label, colors.base, labelAlpha);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
const pos = positions.get(node.id);
|
||||||
|
if (!pos) continue;
|
||||||
|
|
||||||
|
const isCenter = node.id === selectedCenter;
|
||||||
|
const isHovered = node.id === hoverState.nodeId;
|
||||||
|
const degree = degreeMap.get(node.id) || 0;
|
||||||
|
const r = degreeToRadius(degree, isCenter);
|
||||||
|
|
||||||
|
let nodeColorBase = '#2563eb';
|
||||||
|
let nodeColorLight = '#60a5fa';
|
||||||
|
let nodeColorGlow = 'rgba(79,70,229,0.3)';
|
||||||
|
|
||||||
|
if (isCenter) {
|
||||||
|
const firstEdge = edges.find((e) => e.source === node.id || e.target === node.id);
|
||||||
|
if (firstEdge) {
|
||||||
|
const rc = getRelColor(firstEdge.label);
|
||||||
|
nodeColorBase = rc.base;
|
||||||
|
nodeColorLight = rc.light;
|
||||||
|
nodeColorGlow = rc.glow;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const idx = nodes.indexOf(node);
|
||||||
|
const pick = getEdgeColor(`_node_${idx}`);
|
||||||
|
nodeColorBase = pick.base;
|
||||||
|
nodeColorLight = pick.light;
|
||||||
|
nodeColorGlow = pick.glow;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeAlpha = hoverState.nodeId
|
||||||
|
? (isHovered || edges.some(
|
||||||
|
(e) => (e.source === hoverState.nodeId && e.target === node.id) ||
|
||||||
|
(e.target === hoverState.nodeId && e.source === node.id),
|
||||||
|
) ? 1 : 0.2)
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
drawNode(ctx, pos.x, pos.y, r, nodeColorBase, nodeColorLight, nodeColorGlow, isCenter, isHovered, nodeAlpha);
|
||||||
|
drawNodeLabel(ctx, pos.x, pos.y, r, node.label, textColor, isCenter, isHovered);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hoverState.nodeId) {
|
||||||
|
const hoveredNode = nodes.find((n) => n.id === hoverState.nodeId);
|
||||||
|
if (hoveredNode) {
|
||||||
|
const degree = degreeMap.get(hoveredNode.id) || 0;
|
||||||
|
const tooltipText = `${hoveredNode.label} (${degree} 条关系)`;
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||||
|
const metrics = ctx.measureText(tooltipText);
|
||||||
|
const tw = metrics.width + 16;
|
||||||
|
const th = 28;
|
||||||
|
const tx = hoverState.x - tw / 2;
|
||||||
|
const ty = hoverState.y - 40;
|
||||||
|
|
||||||
|
ctx.fillStyle = token.colorBgElevated;
|
||||||
|
ctx.shadowColor = 'rgba(0,0,0,0.15)';
|
||||||
|
ctx.shadowBlur = 8;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(tx, ty, tw, th, 6);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
ctx.fillStyle = token.colorText;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(tooltipText, hoverState.x, ty + th / 2);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [canvasSize, selectedCenter, hoverState, token]);
|
||||||
|
|
||||||
|
useEffect(() => { drawGraph(); }, [drawGraph]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
const observer = new ResizeObserver(() => { drawGraph(); });
|
||||||
|
observer.observe(container);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [drawGraph]);
|
||||||
|
|
||||||
|
const handleCanvasMouseMove = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
const positions = nodePositionsRef.current;
|
||||||
|
const nodes = visibleNodesRef.current;
|
||||||
|
const edges = visibleEdgesRef.current;
|
||||||
|
|
||||||
|
let foundId: string | null = null;
|
||||||
|
for (const node of nodes) {
|
||||||
|
const pos = positions.get(node.id);
|
||||||
|
if (!pos) continue;
|
||||||
|
const degree = getNodeDegree(node.id, edges);
|
||||||
|
const r = degreeToRadius(degree, node.id === selectedCenter) * NODE_HOVER_SCALE;
|
||||||
|
const dx = x - pos.x;
|
||||||
|
const dy = y - pos.y;
|
||||||
|
if (dx * dx + dy * dy < r * r) { foundId = node.id; break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.style.cursor = foundId ? 'pointer' : 'default';
|
||||||
|
setHoverState((prev) =>
|
||||||
|
prev.nodeId === foundId ? prev : { nodeId: foundId, x, y },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[selectedCenter],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCanvasMouseLeave = useCallback(() => {
|
||||||
|
setHoverState({ nodeId: null, x: 0, y: 0 });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCanvasClick = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
const positions = nodePositionsRef.current;
|
||||||
|
const nodes = visibleNodesRef.current;
|
||||||
|
const edges = visibleEdgesRef.current;
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
const pos = positions.get(node.id);
|
||||||
|
if (!pos) continue;
|
||||||
|
const degree = getNodeDegree(node.id, edges);
|
||||||
|
const r = degreeToRadius(degree, node.id === selectedCenter);
|
||||||
|
const dx = x - pos.x;
|
||||||
|
const dy = y - pos.y;
|
||||||
|
if (dx * dx + dy * dy < r * r) {
|
||||||
|
return { clicked: node.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { clicked: null };
|
||||||
|
},
|
||||||
|
[selectedCenter],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
canvasRef,
|
||||||
|
containerRef,
|
||||||
|
hoverState,
|
||||||
|
drawGraph,
|
||||||
|
handleCanvasMouseMove,
|
||||||
|
handleCanvasMouseLeave,
|
||||||
|
handleCanvasClick,
|
||||||
|
};
|
||||||
|
}
|
||||||
117
apps/web/src/pages/PluginGraphPage/useGraphData.ts
Normal file
117
apps/web/src/pages/PluginGraphPage/useGraphData.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { message } from 'antd';
|
||||||
|
import { listPluginData } from '../../api/pluginData';
|
||||||
|
import {
|
||||||
|
getPluginSchema,
|
||||||
|
type PluginFieldSchema,
|
||||||
|
type PluginSchemaResponse,
|
||||||
|
} from '../../api/plugins';
|
||||||
|
import type { GraphNode, GraphEdge, GraphConfig } from '../graph/graphTypes';
|
||||||
|
|
||||||
|
export function useGraphData(pluginId?: string, entityName?: string) {
|
||||||
|
const [customers, setCustomers] = useState<GraphNode[]>([]);
|
||||||
|
const [relationships, setRelationships] = useState<GraphEdge[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [graphConfig, setGraphConfig] = useState<GraphConfig | null>(null);
|
||||||
|
const [fields, setFields] = useState<PluginFieldSchema[]>([]);
|
||||||
|
const [relTypes, setRelTypes] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pluginId || !entityName) return;
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
async function loadSchema() {
|
||||||
|
try {
|
||||||
|
const schema: PluginSchemaResponse = await getPluginSchema(pluginId!);
|
||||||
|
if (abortController.signal.aborted) return;
|
||||||
|
|
||||||
|
const pages = schema.ui?.pages || [];
|
||||||
|
const graphPage = pages.find(
|
||||||
|
(p): p is typeof p & GraphConfig & { type: 'graph' } =>
|
||||||
|
p.type === 'graph' && p.entity === entityName,
|
||||||
|
);
|
||||||
|
if (graphPage) {
|
||||||
|
setGraphConfig({
|
||||||
|
entity: graphPage.entity,
|
||||||
|
relationshipEntity: graphPage.relationship_entity,
|
||||||
|
sourceField: graphPage.source_field,
|
||||||
|
targetField: graphPage.target_field,
|
||||||
|
edgeLabelField: graphPage.edge_label_field,
|
||||||
|
nodeLabelField: graphPage.node_label_field,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const entity = schema.entities?.find((e) => e.name === entityName);
|
||||||
|
if (entity) setFields(entity.fields);
|
||||||
|
} catch {
|
||||||
|
message.warning('Schema 加载失败,部分功能不可用');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSchema();
|
||||||
|
return () => abortController.abort();
|
||||||
|
}, [pluginId, entityName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pluginId || !graphConfig) return;
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const gc = graphConfig;
|
||||||
|
const labelField = fields.find((f) => f.name === gc.nodeLabelField)?.name || fields[1]?.name || 'name';
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
let allCustomers: GraphNode[] = [];
|
||||||
|
let page = 1;
|
||||||
|
let hasMore = true;
|
||||||
|
while (hasMore) {
|
||||||
|
if (abortController.signal.aborted) return;
|
||||||
|
const result = await listPluginData(pluginId!, gc.entity, page, 100);
|
||||||
|
allCustomers = [
|
||||||
|
...allCustomers,
|
||||||
|
...result.data.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
label: String(r.data[labelField] || '未命名'),
|
||||||
|
data: r.data,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
hasMore = result.data.length === 100 && allCustomers.length < result.total;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
if (abortController.signal.aborted) return;
|
||||||
|
setCustomers(allCustomers);
|
||||||
|
|
||||||
|
let allRels: GraphEdge[] = [];
|
||||||
|
page = 1;
|
||||||
|
hasMore = true;
|
||||||
|
const types = new Set<string>();
|
||||||
|
while (hasMore) {
|
||||||
|
if (abortController.signal.aborted) return;
|
||||||
|
const result = await listPluginData(pluginId!, gc.relationshipEntity, page, 100);
|
||||||
|
for (const r of result.data) {
|
||||||
|
const relType = String(r.data[gc.edgeLabelField] || '');
|
||||||
|
types.add(relType);
|
||||||
|
allRels.push({
|
||||||
|
source: String(r.data[gc.sourceField] || ''),
|
||||||
|
target: String(r.data[gc.targetField] || ''),
|
||||||
|
label: relType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
hasMore = result.data.length === 100 && allRels.length < result.total;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
if (abortController.signal.aborted) return;
|
||||||
|
setRelationships(allRels);
|
||||||
|
setRelTypes(Array.from(types));
|
||||||
|
} catch {
|
||||||
|
message.warning('数据加载失败');
|
||||||
|
}
|
||||||
|
if (!abortController.signal.aborted) setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
return () => abortController.abort();
|
||||||
|
}, [pluginId, graphConfig, fields]);
|
||||||
|
|
||||||
|
return { customers, relationships, loading, fields, graphConfig, relTypes };
|
||||||
|
}
|
||||||
@@ -22,8 +22,8 @@ import {
|
|||||||
pointsApi,
|
pointsApi,
|
||||||
type PointsOrder,
|
type PointsOrder,
|
||||||
} from '../../api/health/points';
|
} from '../../api/health/points';
|
||||||
import { patientApi } from '../../api/health/patients';
|
|
||||||
import { AuthButton } from '../../components/AuthButton';
|
import { AuthButton } from '../../components/AuthButton';
|
||||||
|
import { useHealthStore } from '../../stores/health';
|
||||||
|
|
||||||
/** 订单状态映射 */
|
/** 订单状态映射 */
|
||||||
const STATUS_MAP: Record<string, { text: string; color: string }> = {
|
const STATUS_MAP: Record<string, { text: string; color: string }> = {
|
||||||
@@ -56,8 +56,7 @@ export default function PointsOrderList() {
|
|||||||
const [verifyForm] = Form.useForm();
|
const [verifyForm] = Form.useForm();
|
||||||
const [verifying, setVerifying] = useState(false);
|
const [verifying, setVerifying] = useState(false);
|
||||||
|
|
||||||
// 名称缓存
|
const { batchResolvePatientNames, getPatientName } = useHealthStore();
|
||||||
const [nameCache, setNameCache] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
// ---- 数据获取 ----
|
// ---- 数据获取 ----
|
||||||
const fetchData = useCallback(async (p = page, ps = pageSize) => {
|
const fetchData = useCallback(async (p = page, ps = pageSize) => {
|
||||||
@@ -71,29 +70,14 @@ export default function PointsOrderList() {
|
|||||||
setData(result.data);
|
setData(result.data);
|
||||||
setTotal(result.total);
|
setTotal(result.total);
|
||||||
|
|
||||||
// 批量解析患者名称
|
const patientIds = result.data.map((o) => o.patient_id);
|
||||||
const patientIds = [...new Set(result.data.map((o) => o.patient_id))];
|
batchResolvePatientNames(patientIds);
|
||||||
const missingIds = patientIds.filter((id) => !nameCache[id]);
|
|
||||||
if (missingIds.length > 0) {
|
|
||||||
const newNames: Record<string, string> = {};
|
|
||||||
await Promise.all(
|
|
||||||
missingIds.map(async (id) => {
|
|
||||||
try {
|
|
||||||
const detail = await patientApi.get(id);
|
|
||||||
newNames[id] = detail.name;
|
|
||||||
} catch {
|
|
||||||
newNames[id] = id.slice(0, 8);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
setNameCache((prev) => ({ ...prev, ...newNames }));
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
message.error('加载订单列表失败');
|
message.error('加载订单列表失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, pageSize, statusFilter, nameCache]);
|
}, [page, pageSize, statusFilter, batchResolvePatientNames]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
@@ -136,7 +120,7 @@ export default function PointsOrderList() {
|
|||||||
dataIndex: 'patient_id',
|
dataIndex: 'patient_id',
|
||||||
key: 'patient_id',
|
key: 'patient_id',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (id: string) => nameCache[id] || id.slice(0, 8),
|
render: (id: string) => getPatientName(id),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '商品',
|
title: '商品',
|
||||||
@@ -182,7 +166,7 @@ export default function PointsOrderList() {
|
|||||||
dataIndex: 'verified_by',
|
dataIndex: 'verified_by',
|
||||||
key: 'verified_by',
|
key: 'verified_by',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (val: string | null) => val ? <Tag color="blue">{nameCache[val] || val.slice(0, 8)}</Tag> : '-',
|
render: (val: string | null) => val ? <Tag color="blue">{truncateId(val)}</Tag> : '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '过期时间',
|
title: '过期时间',
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
|
||||||
import {
|
import {
|
||||||
Row,
|
Row,
|
||||||
Col,
|
Col,
|
||||||
@@ -10,7 +9,6 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Typography,
|
Typography,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Tag,
|
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
@@ -28,30 +26,23 @@ import {
|
|||||||
ArrowUpOutlined,
|
ArrowUpOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import type { PointsStatistics } from '../../api/health/points';
|
||||||
pointsApi,
|
import { useStatsData } from './StatisticsDashboard/useStatsData';
|
||||||
type PatientStatistics,
|
import HealthDataCenter from './StatisticsDashboard/HealthDataCenter';
|
||||||
type ConsultationStatistics,
|
|
||||||
type FollowUpStatistics,
|
|
||||||
type PointsStatistics,
|
|
||||||
type HealthDataStats,
|
|
||||||
} from '../../api/health/points';
|
|
||||||
|
|
||||||
const { Title: AntTitle, Text } = Typography;
|
const { Title: AntTitle, Text } = Typography;
|
||||||
|
|
||||||
/** Top-level stat card configuration */
|
|
||||||
interface StatCardConfig {
|
interface StatCardConfig {
|
||||||
title: string;
|
title: string;
|
||||||
value: number;
|
value: number;
|
||||||
suffix?: string;
|
suffix?: string;
|
||||||
precision?: number;
|
precision?: number;
|
||||||
prefix?: React.ReactNode;
|
prefix: React.ReactNode;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
color: string;
|
color: string;
|
||||||
bgColor: string;
|
bgColor: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Quick-link card configuration */
|
|
||||||
interface QuickLinkConfig {
|
interface QuickLinkConfig {
|
||||||
title: string;
|
title: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
@@ -59,7 +50,6 @@ interface QuickLinkConfig {
|
|||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Top earner row from points statistics */
|
|
||||||
interface TopEarnerRow {
|
interface TopEarnerRow {
|
||||||
rank: number;
|
rank: number;
|
||||||
patient_id: string;
|
patient_id: string;
|
||||||
@@ -77,137 +67,78 @@ const QUICK_LINKS: QuickLinkConfig[] = [
|
|||||||
{ title: '线下活动', icon: <CalendarOutlined />, path: '/health/offline-events', color: '#be185d' },
|
{ title: '线下活动', icon: <CalendarOutlined />, path: '/health/offline-events', color: '#be185d' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function StatisticsDashboard() {
|
function buildStatCards(stats: ReturnType<typeof useStatsData>): StatCardConfig[] {
|
||||||
const navigate = useNavigate();
|
return [
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [patientStats, setPatientStats] = useState<PatientStatistics | null>(null);
|
|
||||||
const [consultationStats, setConsultationStats] = useState<ConsultationStatistics | null>(null);
|
|
||||||
const [followUpStats, setFollowUpStats] = useState<FollowUpStatistics | null>(null);
|
|
||||||
const [pointsStats, setPointsStats] = useState<PointsStatistics | null>(null);
|
|
||||||
const [healthDataStats, setHealthDataStats] = useState<HealthDataStats | null>(null);
|
|
||||||
|
|
||||||
const fetchAllStats = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
let hasAnyError = false;
|
|
||||||
const errors: string[] = [];
|
|
||||||
|
|
||||||
const tryFetch = async <T,>(fn: () => Promise<T>, setter: (v: T) => void, label: string) => {
|
|
||||||
try {
|
|
||||||
const data = await fn();
|
|
||||||
setter(data);
|
|
||||||
} catch {
|
|
||||||
hasAnyError = true;
|
|
||||||
errors.push(label);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
tryFetch(pointsApi.getPatientStats, setPatientStats, '患者'),
|
|
||||||
tryFetch(pointsApi.getConsultationStats, setConsultationStats, '咨询'),
|
|
||||||
tryFetch(pointsApi.getFollowUpStats, setFollowUpStats, '随访'),
|
|
||||||
tryFetch(pointsApi.getStatistics, setPointsStats, '积分'),
|
|
||||||
tryFetch(pointsApi.getHealthDataStats, setHealthDataStats, '健康数据'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (hasAnyError && errors.length === 5) {
|
|
||||||
setError('加载统计数据失败');
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchAllStats();
|
|
||||||
}, [fetchAllStats]);
|
|
||||||
|
|
||||||
// ---- Derived stat cards ----
|
|
||||||
const statCards: StatCardConfig[] = [
|
|
||||||
{
|
{
|
||||||
title: '患者总数',
|
title: '患者总数',
|
||||||
value: patientStats?.total_patients ?? 0,
|
value: stats.patientStats?.total_patients ?? 0,
|
||||||
prefix: <UserOutlined />,
|
prefix: <UserOutlined />,
|
||||||
subtitle: patientStats?.new_this_month ? `本月 +${patientStats.new_this_month}` : undefined,
|
subtitle: stats.patientStats?.new_this_month ? `本月 +${stats.patientStats.new_this_month}` : undefined,
|
||||||
color: '#2563eb',
|
color: '#2563eb', bgColor: '#eff6ff',
|
||||||
bgColor: '#eff6ff',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '咨询总量',
|
title: '咨询总量',
|
||||||
value: consultationStats?.total_sessions ?? 0,
|
value: stats.consultationStats?.total_sessions ?? 0,
|
||||||
prefix: <MessageOutlined />,
|
prefix: <MessageOutlined />,
|
||||||
subtitle: consultationStats?.this_month ? `本月 +${consultationStats.this_month}` : undefined,
|
subtitle: stats.consultationStats?.this_month ? `本月 +${stats.consultationStats.this_month}` : undefined,
|
||||||
color: '#7c3aed',
|
color: '#7c3aed', bgColor: '#f5f3ff',
|
||||||
bgColor: '#f5f3ff',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '随访完成率',
|
title: '随访完成率',
|
||||||
value: followUpStats?.completion_rate ?? 0,
|
value: stats.followUpStats?.completion_rate ?? 0,
|
||||||
suffix: '%',
|
suffix: '%', precision: 1,
|
||||||
precision: 1,
|
|
||||||
prefix: <PhoneOutlined />,
|
prefix: <PhoneOutlined />,
|
||||||
subtitle: followUpStats?.pending ? `待处理: ${followUpStats.pending}` : undefined,
|
subtitle: stats.followUpStats?.pending ? `待处理: ${stats.followUpStats.pending}` : undefined,
|
||||||
color: '#059669',
|
color: '#059669', bgColor: '#ecfdf5',
|
||||||
bgColor: '#ecfdf5',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '积分总发放',
|
title: '积分总发放',
|
||||||
value: pointsStats?.total_issued ?? 0,
|
value: stats.pointsStats?.total_issued ?? 0,
|
||||||
prefix: <TrophyOutlined />,
|
prefix: <TrophyOutlined />,
|
||||||
subtitle: pointsStats?.active_accounts ? `活跃账户: ${pointsStats.active_accounts}` : undefined,
|
subtitle: stats.pointsStats?.active_accounts ? `活跃账户: ${stats.pointsStats.active_accounts}` : undefined,
|
||||||
color: '#d97706',
|
color: '#d97706', bgColor: '#fffbeb',
|
||||||
bgColor: '#fffbeb',
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Top earners table ----
|
const topEarnerColumns = [
|
||||||
const topEarnerColumns = [
|
{
|
||||||
{
|
title: '排名', dataIndex: 'rank', key: 'rank', width: 70,
|
||||||
title: '排名',
|
render: (rank: number) => {
|
||||||
dataIndex: 'rank',
|
const medalColors = ['#d97706', '#6b7280', '#b45309'];
|
||||||
key: 'rank',
|
const color = rank <= 3 ? medalColors[rank - 1] : undefined;
|
||||||
width: 70,
|
return <Text strong={rank <= 3} style={color ? { color } : undefined}>{rank}</Text>;
|
||||||
render: (rank: number) => {
|
|
||||||
const medalColors = ['#d97706', '#6b7280', '#b45309'];
|
|
||||||
const color = rank <= 3 ? medalColors[rank - 1] : undefined;
|
|
||||||
return (
|
|
||||||
<Text strong={rank <= 3} style={color ? { color } : undefined}>
|
|
||||||
{rank}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
title: '患者 ID',
|
{
|
||||||
dataIndex: 'patient_id',
|
title: '患者 ID', dataIndex: 'patient_id', key: 'patient_id', width: 180,
|
||||||
key: 'patient_id',
|
render: (id: string) => (
|
||||||
width: 180,
|
<Tooltip title={id}>
|
||||||
render: (id: string) => (
|
<Text copyable={{ text: id }}>{id.length > 12 ? `${id.slice(0, 8)}...${id.slice(-4)}` : id}</Text>
|
||||||
<Tooltip title={id}>
|
</Tooltip>
|
||||||
<Text copyable={{ text: id }}>{id.length > 12 ? `${id.slice(0, 8)}...${id.slice(-4)}` : id}</Text>
|
),
|
||||||
</Tooltip>
|
},
|
||||||
),
|
{
|
||||||
},
|
title: '累计积分', dataIndex: 'total_earned', key: 'total_earned', width: 140,
|
||||||
{
|
render: (val: number) => <Text strong>{val.toLocaleString()}</Text>,
|
||||||
title: '累计积分',
|
},
|
||||||
dataIndex: 'total_earned',
|
];
|
||||||
key: 'total_earned',
|
|
||||||
width: 140,
|
|
||||||
render: (val: number) => <Text strong>{val.toLocaleString()}</Text>,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const topEarnerData: TopEarnerRow[] = (pointsStats?.top_earners ?? []).map((item, idx) => ({
|
function buildTopEarnerData(pointsStats: PointsStatistics | null): TopEarnerRow[] {
|
||||||
|
return (pointsStats?.top_earners ?? []).map((item, idx) => ({
|
||||||
rank: idx + 1,
|
rank: idx + 1,
|
||||||
patient_id: item.patient_id,
|
patient_id: item.patient_id,
|
||||||
total_earned: item.total_earned,
|
total_earned: item.total_earned,
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Loading / Error states ----
|
export default function StatisticsDashboard() {
|
||||||
if (loading) {
|
const navigate = useNavigate();
|
||||||
|
const stats = useStatsData();
|
||||||
|
const statCards = buildStatCards(stats);
|
||||||
|
const topEarnerData = buildTopEarnerData(stats.pointsStats);
|
||||||
|
|
||||||
|
if (stats.loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}>
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}>
|
||||||
<Spin size="large" tip="加载统计数据中..." />
|
<Spin size="large" tip="加载统计数据中..." />
|
||||||
@@ -215,56 +146,35 @@ export default function StatisticsDashboard() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (stats.error) {
|
||||||
return (
|
return (
|
||||||
<Alert
|
<Alert
|
||||||
type="error"
|
type="error"
|
||||||
message="加载统计数据失败"
|
message="加载统计数据失败"
|
||||||
description={error}
|
description={stats.error}
|
||||||
showIcon
|
showIcon
|
||||||
action={
|
action={<Button size="small" icon={<ReloadOutlined />} onClick={stats.refresh}>重试</Button>}
|
||||||
<Button size="small" icon={<ReloadOutlined />} onClick={fetchAllStats}>
|
|
||||||
重试
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
{/* Section 1: Top Stats Cards */}
|
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
{statCards.map((card) => (
|
{statCards.map((card) => (
|
||||||
<Col xs={24} sm={12} md={6} key={card.title}>
|
<Col xs={24} sm={12} md={6} key={card.title}>
|
||||||
<Card
|
<Card bordered={false} style={{ borderRadius: 12 }} bodyStyle={{ padding: '20px 24px' }} hoverable>
|
||||||
bordered={false}
|
|
||||||
style={{ borderRadius: 12 }}
|
|
||||||
bodyStyle={{ padding: '20px 24px' }}
|
|
||||||
hoverable
|
|
||||||
>
|
|
||||||
<Statistic
|
<Statistic
|
||||||
title={
|
title={<span style={{ fontSize: 14, color: '#64748b' }}>{card.title}</span>}
|
||||||
<span style={{ fontSize: 14, color: '#64748b' }}>{card.title}</span>
|
|
||||||
}
|
|
||||||
value={card.value}
|
value={card.value}
|
||||||
precision={card.precision}
|
precision={card.precision}
|
||||||
suffix={card.suffix}
|
suffix={card.suffix}
|
||||||
prefix={
|
prefix={
|
||||||
<span
|
<span style={{
|
||||||
style={{
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
display: 'inline-flex',
|
width: 40, height: 40, borderRadius: 10, backgroundColor: card.bgColor,
|
||||||
alignItems: 'center',
|
color: card.color, fontSize: 20, marginRight: 12,
|
||||||
justifyContent: 'center',
|
}}>
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: 10,
|
|
||||||
backgroundColor: card.bgColor,
|
|
||||||
color: card.color,
|
|
||||||
fontSize: 20,
|
|
||||||
marginRight: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{card.prefix}
|
{card.prefix}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@@ -272,8 +182,7 @@ export default function StatisticsDashboard() {
|
|||||||
/>
|
/>
|
||||||
{card.subtitle && (
|
{card.subtitle && (
|
||||||
<div style={{ marginTop: 8, fontSize: 13, color: '#94a3b8' }}>
|
<div style={{ marginTop: 8, fontSize: 13, color: '#94a3b8' }}>
|
||||||
<ArrowUpOutlined style={{ fontSize: 11, marginRight: 4 }} />
|
<ArrowUpOutlined style={{ fontSize: 11, marginRight: 4 }} />{card.subtitle}
|
||||||
{card.subtitle}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
@@ -281,235 +190,32 @@ export default function StatisticsDashboard() {
|
|||||||
))}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{/* Section 2: Points Statistics Details */}
|
|
||||||
<Card
|
<Card
|
||||||
title={
|
title={<span style={{ fontSize: 16, fontWeight: 600 }}><TrophyOutlined style={{ marginRight: 8, color: '#d97706' }} />积分统计</span>}
|
||||||
<span style={{ fontSize: 16, fontWeight: 600 }}>
|
|
||||||
<TrophyOutlined style={{ marginRight: 8, color: '#d97706' }} />
|
|
||||||
积分统计
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
bordered={false}
|
bordered={false}
|
||||||
style={{ borderRadius: 12 }}
|
style={{ borderRadius: 12 }}
|
||||||
extra={
|
extra={<Button type="text" icon={<ReloadOutlined />} onClick={stats.refresh} loading={stats.loading}>刷新</Button>}
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
icon={<ReloadOutlined />}
|
|
||||||
onClick={fetchAllStats}
|
|
||||||
loading={loading}
|
|
||||||
>
|
|
||||||
刷新
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||||
<Col xs={12} sm={6}>
|
<Col xs={12} sm={6}><Statistic title="总发放" value={stats.pointsStats?.total_issued ?? 0} valueStyle={{ color: '#059669', fontSize: 22 }} prefix={<ArrowUpOutlined />} /></Col>
|
||||||
<Statistic
|
<Col xs={12} sm={6}><Statistic title="总消费" value={stats.pointsStats?.total_spent ?? 0} valueStyle={{ color: '#dc2626', fontSize: 22 }} /></Col>
|
||||||
title="总发放"
|
<Col xs={12} sm={6}><Statistic title="总过期" value={stats.pointsStats?.total_expired ?? 0} valueStyle={{ color: '#6b7280', fontSize: 22 }} /></Col>
|
||||||
value={pointsStats?.total_issued ?? 0}
|
<Col xs={12} sm={6}><Statistic title="活跃账户" value={stats.pointsStats?.active_accounts ?? 0} valueStyle={{ color: '#2563eb', fontSize: 22 }} prefix={<TeamOutlined />} /></Col>
|
||||||
valueStyle={{ color: '#059669', fontSize: 22 }}
|
|
||||||
prefix={<ArrowUpOutlined />}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
<Col xs={12} sm={6}>
|
|
||||||
<Statistic
|
|
||||||
title="总消费"
|
|
||||||
value={pointsStats?.total_spent ?? 0}
|
|
||||||
valueStyle={{ color: '#dc2626', fontSize: 22 }}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
<Col xs={12} sm={6}>
|
|
||||||
<Statistic
|
|
||||||
title="总过期"
|
|
||||||
value={pointsStats?.total_expired ?? 0}
|
|
||||||
valueStyle={{ color: '#6b7280', fontSize: 22 }}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
<Col xs={12} sm={6}>
|
|
||||||
<Statistic
|
|
||||||
title="活跃账户"
|
|
||||||
value={pointsStats?.active_accounts ?? 0}
|
|
||||||
valueStyle={{ color: '#2563eb', fontSize: 22 }}
|
|
||||||
prefix={<TeamOutlined />}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
</Row>
|
||||||
|
<AntTitle level={5} style={{ marginBottom: 16 }}>积分排行 Top 10</AntTitle>
|
||||||
<AntTitle level={5} style={{ marginBottom: 16 }}>
|
<Table rowKey="rank" columns={topEarnerColumns} dataSource={topEarnerData} pagination={false} size="small" locale={{ emptyText: '暂无数据' }} style={{ marginTop: 8 }} />
|
||||||
积分排行 Top 10
|
|
||||||
</AntTitle>
|
|
||||||
<Table
|
|
||||||
rowKey="rank"
|
|
||||||
columns={topEarnerColumns}
|
|
||||||
dataSource={topEarnerData}
|
|
||||||
pagination={false}
|
|
||||||
size="small"
|
|
||||||
locale={{ emptyText: '暂无数据' }}
|
|
||||||
style={{ marginTop: 8 }}
|
|
||||||
/>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Section 2.5: Health Data Statistics */}
|
|
||||||
<Card
|
<Card
|
||||||
title={
|
title={<span style={{ fontSize: 16, fontWeight: 600 }}><MedicineBoxOutlined style={{ marginRight: 8, color: '#2563eb' }} />健康数据中心</span>}
|
||||||
<span style={{ fontSize: 16, fontWeight: 600 }}>
|
|
||||||
<MedicineBoxOutlined style={{ marginRight: 8, color: '#2563eb' }} />
|
|
||||||
健康数据中心
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
bordered={false}
|
bordered={false}
|
||||||
style={{ borderRadius: 12 }}
|
style={{ borderRadius: 12 }}
|
||||||
>
|
>
|
||||||
<Row gutter={[16, 16]}>
|
<HealthDataCenter data={stats.healthDataStats} />
|
||||||
{/* 透析统计 */}
|
|
||||||
<Col xs={24} md={12}>
|
|
||||||
<Card
|
|
||||||
type="inner"
|
|
||||||
title={<span style={{ fontSize: 14, fontWeight: 600 }}>透析记录</span>}
|
|
||||||
style={{ borderRadius: 8 }}
|
|
||||||
>
|
|
||||||
<Row gutter={[12, 12]}>
|
|
||||||
<Col span={8}>
|
|
||||||
<Statistic title="总记录" value={healthDataStats?.dialysis.total_records ?? 0} valueStyle={{ fontSize: 20 }} />
|
|
||||||
</Col>
|
|
||||||
<Col span={8}>
|
|
||||||
<Statistic title="本月新增" value={healthDataStats?.dialysis.this_month ?? 0} valueStyle={{ fontSize: 20, color: '#2563eb' }} />
|
|
||||||
</Col>
|
|
||||||
<Col span={8}>
|
|
||||||
<Statistic title="待审核" value={healthDataStats?.dialysis.pending_review ?? 0} valueStyle={{ fontSize: 20, color: '#d97706' }} />
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
<Row gutter={[12, 12]} style={{ marginTop: 12 }}>
|
|
||||||
<Col span={8}>
|
|
||||||
<Statistic title="并发症率" value={healthDataStats?.dialysis.complication_rate ?? 0} suffix="%" precision={1} valueStyle={{ fontSize: 18 }} />
|
|
||||||
</Col>
|
|
||||||
<Col span={8}>
|
|
||||||
<Statistic title="平均超滤(ml)" value={healthDataStats?.dialysis.avg_ultrafiltration ?? 0} precision={0} valueStyle={{ fontSize: 18 }} />
|
|
||||||
</Col>
|
|
||||||
<Col span={8}>
|
|
||||||
<Statistic title="平均时长(分)" value={healthDataStats?.dialysis.avg_duration ?? 0} precision={0} valueStyle={{ fontSize: 18 }} />
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
{(healthDataStats?.dialysis.type_distribution ?? []).length > 0 && (
|
|
||||||
<div style={{ marginTop: 12 }}>
|
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>类型分布: </Text>
|
|
||||||
{healthDataStats!.dialysis.type_distribution.map((item) => (
|
|
||||||
<Tag key={item.name} color="blue" style={{ marginTop: 4 }}>{item.name}: {item.value}</Tag>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
{/* 化验报告 */}
|
|
||||||
<Col xs={24} md={12}>
|
|
||||||
<Card
|
|
||||||
type="inner"
|
|
||||||
title={<span style={{ fontSize: 14, fontWeight: 600 }}>化验报告</span>}
|
|
||||||
style={{ borderRadius: 8 }}
|
|
||||||
>
|
|
||||||
<Row gutter={[12, 12]}>
|
|
||||||
<Col span={8}>
|
|
||||||
<Statistic title="总报告" value={healthDataStats?.lab_reports.total_reports ?? 0} valueStyle={{ fontSize: 20 }} />
|
|
||||||
</Col>
|
|
||||||
<Col span={8}>
|
|
||||||
<Statistic title="本月新增" value={healthDataStats?.lab_reports.this_month ?? 0} valueStyle={{ fontSize: 20, color: '#2563eb' }} />
|
|
||||||
</Col>
|
|
||||||
<Col span={8}>
|
|
||||||
<Statistic title="异常项" value={healthDataStats?.lab_reports.abnormal_items ?? 0} valueStyle={{ fontSize: 20, color: '#dc2626' }} />
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
<Row gutter={[12, 12]} style={{ marginTop: 12 }}>
|
|
||||||
<Col span={12}>
|
|
||||||
<Statistic title="待审核" value={healthDataStats?.lab_reports.pending_review ?? 0} valueStyle={{ fontSize: 18, color: '#d97706' }} />
|
|
||||||
</Col>
|
|
||||||
<Col span={12}>
|
|
||||||
<Statistic title="已审核" value={healthDataStats?.lab_reports.reviewed ?? 0} valueStyle={{ fontSize: 18, color: '#059669' }} />
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
{(healthDataStats?.lab_reports.type_distribution ?? []).length > 0 && (
|
|
||||||
<div style={{ marginTop: 12 }}>
|
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>类型分布: </Text>
|
|
||||||
{healthDataStats!.lab_reports.type_distribution.map((item) => (
|
|
||||||
<Tag key={item.name} color="green" style={{ marginTop: 4 }}>{item.name}: {item.value}</Tag>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
{/* 预约统计 */}
|
|
||||||
<Col xs={24} md={12}>
|
|
||||||
<Card
|
|
||||||
type="inner"
|
|
||||||
title={<span style={{ fontSize: 14, fontWeight: 600 }}>预约统计</span>}
|
|
||||||
style={{ borderRadius: 8 }}
|
|
||||||
>
|
|
||||||
<Row gutter={[12, 12]}>
|
|
||||||
<Col span={8}>
|
|
||||||
<Statistic title="总预约" value={healthDataStats?.appointments.total_appointments ?? 0} valueStyle={{ fontSize: 20 }} />
|
|
||||||
</Col>
|
|
||||||
<Col span={8}>
|
|
||||||
<Statistic title="本月" value={healthDataStats?.appointments.this_month ?? 0} valueStyle={{ fontSize: 20, color: '#2563eb' }} />
|
|
||||||
</Col>
|
|
||||||
<Col span={8}>
|
|
||||||
<Statistic title="取消率" value={healthDataStats?.appointments.cancel_rate ?? 0} suffix="%" precision={1} valueStyle={{ fontSize: 20, color: '#dc2626' }} />
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
{(healthDataStats?.appointments.status_distribution ?? []).length > 0 && (
|
|
||||||
<div style={{ marginTop: 12 }}>
|
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>状态: </Text>
|
|
||||||
{healthDataStats!.appointments.status_distribution.map((item) => (
|
|
||||||
<Tag key={item.name} color="purple" style={{ marginTop: 4 }}>{item.name}: {item.value}</Tag>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
{/* 体征上报率 */}
|
|
||||||
<Col xs={24} md={12}>
|
|
||||||
<Card
|
|
||||||
type="inner"
|
|
||||||
title={<span style={{ fontSize: 14, fontWeight: 600 }}>体征上报率</span>}
|
|
||||||
style={{ borderRadius: 8 }}
|
|
||||||
>
|
|
||||||
<Row gutter={[12, 12]}>
|
|
||||||
<Col span={8}>
|
|
||||||
<Statistic title="总患者" value={healthDataStats?.vital_signs_report_rate.total_patients ?? 0} valueStyle={{ fontSize: 20 }} />
|
|
||||||
</Col>
|
|
||||||
<Col span={8}>
|
|
||||||
<Statistic title="本月上报" value={healthDataStats?.vital_signs_report_rate.reported_patients ?? 0} valueStyle={{ fontSize: 20, color: '#059669' }} />
|
|
||||||
</Col>
|
|
||||||
<Col span={8}>
|
|
||||||
<Statistic title="上报率" value={healthDataStats?.vital_signs_report_rate.report_rate ?? 0} suffix="%" precision={1} valueStyle={{ fontSize: 20, color: '#7c3aed' }} />
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
{(healthDataStats?.vital_signs_report_rate.daily_trend ?? []).length > 0 && (
|
|
||||||
<div style={{ marginTop: 12 }}>
|
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>近 7 天: </Text>
|
|
||||||
<div style={{ display: 'flex', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
|
|
||||||
{healthDataStats!.vital_signs_report_rate.daily_trend.map((d) => (
|
|
||||||
<Tag key={d.date} color={d.rate >= 50 ? 'green' : d.rate >= 20 ? 'orange' : 'red'}>
|
|
||||||
{d.date.slice(5)} {d.reported}/{d.total}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Section 3: Quick Links */}
|
|
||||||
<Card
|
<Card
|
||||||
title={
|
title={<span style={{ fontSize: 16, fontWeight: 600 }}><MedicineBoxOutlined style={{ marginRight: 8, color: '#2563eb' }} />快捷入口</span>}
|
||||||
<span style={{ fontSize: 16, fontWeight: 600 }}>
|
|
||||||
<MedicineBoxOutlined style={{ marginRight: 8, color: '#2563eb' }} />
|
|
||||||
快捷入口
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
bordered={false}
|
bordered={false}
|
||||||
style={{ borderRadius: 12 }}
|
style={{ borderRadius: 12 }}
|
||||||
>
|
>
|
||||||
@@ -517,62 +223,32 @@ export default function StatisticsDashboard() {
|
|||||||
{QUICK_LINKS.map((link) => (
|
{QUICK_LINKS.map((link) => (
|
||||||
<Col xs={12} sm={8} md={6} key={link.path}>
|
<Col xs={12} sm={8} md={6} key={link.path}>
|
||||||
<Card
|
<Card
|
||||||
hoverable
|
hoverable bordered={false}
|
||||||
bordered={false}
|
style={{ borderRadius: 10, cursor: 'pointer', textAlign: 'center', transition: 'transform 0.2s, box-shadow 0.2s' }}
|
||||||
style={{
|
|
||||||
borderRadius: 10,
|
|
||||||
cursor: 'pointer',
|
|
||||||
textAlign: 'center',
|
|
||||||
transition: 'transform 0.2s, box-shadow 0.2s',
|
|
||||||
}}
|
|
||||||
bodyStyle={{ padding: '20px 12px' }}
|
bodyStyle={{ padding: '20px 12px' }}
|
||||||
onClick={() => navigate(link.path)}
|
onClick={() => navigate(link.path)}
|
||||||
>
|
>
|
||||||
<div
|
<div style={{
|
||||||
style={{
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
display: 'inline-flex',
|
width: 48, height: 48, borderRadius: 12, backgroundColor: `${link.color}15`,
|
||||||
alignItems: 'center',
|
color: link.color, fontSize: 24, marginBottom: 10,
|
||||||
justifyContent: 'center',
|
}}>
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
borderRadius: 12,
|
|
||||||
backgroundColor: `${link.color}15`,
|
|
||||||
color: link.color,
|
|
||||||
fontSize: 24,
|
|
||||||
marginBottom: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{link.icon}
|
{link.icon}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 14, fontWeight: 500, color: '#334155' }}>
|
<div style={{ fontSize: 14, fontWeight: 500, color: '#334155' }}>{link.title}</div>
|
||||||
{link.title}
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
))}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Section 4: Recent Activity (top earners as proxy) */}
|
|
||||||
{topEarnerData.length > 0 && (
|
{topEarnerData.length > 0 && (
|
||||||
<Card
|
<Card
|
||||||
title={
|
title={<span style={{ fontSize: 16, fontWeight: 600 }}><ClockCircleOutlined style={{ marginRight: 8, color: '#7c3aed' }} />最近活动</span>}
|
||||||
<span style={{ fontSize: 16, fontWeight: 600 }}>
|
|
||||||
<ClockCircleOutlined style={{ marginRight: 8, color: '#7c3aed' }} />
|
|
||||||
最近活动
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
bordered={false}
|
bordered={false}
|
||||||
style={{ borderRadius: 12 }}
|
style={{ borderRadius: 12 }}
|
||||||
>
|
>
|
||||||
<Table
|
<Table rowKey="rank" columns={topEarnerColumns} dataSource={topEarnerData} pagination={{ pageSize: 5, size: 'small' }} size="small" locale={{ emptyText: '暂无活动记录' }} />
|
||||||
rowKey="rank"
|
|
||||||
columns={topEarnerColumns}
|
|
||||||
dataSource={topEarnerData}
|
|
||||||
pagination={{ pageSize: 5, size: 'small' }}
|
|
||||||
size="small"
|
|
||||||
locale={{ emptyText: '暂无活动记录' }}
|
|
||||||
/>
|
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { Row, Col, Card, Statistic, Tag, Typography } from 'antd';
|
||||||
|
import type { HealthDataStats } from '../../../api/health/points';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
interface HealthDataCenterProps {
|
||||||
|
data: HealthDataStats | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HealthDataCenter({ data }: HealthDataCenterProps) {
|
||||||
|
return (
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Card type="inner" title={<span style={{ fontSize: 14, fontWeight: 600 }}>透析记录</span>} style={{ borderRadius: 8 }}>
|
||||||
|
<Row gutter={[12, 12]}>
|
||||||
|
<Col span={8}><Statistic title="总记录" value={data?.dialysis.total_records ?? 0} valueStyle={{ fontSize: 20 }} /></Col>
|
||||||
|
<Col span={8}><Statistic title="本月新增" value={data?.dialysis.this_month ?? 0} valueStyle={{ fontSize: 20, color: '#2563eb' }} /></Col>
|
||||||
|
<Col span={8}><Statistic title="待审核" value={data?.dialysis.pending_review ?? 0} valueStyle={{ fontSize: 20, color: '#d97706' }} /></Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={[12, 12]} style={{ marginTop: 12 }}>
|
||||||
|
<Col span={8}><Statistic title="并发症率" value={data?.dialysis.complication_rate ?? 0} suffix="%" precision={1} valueStyle={{ fontSize: 18 }} /></Col>
|
||||||
|
<Col span={8}><Statistic title="平均超滤(ml)" value={data?.dialysis.avg_ultrafiltration ?? 0} precision={0} valueStyle={{ fontSize: 18 }} /></Col>
|
||||||
|
<Col span={8}><Statistic title="平均时长(分)" value={data?.dialysis.avg_duration ?? 0} precision={0} valueStyle={{ fontSize: 18 }} /></Col>
|
||||||
|
</Row>
|
||||||
|
{(data?.dialysis.type_distribution ?? []).length > 0 && (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>类型分布: </Text>
|
||||||
|
{data!.dialysis.type_distribution.map((item) => (
|
||||||
|
<Tag key={item.name} color="blue" style={{ marginTop: 4 }}>{item.name}: {item.value}</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Card type="inner" title={<span style={{ fontSize: 14, fontWeight: 600 }}>化验报告</span>} style={{ borderRadius: 8 }}>
|
||||||
|
<Row gutter={[12, 12]}>
|
||||||
|
<Col span={8}><Statistic title="总报告" value={data?.lab_reports.total_reports ?? 0} valueStyle={{ fontSize: 20 }} /></Col>
|
||||||
|
<Col span={8}><Statistic title="本月新增" value={data?.lab_reports.this_month ?? 0} valueStyle={{ fontSize: 20, color: '#2563eb' }} /></Col>
|
||||||
|
<Col span={8}><Statistic title="异常项" value={data?.lab_reports.abnormal_items ?? 0} valueStyle={{ fontSize: 20, color: '#dc2626' }} /></Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={[12, 12]} style={{ marginTop: 12 }}>
|
||||||
|
<Col span={12}><Statistic title="待审核" value={data?.lab_reports.pending_review ?? 0} valueStyle={{ fontSize: 18, color: '#d97706' }} /></Col>
|
||||||
|
<Col span={12}><Statistic title="已审核" value={data?.lab_reports.reviewed ?? 0} valueStyle={{ fontSize: 18, color: '#059669' }} /></Col>
|
||||||
|
</Row>
|
||||||
|
{(data?.lab_reports.type_distribution ?? []).length > 0 && (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>类型分布: </Text>
|
||||||
|
{data!.lab_reports.type_distribution.map((item) => (
|
||||||
|
<Tag key={item.name} color="green" style={{ marginTop: 4 }}>{item.name}: {item.value}</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Card type="inner" title={<span style={{ fontSize: 14, fontWeight: 600 }}>预约统计</span>} style={{ borderRadius: 8 }}>
|
||||||
|
<Row gutter={[12, 12]}>
|
||||||
|
<Col span={8}><Statistic title="总预约" value={data?.appointments.total_appointments ?? 0} valueStyle={{ fontSize: 20 }} /></Col>
|
||||||
|
<Col span={8}><Statistic title="本月" value={data?.appointments.this_month ?? 0} valueStyle={{ fontSize: 20, color: '#2563eb' }} /></Col>
|
||||||
|
<Col span={8}><Statistic title="取消率" value={data?.appointments.cancel_rate ?? 0} suffix="%" precision={1} valueStyle={{ fontSize: 20, color: '#dc2626' }} /></Col>
|
||||||
|
</Row>
|
||||||
|
{(data?.appointments.status_distribution ?? []).length > 0 && (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>状态: </Text>
|
||||||
|
{data!.appointments.status_distribution.map((item) => (
|
||||||
|
<Tag key={item.name} color="purple" style={{ marginTop: 4 }}>{item.name}: {item.value}</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Card type="inner" title={<span style={{ fontSize: 14, fontWeight: 600 }}>体征上报率</span>} style={{ borderRadius: 8 }}>
|
||||||
|
<Row gutter={[12, 12]}>
|
||||||
|
<Col span={8}><Statistic title="总患者" value={data?.vital_signs_report_rate.total_patients ?? 0} valueStyle={{ fontSize: 20 }} /></Col>
|
||||||
|
<Col span={8}><Statistic title="本月上报" value={data?.vital_signs_report_rate.reported_patients ?? 0} valueStyle={{ fontSize: 20, color: '#059669' }} /></Col>
|
||||||
|
<Col span={8}><Statistic title="上报率" value={data?.vital_signs_report_rate.report_rate ?? 0} suffix="%" precision={1} valueStyle={{ fontSize: 20, color: '#7c3aed' }} /></Col>
|
||||||
|
</Row>
|
||||||
|
{(data?.vital_signs_report_rate.daily_trend ?? []).length > 0 && (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>近 7 天: </Text>
|
||||||
|
<div style={{ display: 'flex', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
|
||||||
|
{data!.vital_signs_report_rate.daily_trend.map((d) => (
|
||||||
|
<Tag key={d.date} color={d.rate >= 50 ? 'green' : d.rate >= 20 ? 'orange' : 'red'}>
|
||||||
|
{d.date.slice(5)} {d.reported}/{d.total}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
pointsApi,
|
||||||
|
type PatientStatistics,
|
||||||
|
type ConsultationStatistics,
|
||||||
|
type FollowUpStatistics,
|
||||||
|
type PointsStatistics,
|
||||||
|
type HealthDataStats,
|
||||||
|
} from '../../../api/health/points';
|
||||||
|
|
||||||
|
export interface StatsData {
|
||||||
|
patientStats: PatientStatistics | null;
|
||||||
|
consultationStats: ConsultationStatistics | null;
|
||||||
|
followUpStats: FollowUpStatistics | null;
|
||||||
|
pointsStats: PointsStatistics | null;
|
||||||
|
healthDataStats: HealthDataStats | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
refresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStatsData(): StatsData {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [patientStats, setPatientStats] = useState<PatientStatistics | null>(null);
|
||||||
|
const [consultationStats, setConsultationStats] = useState<ConsultationStatistics | null>(null);
|
||||||
|
const [followUpStats, setFollowUpStats] = useState<FollowUpStatistics | null>(null);
|
||||||
|
const [pointsStats, setPointsStats] = useState<PointsStatistics | null>(null);
|
||||||
|
const [healthDataStats, setHealthDataStats] = useState<HealthDataStats | null>(null);
|
||||||
|
|
||||||
|
const fetchAllStats = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
let hasAnyError = false;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
const tryFetch = async <T,>(fn: () => Promise<T>, setter: (v: T) => void, label: string) => {
|
||||||
|
try {
|
||||||
|
const data = await fn();
|
||||||
|
setter(data);
|
||||||
|
} catch {
|
||||||
|
hasAnyError = true;
|
||||||
|
errors.push(label);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
tryFetch(pointsApi.getPatientStats, setPatientStats, '患者'),
|
||||||
|
tryFetch(pointsApi.getConsultationStats, setConsultationStats, '咨询'),
|
||||||
|
tryFetch(pointsApi.getFollowUpStats, setFollowUpStats, '随访'),
|
||||||
|
tryFetch(pointsApi.getStatistics, setPointsStats, '积分'),
|
||||||
|
tryFetch(pointsApi.getHealthDataStats, setHealthDataStats, '健康数据'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (hasAnyError && errors.length === 5) {
|
||||||
|
setError('加载统计数据失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAllStats();
|
||||||
|
}, [fetchAllStats]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
patientStats, consultationStats, followUpStats, pointsStats, healthDataStats,
|
||||||
|
loading, error, refresh: fetchAllStats,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ interface HealthState {
|
|||||||
resolveDoctorName: (id: string) => Promise<string>;
|
resolveDoctorName: (id: string) => Promise<string>;
|
||||||
getPatientName: (id: string) => string;
|
getPatientName: (id: string) => string;
|
||||||
getDoctorName: (id: string) => string;
|
getDoctorName: (id: string) => string;
|
||||||
|
batchResolvePatientNames: (ids: string[]) => Promise<void>;
|
||||||
|
batchResolveDoctorNames: (ids: string[]) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useHealthStore = create<HealthState>((set, get) => ({
|
export const useHealthStore = create<HealthState>((set, get) => ({
|
||||||
@@ -72,4 +74,50 @@ export const useHealthStore = create<HealthState>((set, get) => ({
|
|||||||
|
|
||||||
getPatientName: (id: string) => get().patientNames[id] || id.slice(0, 8),
|
getPatientName: (id: string) => get().patientNames[id] || id.slice(0, 8),
|
||||||
getDoctorName: (id: string) => get().doctorNames[id] || id.slice(0, 8),
|
getDoctorName: (id: string) => get().doctorNames[id] || id.slice(0, 8),
|
||||||
|
|
||||||
|
batchResolvePatientNames: async (ids: string[]) => {
|
||||||
|
const { patientNames } = get();
|
||||||
|
const uniqueIds = [...new Set(ids)];
|
||||||
|
const missing = uniqueIds.filter((id) => !patientNames[id]);
|
||||||
|
if (missing.length === 0) return;
|
||||||
|
|
||||||
|
const limit = 5;
|
||||||
|
for (let i = 0; i < missing.length; i += limit) {
|
||||||
|
const batch = missing.slice(i, i + limit);
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
batch.map(async (id) => {
|
||||||
|
const detail = await patientApi.get(id);
|
||||||
|
return { id, name: detail.name };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const newEntries: Record<string, string> = {};
|
||||||
|
results.forEach((r, idx) => {
|
||||||
|
newEntries[batch[idx]] = r.status === 'fulfilled' ? r.value.name : batch[idx].slice(0, 8);
|
||||||
|
});
|
||||||
|
set((s) => ({ patientNames: { ...s.patientNames, ...newEntries } }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
batchResolveDoctorNames: async (ids: string[]) => {
|
||||||
|
const { doctorNames } = get();
|
||||||
|
const uniqueIds = [...new Set(ids)];
|
||||||
|
const missing = uniqueIds.filter((id) => !doctorNames[id]);
|
||||||
|
if (missing.length === 0) return;
|
||||||
|
|
||||||
|
const limit = 5;
|
||||||
|
for (let i = 0; i < missing.length; i += limit) {
|
||||||
|
const batch = missing.slice(i, i + limit);
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
batch.map(async (id) => {
|
||||||
|
const detail = await doctorApi.get(id);
|
||||||
|
return { id, name: detail.name };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const newEntries: Record<string, string> = {};
|
||||||
|
results.forEach((r, idx) => {
|
||||||
|
newEntries[batch[idx]] = r.status === 'fulfilled' ? r.value.name : batch[idx].slice(0, 8);
|
||||||
|
});
|
||||||
|
set((s) => ({ doctorNames: { ...s.doctorNames, ...newEntries } }));
|
||||||
|
}
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user