refactor(web): 前端工程化 — 组件拆分 + 名称缓存统一
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- 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:
iven
2026-04-27 20:56:27 +08:00
parent fdceed7284
commit 41af241238
13 changed files with 1624 additions and 1841 deletions

View File

@@ -1,871 +1 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import {
Table,
Button,
Space,
Modal,
Form,
Input,
InputNumber,
DatePicker,
Switch,
Select,
Tag,
message,
Popconfirm,
Drawer,
Descriptions,
Segmented,
Timeline,
Upload,
Alert,
Dropdown,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
ReloadOutlined,
EyeOutlined,
DownloadOutlined,
UploadOutlined,
} from '@ant-design/icons';
import {
listPluginData,
createPluginData,
updatePluginData,
deletePluginData,
batchPluginData,
resolveRefLabels,
exportPluginData,
exportPluginDataAsBlob,
importPluginData,
type PluginDataListOptions,
type ImportResult,
} from '../api/pluginData';
import EntitySelect from '../components/EntitySelect';
import {
getPluginSchema,
type PluginFieldSchema,
type PluginEntitySchema,
type PluginPageSchema,
type PluginSectionSchema,
} from '../api/plugins';
import { evaluateVisibleWhen } from '../utils/exprEvaluator';
const { Search } = Input;
const { TextArea } = Input;
interface PluginCRUDPageProps {
/** 如果从 tabs/detail 页面内嵌使用,通过 props 传入配置 */
pluginIdOverride?: string;
entityOverride?: string;
filterField?: string;
filterValue?: string;
enableViews?: string[];
/** detail 页面内嵌时使用 compact 模式 */
compact?: boolean;
}
export default function PluginCRUDPage({
pluginIdOverride,
entityOverride,
filterField,
filterValue,
enableViews: enableViewsProp,
compact,
}: PluginCRUDPageProps = {}) {
const routeParams = useParams<{ pluginId: string; entityName: string }>();
const pluginId = pluginIdOverride || routeParams.pluginId || '';
const entityName = entityOverride || routeParams.entityName || '';
const [records, setRecords] = useState<Record<string, unknown>[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [fields, setFields] = useState<PluginFieldSchema[]>([]);
const [displayName, setDisplayName] = useState(entityName || '');
const [modalOpen, setModalOpen] = useState(false);
const [editRecord, setEditRecord] = useState<Record<string, unknown> | null>(null);
const [form] = Form.useForm();
const [formValues, setFormValues] = useState<Record<string, unknown>>({});
// 筛选/搜索/排序 state
const [searchText, setSearchText] = useState('');
const [filters, setFilters] = useState<Record<string, string>>({});
const [sortBy, setSortBy] = useState<string | undefined>();
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
// 视图切换
const [viewMode, setViewMode] = useState<string>('table');
// 批量选择
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
// 跨插件引用标签解析
const [resolvedLabels, setResolvedLabels] = useState<Record<string, Record<string, string | null>>>({});
const [labelMeta, setLabelMeta] = useState<Record<string, { plugin_installed: boolean }>>({});
// 详情 Drawer
const [detailOpen, setDetailOpen] = useState(false);
const [detailRecord, setDetailRecord] = useState<Record<string, unknown> | null>(null);
const [detailSections, setDetailSections] = useState<PluginSectionSchema[]>([]);
const [allEntities, setAllEntities] = useState<PluginEntitySchema[]>([]);
const [allPages, setAllPages] = useState<PluginPageSchema[]>([]);
// 导入导出
const [entityDef, setEntityDef] = useState<PluginEntitySchema | null>(null);
const [importModalOpen, setImportModalOpen] = useState(false);
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState<ImportResult | null>(null);
const [exporting, setExporting] = useState(false);
// 从 fields 中提取 filterable 字段
const filterableFields = fields.filter((f) => f.filterable);
// 查找是否有 detail 页面
const hasDetailPage = allPages.some(
(p) => p.type === 'detail' && 'entity' in p && p.entity === entityName,
);
// 可用视图
const enableViews = enableViewsProp ||
(() => {
const page = allPages.find(
(p) => p.type === 'crud' && 'entity' in p && p.entity === entityName,
);
return (page as { enable_views?: string[] })?.enable_views || ['table'];
})();
// 加载 schema
useEffect(() => {
if (!pluginId) return;
const abortController = new AbortController();
async function loadSchema() {
try {
const schema = await getPluginSchema(pluginId!);
if (abortController.signal.aborted) return;
const entities: PluginEntitySchema[] = (schema as { entities?: PluginEntitySchema[] }).entities || [];
setAllEntities(entities);
const entity = entities.find((e) => e.name === entityName);
if (entity) {
setFields(entity.fields);
setDisplayName(entity.display_name || entityName || '');
setEntityDef(entity);
}
const ui = (schema as { ui?: { pages: PluginPageSchema[] } }).ui;
if (ui?.pages) {
setAllPages(ui.pages);
const detailPage = ui.pages.find(
(p) => p.type === 'detail' && 'entity' in p && p.entity === entityName,
);
if (detailPage && 'sections' in detailPage) {
setDetailSections(detailPage.sections);
}
}
} catch {
message.warning('Schema 加载失败,部分功能不可用');
}
}
loadSchema();
return () => abortController.abort();
}, [pluginId, entityName]);
const fetchData = useCallback(
async (p = page, overrides?: { search?: string; sort_by?: string; sort_order?: 'asc' | 'desc' }) => {
if (!pluginId || !entityName) return;
setLoading(true);
try {
const options: PluginDataListOptions = {};
const mergedFilters = { ...filters };
if (filterField && filterValue) {
mergedFilters[filterField] = filterValue;
}
if (Object.keys(mergedFilters).length > 0) {
options.filter = mergedFilters;
}
const effectiveSearch = overrides?.search ?? searchText;
if (effectiveSearch) options.search = effectiveSearch;
const effectiveSortBy = overrides?.sort_by ?? sortBy;
const effectiveSortOrder = overrides?.sort_order ?? sortOrder;
if (effectiveSortBy) {
options.sort_by = effectiveSortBy;
options.sort_order = effectiveSortOrder;
}
const result = await listPluginData(pluginId, entityName, p, 20, options);
setRecords(
result.data.map((r) => ({ ...r.data, _id: r.id, _version: r.version })),
);
setTotal(result.total);
} catch {
message.error('加载数据失败');
}
setLoading(false);
},
[pluginId, entityName, page, filters, searchText, sortBy, sortOrder, filterField, filterValue],
);
useEffect(() => {
fetchData();
}, [fetchData]);
// 数据加载后解析跨插件引用标签
useEffect(() => {
if (!pluginId || !entityName || !records.length || !fields.length) return;
const refFields = fields.filter((f) => f.ref_entity);
if (!refFields.length) return;
const fieldUuids: Record<string, string[]> = {};
for (const f of refFields) {
const uuids = [...new Set(
records.map((r) => r[f.name]).filter(Boolean).map(String),
)];
if (uuids.length) fieldUuids[f.name] = uuids;
}
if (!Object.keys(fieldUuids).length) return;
resolveRefLabels(pluginId, entityName, fieldUuids)
.then((result) => {
setResolvedLabels(result.labels);
setLabelMeta(result.meta as Record<string, { plugin_installed: boolean }>);
})
.catch(() => {});
}, [records, fields, pluginId, entityName]);
// 筛选变化
const handleFilterChange = (fieldName: string, value: string | undefined) => {
const newFilters = { ...filters };
if (value) {
newFilters[fieldName] = value;
} else {
delete newFilters[fieldName];
}
setFilters(newFilters);
setPage(1);
// 直接触发重新查询
fetchData(1);
};
const handleSubmit = async (values: Record<string, unknown>) => {
if (!pluginId || !entityName) return;
const { _id, _version, ...data } = values as Record<string, unknown> & {
_id?: string;
_version?: number;
};
try {
if (editRecord) {
await updatePluginData(
pluginId,
entityName,
editRecord._id as string,
data,
editRecord._version as number,
);
message.success('更新成功');
} else {
await createPluginData(pluginId, entityName, data);
message.success('创建成功');
}
setModalOpen(false);
setEditRecord(null);
fetchData();
} catch {
message.error('操作失败');
}
};
const handleDelete = async (record: Record<string, unknown>) => {
if (!pluginId || !entityName) return;
try {
await deletePluginData(pluginId, entityName, record._id as string);
message.success('删除成功');
fetchData();
} catch {
message.error('删除失败');
}
};
const handleBatchDelete = async () => {
if (!pluginId || !entityName || selectedRowKeys.length === 0) return;
try {
await batchPluginData(pluginId, entityName, {
action: 'delete',
ids: selectedRowKeys,
});
message.success(`已删除 ${selectedRowKeys.length} 条记录`);
setSelectedRowKeys([]);
fetchData();
} catch {
message.error('批量删除失败');
}
};
// 动态生成列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>
);
}
export { default } from './PluginCRUDPage/PluginCRUDPageInner';

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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,
};
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useRef, useCallback } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import {
Card,
@@ -11,7 +11,6 @@ import {
Col,
Tag,
Tooltip,
message,
theme,
Typography,
Divider,
@@ -26,56 +25,24 @@ import {
InfoCircleOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { listPluginData } from '../api/pluginData';
import {
getPluginSchema,
type PluginFieldSchema,
type PluginSchemaResponse,
} 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';
import type { GraphNode } from './graph/graphTypes';
import { getNodeDegree } from './graph/graphRenderer';
import { getRelColor, getEdgeTypeLabel } from './graph/graphRenderer';
import { useGraphData } from './PluginGraphPage/useGraphData';
import { useGraphCanvas } from './PluginGraphPage/useGraphCanvas';
const { Text } = Typography;
/**
* 插件关系图谱页面 — 通过路由参数自加载 schema
* 路由: /plugins/:pluginId/graph/:entityName
*/
export function PluginGraphPage() {
const { pluginId, entityName } = useParams<{ pluginId: string; entityName: string }>();
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 [relationships, setRelationships] = useState<GraphEdge[]>([]);
const [loading, setLoading] = useState(false);
const { customers, relationships, loading, fields, relTypes } = useGraphData(pluginId, entityName);
const [selectedCenter, setSelectedCenter] = useState<string | null>(null);
const [relTypes, setRelTypes] = useState<string[]>([]);
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 });
// ── Computed stats ──
const filteredRels = relFilter
? relationships.filter((r) => r.label === relFilter)
: relationships;
@@ -87,10 +54,7 @@ export function PluginGraphPage() {
const visibleNodeIds = new Set<string>();
if (selectedCenter) {
visibleNodeIds.add(selectedCenter);
for (const e of visibleEdges) {
visibleNodeIds.add(e.source);
visibleNodeIds.add(e.target);
}
for (const e of visibleEdges) { visibleNodeIds.add(e.source); visibleNodeIds.add(e.target); }
}
const visibleNodes = selectedCenter
? customers.filter((n) => visibleNodeIds.has(n.id))
@@ -99,375 +63,40 @@ export function PluginGraphPage() {
const centerNode = customers.find((c) => c.id === selectedCenter);
const centerDegree = selectedCenter ? getNodeDegree(selectedCenter, visibleEdges) : 0;
// ── Schema loading ──
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]);
// ── 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 ──
const {
canvasRef, containerRef, hoverState,
handleCanvasMouseMove, handleCanvasMouseLeave, handleCanvasClick,
} = useGraphCanvas({
token,
canvasSize,
selectedCenter,
visibleNodes,
visibleEdges,
});
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width } = entry.contentRect;
if (width > 0) {
setCanvasSize({ width, height: Math.max(500, Math.min(700, width * 0.65)) });
}
if (width > 0) setCanvasSize({ width, height: Math.max(500, Math.min(700, width * 0.65)) });
}
});
observer.observe(container);
return () => observer.disconnect();
}, []);
// ── Update refs for animation loop ──
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(
const onCanvasClick = 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) => {
if (prev.nodeId === foundId) return prev;
return { nodeId: foundId, x, y };
});
if (foundId) {
setHoverState({ nodeId: foundId, x, y });
const result = handleCanvasClick(e);
if (result.clicked) {
setSelectedCenter((prev) => (prev === result.clicked ? null : result.clicked));
}
},
[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) => ({
label: getEdgeTypeLabel(type),
rawLabel: type,
@@ -475,8 +104,6 @@ export function PluginGraphPage() {
count: relationships.filter((r) => r.label === type).length,
}));
// ── Render ──
if (loading) {
return (
<div style={{ padding: 24, textAlign: 'center' }}>
@@ -487,87 +114,43 @@ export function PluginGraphPage() {
return (
<div style={{ padding: 24 }}>
{/* Stats Row */}
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={8}>
<Card
size="small"
style={{ borderLeft: `3px solid ${token.colorPrimary}` }}
>
<Card size="small" style={{ borderLeft: `3px solid ${token.colorPrimary}` }}>
<Statistic
title={
<Text type="secondary" style={{ fontSize: 12 }}>
<TeamOutlined style={{ marginRight: 4 }} />
</Text>
}
title={<Text type="secondary" style={{ fontSize: 12 }}><TeamOutlined style={{ marginRight: 4 }} /></Text>}
value={customers.length}
styles={{ content: { color: token.colorPrimary, fontWeight: 600 } }}
/>
</Card>
</Col>
<Col xs={24} sm={8}>
<Card
size="small"
style={{ borderLeft: `3px solid ${token.colorSuccess}` }}
>
<Card size="small" style={{ borderLeft: `3px solid ${token.colorSuccess}` }}>
<Statistic
title={
<Text type="secondary" style={{ fontSize: 12 }}>
<NodeIndexOutlined style={{ marginRight: 4 }} />
</Text>
}
title={<Text type="secondary" style={{ fontSize: 12 }}><NodeIndexOutlined style={{ marginRight: 4 }} /></Text>}
value={relationships.length}
styles={{ content: { color: token.colorSuccess, fontWeight: 600 } }}
/>
</Card>
</Col>
<Col xs={24} sm={8}>
<Card
size="small"
style={{ borderLeft: `3px solid ${token.colorWarning}` }}
>
<Card size="small" style={{ borderLeft: `3px solid ${token.colorWarning}` }}>
<Statistic
title={
<Text type="secondary" style={{ fontSize: 12 }}>
<AimOutlined style={{ marginRight: 4 }} />
</Text>
}
title={<Text type="secondary" style={{ fontSize: 12 }}><AimOutlined style={{ marginRight: 4 }} /></Text>}
value={centerNode?.label || '未选择'}
styles={{
content: {
fontSize: 20,
color: centerNode ? token.colorWarning : token.colorTextDisabled,
fontWeight: 600,
},
}}
styles={{ content: { fontSize: 20, color: centerNode ? token.colorWarning : token.colorTextDisabled, fontWeight: 600 } }}
/>
{selectedCenter && (
<Text type="secondary" style={{ fontSize: 11 }}>
{centerDegree}
</Text>
)}
{selectedCenter && <Text type="secondary" style={{ fontSize: 11 }}>{centerDegree} </Text>}
</Card>
</Col>
</Row>
{/* Main Graph Card */}
<Card
title={
<Space>
<ApartmentOutlined />
<span></span>
{relFilter && (
<Tag
color="blue"
closable
onClose={() => setRelFilter(undefined)}
>
{getEdgeTypeLabel(relFilter)}
</Tag>
)}
{relFilter && <Tag color="blue" closable onClose={() => setRelFilter(undefined)}>{getEdgeTypeLabel(relFilter)}</Tag>}
</Space>
}
size="small"
@@ -581,19 +164,9 @@ export function PluginGraphPage() {
options={relTypes.map((t) => ({
label: (
<Space>
<span
style={{
display: 'inline-block',
width: 10,
height: 10,
borderRadius: '50%',
backgroundColor: getRelColor(t).base,
}}
/>
<span style={{ display: 'inline-block', width: 10, height: 10, borderRadius: '50%', backgroundColor: getRelColor(t).base }} />
{getEdgeTypeLabel(t)}
<Text type="secondary" style={{ fontSize: 11 }}>
({relationships.filter((r) => r.label === t).length})
</Text>
<Text type="secondary" style={{ fontSize: 11 }}>({relationships.filter((r) => r.label === t).length})</Text>
</Space>
),
value: t,
@@ -607,73 +180,42 @@ export function PluginGraphPage() {
style={{ width: 200 }}
optionFilterProp="label"
value={selectedCenter || undefined}
options={customers.map((c) => ({
label: c.label,
value: c.id,
}))}
options={customers.map((c) => ({ label: c.label, value: c.id }))}
onChange={(v) => setSelectedCenter(v || null)}
/>
</Space>
}
>
{customers.length === 0 ? (
<Empty
description="暂无客户数据"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
<Empty description="暂无客户数据" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<div ref={containerRef} style={{ position: 'relative' }}>
<canvas
ref={canvasRef}
onMouseMove={handleCanvasMouseMove}
onMouseLeave={handleCanvasMouseLeave}
onClick={handleCanvasClick}
style={{
width: '100%',
height: canvasSize.height,
borderRadius: 8,
border: `1px solid ${token.colorBorderSecondary}`,
display: 'block',
}}
onClick={onCanvasClick}
style={{ width: '100%', height: canvasSize.height, borderRadius: 8, border: `1px solid ${token.colorBorderSecondary}`, display: 'block' }}
/>
{/* Legend overlay */}
{legendItems.length > 0 && (
<div
style={{
position: 'absolute',
bottom: 12,
left: 12,
background: token.colorBgElevated,
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>
<div style={{
position: 'absolute', bottom: 12, left: 12,
background: token.colorBgElevated, 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}>
{legendItems.map((item) => (
<Tag
key={item.rawLabel}
color={item.color}
style={{
margin: 0,
fontSize: 11,
margin: 0, fontSize: 11,
cursor: relFilter === item.rawLabel ? 'default' : 'pointer',
opacity: relFilter && relFilter !== item.rawLabel ? 0.4 : 1,
}}
onClick={() => {
setRelFilter((prev) =>
prev === item.rawLabel ? undefined : item.rawLabel,
);
}}
onClick={() => setRelFilter((prev) => prev === item.rawLabel ? undefined : item.rawLabel)}
>
{item.label} ({item.count})
</Tag>
@@ -682,29 +224,17 @@ export function PluginGraphPage() {
</div>
)}
{/* Info overlay */}
{hoverState.nodeId && (
<div
style={{
position: 'absolute',
top: 12,
right: 12,
background: token.colorBgElevated,
border: `1px solid ${token.colorBorderSecondary}`,
borderRadius: 8,
padding: '8px 12px',
boxShadow: token.boxShadowSecondary,
maxWidth: 280,
transition: 'opacity 0.15s ease',
}}
>
<div style={{
position: 'absolute', top: 12, right: 12,
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}>
<Text strong>
{visibleNodes.find((n) => n.id === hoverState.nodeId)?.label}
</Text>
<Text strong>{visibleNodes.find((n) => n.id === hoverState.nodeId)?.label}</Text>
<Text type="secondary" style={{ fontSize: 11 }}>
<InfoCircleOutlined style={{ marginRight: 4 }} />
/
<InfoCircleOutlined style={{ marginRight: 4 }} /> /
</Text>
</Space>
</div>
@@ -713,7 +243,6 @@ export function PluginGraphPage() {
)}
</Card>
{/* Selected node detail panel */}
{selectedCenter && centerNode && (
<Card
size="small"
@@ -727,13 +256,8 @@ export function PluginGraphPage() {
}
extra={
<Tooltip title="取消选中">
<Text
type="secondary"
style={{ cursor: 'pointer', fontSize: 12 }}
onClick={() => setSelectedCenter(null)}
>
<ReloadOutlined style={{ marginRight: 4 }} />
<Text type="secondary" style={{ cursor: 'pointer', fontSize: 12 }} onClick={() => setSelectedCenter(null)}>
<ReloadOutlined style={{ marginRight: 4 }} />
</Text>
</Tooltip>
}
@@ -742,12 +266,9 @@ export function PluginGraphPage() {
{Object.entries(centerNode.data).map(([key, value]) => {
if (value == null || value === '') return null;
const fieldSchema = fields.find((f) => f.name === key);
const displayName = fieldSchema?.display_name || key;
return (
<Col xs={12} sm={8} md={6} key={key}>
<Text type="secondary" style={{ fontSize: 11, display: 'block' }}>
{displayName}
</Text>
<Text type="secondary" style={{ fontSize: 11, display: 'block' }}>{fieldSchema?.display_name || key}</Text>
<Text style={{ fontSize: 13 }}>{String(value)}</Text>
</Col>
);
@@ -755,8 +276,7 @@ export function PluginGraphPage() {
</Row>
<Divider style={{ margin: '12px 0 8px' }} />
<Text type="secondary" style={{ fontSize: 12 }}>
: {centerDegree}
{visibleNodes.length} {visibleEdges.length}
: {centerDegree} {visibleNodes.length} {visibleEdges.length}
</Text>
</Card>
)}

View 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,
};
}

View 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 };
}

View File

@@ -22,8 +22,8 @@ import {
pointsApi,
type PointsOrder,
} from '../../api/health/points';
import { patientApi } from '../../api/health/patients';
import { AuthButton } from '../../components/AuthButton';
import { useHealthStore } from '../../stores/health';
/** 订单状态映射 */
const STATUS_MAP: Record<string, { text: string; color: string }> = {
@@ -56,8 +56,7 @@ export default function PointsOrderList() {
const [verifyForm] = Form.useForm();
const [verifying, setVerifying] = useState(false);
// 名称缓存
const [nameCache, setNameCache] = useState<Record<string, string>>({});
const { batchResolvePatientNames, getPatientName } = useHealthStore();
// ---- 数据获取 ----
const fetchData = useCallback(async (p = page, ps = pageSize) => {
@@ -71,29 +70,14 @@ export default function PointsOrderList() {
setData(result.data);
setTotal(result.total);
// 批量解析患者名称
const patientIds = [...new Set(result.data.map((o) => o.patient_id))];
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 }));
}
const patientIds = result.data.map((o) => o.patient_id);
batchResolvePatientNames(patientIds);
} catch {
message.error('加载订单列表失败');
} finally {
setLoading(false);
}
}, [page, pageSize, statusFilter, nameCache]);
}, [page, pageSize, statusFilter, batchResolvePatientNames]);
useEffect(() => {
fetchData();
@@ -136,7 +120,7 @@ export default function PointsOrderList() {
dataIndex: 'patient_id',
key: 'patient_id',
width: 100,
render: (id: string) => nameCache[id] || id.slice(0, 8),
render: (id: string) => getPatientName(id),
},
{
title: '商品',
@@ -182,7 +166,7 @@ export default function PointsOrderList() {
dataIndex: 'verified_by',
key: 'verified_by',
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: '过期时间',

View File

@@ -1,4 +1,3 @@
import { useEffect, useState, useCallback } from 'react';
import {
Row,
Col,
@@ -10,7 +9,6 @@ import {
Button,
Typography,
Tooltip,
Tag,
} from 'antd';
import {
UserOutlined,
@@ -28,30 +26,23 @@ import {
ArrowUpOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import {
pointsApi,
type PatientStatistics,
type ConsultationStatistics,
type FollowUpStatistics,
type PointsStatistics,
type HealthDataStats,
} from '../../api/health/points';
import type { PointsStatistics } from '../../api/health/points';
import { useStatsData } from './StatisticsDashboard/useStatsData';
import HealthDataCenter from './StatisticsDashboard/HealthDataCenter';
const { Title: AntTitle, Text } = Typography;
/** Top-level stat card configuration */
interface StatCardConfig {
title: string;
value: number;
suffix?: string;
precision?: number;
prefix?: React.ReactNode;
prefix: React.ReactNode;
subtitle?: string;
color: string;
bgColor: string;
}
/** Quick-link card configuration */
interface QuickLinkConfig {
title: string;
icon: React.ReactNode;
@@ -59,7 +50,6 @@ interface QuickLinkConfig {
color: string;
}
/** Top earner row from points statistics */
interface TopEarnerRow {
rank: number;
patient_id: string;
@@ -77,137 +67,78 @@ const QUICK_LINKS: QuickLinkConfig[] = [
{ title: '线下活动', icon: <CalendarOutlined />, path: '/health/offline-events', color: '#be185d' },
];
export default function StatisticsDashboard() {
const navigate = useNavigate();
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[] = [
function buildStatCards(stats: ReturnType<typeof useStatsData>): StatCardConfig[] {
return [
{
title: '患者总数',
value: patientStats?.total_patients ?? 0,
value: stats.patientStats?.total_patients ?? 0,
prefix: <UserOutlined />,
subtitle: patientStats?.new_this_month ? `本月 +${patientStats.new_this_month}` : undefined,
color: '#2563eb',
bgColor: '#eff6ff',
subtitle: stats.patientStats?.new_this_month ? `本月 +${stats.patientStats.new_this_month}` : undefined,
color: '#2563eb', bgColor: '#eff6ff',
},
{
title: '咨询总量',
value: consultationStats?.total_sessions ?? 0,
value: stats.consultationStats?.total_sessions ?? 0,
prefix: <MessageOutlined />,
subtitle: consultationStats?.this_month ? `本月 +${consultationStats.this_month}` : undefined,
color: '#7c3aed',
bgColor: '#f5f3ff',
subtitle: stats.consultationStats?.this_month ? `本月 +${stats.consultationStats.this_month}` : undefined,
color: '#7c3aed', bgColor: '#f5f3ff',
},
{
title: '随访完成率',
value: followUpStats?.completion_rate ?? 0,
suffix: '%',
precision: 1,
value: stats.followUpStats?.completion_rate ?? 0,
suffix: '%', precision: 1,
prefix: <PhoneOutlined />,
subtitle: followUpStats?.pending ? `待处理: ${followUpStats.pending}` : undefined,
color: '#059669',
bgColor: '#ecfdf5',
subtitle: stats.followUpStats?.pending ? `待处理: ${stats.followUpStats.pending}` : undefined,
color: '#059669', bgColor: '#ecfdf5',
},
{
title: '积分总发放',
value: pointsStats?.total_issued ?? 0,
value: stats.pointsStats?.total_issued ?? 0,
prefix: <TrophyOutlined />,
subtitle: pointsStats?.active_accounts ? `活跃账户: ${pointsStats.active_accounts}` : undefined,
color: '#d97706',
bgColor: '#fffbeb',
subtitle: stats.pointsStats?.active_accounts ? `活跃账户: ${stats.pointsStats.active_accounts}` : undefined,
color: '#d97706', bgColor: '#fffbeb',
},
];
}
// ---- Top earners table ----
const topEarnerColumns = [
{
title: '排名',
dataIndex: 'rank',
key: 'rank',
width: 70,
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>
);
},
const topEarnerColumns = [
{
title: '排名', dataIndex: 'rank', key: 'rank', width: 70,
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',
key: 'patient_id',
width: 180,
render: (id: string) => (
<Tooltip title={id}>
<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: '患者 ID', dataIndex: 'patient_id', key: 'patient_id', width: 180,
render: (id: string) => (
<Tooltip title={id}>
<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>,
},
];
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,
patient_id: item.patient_id,
total_earned: item.total_earned,
}));
}
// ---- Loading / Error states ----
if (loading) {
export default function StatisticsDashboard() {
const navigate = useNavigate();
const stats = useStatsData();
const statCards = buildStatCards(stats);
const topEarnerData = buildTopEarnerData(stats.pointsStats);
if (stats.loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}>
<Spin size="large" tip="加载统计数据中..." />
@@ -215,56 +146,35 @@ export default function StatisticsDashboard() {
);
}
if (error) {
if (stats.error) {
return (
<Alert
type="error"
message="加载统计数据失败"
description={error}
description={stats.error}
showIcon
action={
<Button size="small" icon={<ReloadOutlined />} onClick={fetchAllStats}>
</Button>
}
action={<Button size="small" icon={<ReloadOutlined />} onClick={stats.refresh}></Button>}
/>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{/* Section 1: Top Stats Cards */}
<Row gutter={[16, 16]}>
{statCards.map((card) => (
<Col xs={24} sm={12} md={6} key={card.title}>
<Card
bordered={false}
style={{ borderRadius: 12 }}
bodyStyle={{ padding: '20px 24px' }}
hoverable
>
<Card bordered={false} style={{ borderRadius: 12 }} bodyStyle={{ padding: '20px 24px' }} hoverable>
<Statistic
title={
<span style={{ fontSize: 14, color: '#64748b' }}>{card.title}</span>
}
title={<span style={{ fontSize: 14, color: '#64748b' }}>{card.title}</span>}
value={card.value}
precision={card.precision}
suffix={card.suffix}
prefix={
<span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 40,
height: 40,
borderRadius: 10,
backgroundColor: card.bgColor,
color: card.color,
fontSize: 20,
marginRight: 12,
}}
>
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 40, height: 40, borderRadius: 10, backgroundColor: card.bgColor,
color: card.color, fontSize: 20, marginRight: 12,
}}>
{card.prefix}
</span>
}
@@ -272,8 +182,7 @@ export default function StatisticsDashboard() {
/>
{card.subtitle && (
<div style={{ marginTop: 8, fontSize: 13, color: '#94a3b8' }}>
<ArrowUpOutlined style={{ fontSize: 11, marginRight: 4 }} />
{card.subtitle}
<ArrowUpOutlined style={{ fontSize: 11, marginRight: 4 }} />{card.subtitle}
</div>
)}
</Card>
@@ -281,235 +190,32 @@ export default function StatisticsDashboard() {
))}
</Row>
{/* Section 2: Points Statistics Details */}
<Card
title={
<span style={{ fontSize: 16, fontWeight: 600 }}>
<TrophyOutlined style={{ marginRight: 8, color: '#d97706' }} />
</span>
}
title={<span style={{ fontSize: 16, fontWeight: 600 }}><TrophyOutlined style={{ marginRight: 8, color: '#d97706' }} /></span>}
bordered={false}
style={{ borderRadius: 12 }}
extra={
<Button
type="text"
icon={<ReloadOutlined />}
onClick={fetchAllStats}
loading={loading}
>
</Button>
}
extra={<Button type="text" icon={<ReloadOutlined />} onClick={stats.refresh} loading={stats.loading}></Button>}
>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={12} sm={6}>
<Statistic
title="总发放"
value={pointsStats?.total_issued ?? 0}
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>
<Col xs={12} sm={6}><Statistic title="总发放" value={stats.pointsStats?.total_issued ?? 0} valueStyle={{ color: '#059669', fontSize: 22 }} prefix={<ArrowUpOutlined />} /></Col>
<Col xs={12} sm={6}><Statistic title="总消费" value={stats.pointsStats?.total_spent ?? 0} valueStyle={{ color: '#dc2626', fontSize: 22 }} /></Col>
<Col xs={12} sm={6}><Statistic title="总过期" value={stats.pointsStats?.total_expired ?? 0} valueStyle={{ color: '#6b7280', fontSize: 22 }} /></Col>
<Col xs={12} sm={6}><Statistic title="活跃账户" value={stats.pointsStats?.active_accounts ?? 0} valueStyle={{ color: '#2563eb', fontSize: 22 }} prefix={<TeamOutlined />} /></Col>
</Row>
<AntTitle level={5} style={{ marginBottom: 16 }}>
Top 10
</AntTitle>
<Table
rowKey="rank"
columns={topEarnerColumns}
dataSource={topEarnerData}
pagination={false}
size="small"
locale={{ emptyText: '暂无数据' }}
style={{ marginTop: 8 }}
/>
<AntTitle level={5} style={{ marginBottom: 16 }}> Top 10</AntTitle>
<Table rowKey="rank" columns={topEarnerColumns} dataSource={topEarnerData} pagination={false} size="small" locale={{ emptyText: '暂无数据' }} style={{ marginTop: 8 }} />
</Card>
{/* Section 2.5: Health Data Statistics */}
<Card
title={
<span style={{ fontSize: 16, fontWeight: 600 }}>
<MedicineBoxOutlined style={{ marginRight: 8, color: '#2563eb' }} />
</span>
}
title={<span style={{ fontSize: 16, fontWeight: 600 }}><MedicineBoxOutlined style={{ marginRight: 8, color: '#2563eb' }} /></span>}
bordered={false}
style={{ borderRadius: 12 }}
>
<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={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>
<HealthDataCenter data={stats.healthDataStats} />
</Card>
{/* Section 3: Quick Links */}
<Card
title={
<span style={{ fontSize: 16, fontWeight: 600 }}>
<MedicineBoxOutlined style={{ marginRight: 8, color: '#2563eb' }} />
</span>
}
title={<span style={{ fontSize: 16, fontWeight: 600 }}><MedicineBoxOutlined style={{ marginRight: 8, color: '#2563eb' }} /></span>}
bordered={false}
style={{ borderRadius: 12 }}
>
@@ -517,62 +223,32 @@ export default function StatisticsDashboard() {
{QUICK_LINKS.map((link) => (
<Col xs={12} sm={8} md={6} key={link.path}>
<Card
hoverable
bordered={false}
style={{
borderRadius: 10,
cursor: 'pointer',
textAlign: 'center',
transition: 'transform 0.2s, box-shadow 0.2s',
}}
hoverable bordered={false}
style={{ borderRadius: 10, cursor: 'pointer', textAlign: 'center', transition: 'transform 0.2s, box-shadow 0.2s' }}
bodyStyle={{ padding: '20px 12px' }}
onClick={() => navigate(link.path)}
>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 48,
height: 48,
borderRadius: 12,
backgroundColor: `${link.color}15`,
color: link.color,
fontSize: 24,
marginBottom: 10,
}}
>
<div style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 48, height: 48, borderRadius: 12, backgroundColor: `${link.color}15`,
color: link.color, fontSize: 24, marginBottom: 10,
}}>
{link.icon}
</div>
<div style={{ fontSize: 14, fontWeight: 500, color: '#334155' }}>
{link.title}
</div>
<div style={{ fontSize: 14, fontWeight: 500, color: '#334155' }}>{link.title}</div>
</Card>
</Col>
))}
</Row>
</Card>
{/* Section 4: Recent Activity (top earners as proxy) */}
{topEarnerData.length > 0 && (
<Card
title={
<span style={{ fontSize: 16, fontWeight: 600 }}>
<ClockCircleOutlined style={{ marginRight: 8, color: '#7c3aed' }} />
</span>
}
title={<span style={{ fontSize: 16, fontWeight: 600 }}><ClockCircleOutlined style={{ marginRight: 8, color: '#7c3aed' }} /></span>}
bordered={false}
style={{ borderRadius: 12 }}
>
<Table
rowKey="rank"
columns={topEarnerColumns}
dataSource={topEarnerData}
pagination={{ pageSize: 5, size: 'small' }}
size="small"
locale={{ emptyText: '暂无活动记录' }}
/>
<Table rowKey="rank" columns={topEarnerColumns} dataSource={topEarnerData} pagination={{ pageSize: 5, size: 'small' }} size="small" locale={{ emptyText: '暂无活动记录' }} />
</Card>
)}
</div>

View File

@@ -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>
);
}

View File

@@ -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,
};
}

View File

@@ -11,6 +11,8 @@ interface HealthState {
resolveDoctorName: (id: string) => Promise<string>;
getPatientName: (id: string) => string;
getDoctorName: (id: string) => string;
batchResolvePatientNames: (ids: string[]) => Promise<void>;
batchResolveDoctorNames: (ids: string[]) => Promise<void>;
}
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),
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 } }));
}
},
}));