Files
hms/apps/web/src/pages/PluginCRUDPage.tsx
iven e68fe8c1b1 feat(web): 插件前端全面增强 — 搜索/筛选/排序/详情页/条件表单/timeline 视图
- pluginData API: 支持 filter/search/sort_by/sort_order 参数
- plugins API: 新增 PluginFieldSchema/PluginEntitySchema/PluginPageSchema 类型
- PluginCRUDPage: 添加搜索框、筛选栏、视图切换(表格/时间线)
- PluginCRUDPage: 添加详情 Drawer(Descriptions + 嵌套 CRUD)
- PluginCRUDPage: 支持 visible_when 条件表单字段动态显示/隐藏
- PluginCRUDPage: 支持 compact 模式用于 detail 页面内嵌
2026-04-16 12:35:24 +08:00

595 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import {
Table,
Button,
Space,
Modal,
Form,
Input,
InputNumber,
DatePicker,
Switch,
Select,
Tag,
message,
Popconfirm,
Drawer,
Descriptions,
Segmented,
Timeline,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
ReloadOutlined,
EyeOutlined,
} from '@ant-design/icons';
import {
listPluginData,
createPluginData,
updatePluginData,
deletePluginData,
PluginDataListOptions,
} from '../api/pluginData';
import {
getPluginSchema,
PluginFieldSchema,
PluginEntitySchema,
PluginPageSchema,
PluginSectionSchema,
} from '../api/plugins';
const { Search } = Input;
const { TextArea } = Input;
/** visible_when 表达式解析 */
function parseVisibleWhen(expression: string): { field: string; value: string } | null {
const regex = /^(\w+)\s*==\s*'([^']*)'$/;
const match = expression.trim().match(regex);
if (!match) return null;
return { field: match[1], value: match[2] };
}
/** 判断字段是否应该显示 */
function shouldShowField(
allValues: Record<string, unknown>,
visibleWhen: string | undefined,
): boolean {
if (!visibleWhen) return true;
const parsed = parseVisibleWhen(visibleWhen);
if (!parsed) return true;
return String(allValues[parsed.field] ?? '') === parsed.value;
}
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');
// 详情 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[]>([]);
// 从 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;
getPluginSchema(pluginId)
.then((schema) => {
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 || '');
}
const ui = (schema as { ui?: { pages: PluginPageSchema[] } }).ui;
if (ui?.pages) {
setAllPages(ui.pages);
// 找到 detail 页面的 sections
const detailPage = ui.pages.find(
(p) => p.type === 'detail' && 'entity' in p && p.entity === entityName,
);
if (detailPage && 'sections' in detailPage) {
setDetailSections(detailPage.sections);
}
}
})
.catch(() => {
// schema 加载失败时仍可使用
});
}, [pluginId, entityName]);
const fetchData = useCallback(
async (p = page) => {
if (!pluginId || !entityName) return;
setLoading(true);
try {
const options: PluginDataListOptions = {};
// 自动添加 filterField 过滤detail 页面内嵌 CRUD
const mergedFilters = { ...filters };
if (filterField && filterValue) {
mergedFilters[filterField] = filterValue;
}
if (Object.keys(mergedFilters).length > 0) {
options.filter = mergedFilters;
}
if (searchText) options.search = searchText;
if (sortBy) {
options.sort_by = sortBy;
options.sort_order = sortOrder;
}
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]);
// 筛选变化
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 columns = [
...fields.slice(0, 5).map((f) => ({
title: f.display_name || f.name,
dataIndex: f.name,
key: f.name,
ellipsis: true,
sorter: f.sortable ? true : undefined,
render: (val: unknown) => {
if (typeof val === 'boolean') return val ? <Tag color="green"></Tag> : <Tag></Tag>;
return String(val ?? '-');
},
})),
{
title: '操作',
key: 'action',
width: hasDetailPage ? 200 : 150,
render: (_: unknown, record: Record<string, unknown>) => (
<Space size="small">
{hasDetailPage && (
<Button
size="small"
icon={<EyeOutlined />}
onClick={() => {
setDetailRecord(record);
setDetailOpen(true);
}}
>
</Button>
)}
<Button
size="small"
icon={<EditOutlined />}
onClick={() => {
setEditRecord(record);
form.setFieldsValue(record);
setFormValues(record);
setModalOpen(true);
}}
>
</Button>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(record)}>
<Button size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
// 动态生成表单字段
const renderFormField = (field: PluginFieldSchema) => {
const widget = field.ui_widget || field.field_type;
switch (widget) {
case 'number':
case 'integer':
case 'float':
case 'decimal':
return <InputNumber style={{ width: '100%' }} />;
case 'boolean':
return <Switch />;
case 'date':
case 'datetime':
return <DatePicker showTime={widget === 'datetime'} style={{ width: '100%' }} />;
case 'select':
return (
<Select>
{(field.options || []).map((opt) => (
<Select.Option key={String(opt.value)} value={opt.value}>
{opt.label}
</Select.Option>
))}
</Select>
);
case 'textarea':
return <TextArea rows={3} />;
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>
</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);
}}
/>
)}
{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>
)}
{viewMode === 'table' || enableViews.length <= 1 ? (
<Table
columns={columns}
dataSource={records}
rowKey="_id"
loading={loading}
size={compact ? 'small' : 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 = shouldShowField(formValues, field.visible_when);
if (!visible) return null;
return (
<Form.Item
key={field.name}
name={field.name}
label={field.display_name || field.name}
rules={
field.required
? [{ required: true, message: `请输入${field.display_name || field.name}` }]
: []
}
valuePropName={field.field_type === 'boolean' ? 'checked' : 'value'}
>
{renderFormField(field)}
</Form.Item>
);
})}
</Form>
</Modal>
{/* 详情 Drawer */}
{renderDetailDrawer()}
</div>
);
}