diff --git a/apps/web/src/pages/PluginCRUDPage.tsx b/apps/web/src/pages/PluginCRUDPage.tsx index fa897a1..ec545cb 100644 --- a/apps/web/src/pages/PluginCRUDPage.tsx +++ b/apps/web/src/pages/PluginCRUDPage.tsx @@ -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[]>([]); - const [total, setTotal] = useState(0); - const [page, setPage] = useState(1); - const [loading, setLoading] = useState(false); - const [fields, setFields] = useState([]); - const [displayName, setDisplayName] = useState(entityName || ''); - const [modalOpen, setModalOpen] = useState(false); - const [editRecord, setEditRecord] = useState | null>(null); - const [form] = Form.useForm(); - const [formValues, setFormValues] = useState>({}); - - // 筛选/搜索/排序 state - const [searchText, setSearchText] = useState(''); - const [filters, setFilters] = useState>({}); - const [sortBy, setSortBy] = useState(); - const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); - - // 视图切换 - const [viewMode, setViewMode] = useState('table'); - - // 批量选择 - const [selectedRowKeys, setSelectedRowKeys] = useState([]); - - // 跨插件引用标签解析 - const [resolvedLabels, setResolvedLabels] = useState>>({}); - const [labelMeta, setLabelMeta] = useState>({}); - - // 详情 Drawer - const [detailOpen, setDetailOpen] = useState(false); - const [detailRecord, setDetailRecord] = useState | null>(null); - const [detailSections, setDetailSections] = useState([]); - const [allEntities, setAllEntities] = useState([]); - const [allPages, setAllPages] = useState([]); - - // 导入导出 - const [entityDef, setEntityDef] = useState(null); - const [importModalOpen, setImportModalOpen] = useState(false); - const [importing, setImporting] = useState(false); - const [importResult, setImportResult] = useState(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 = {}; - 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); - }) - .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) => { - if (!pluginId || !entityName) return; - const { _id, _version, ...data } = values as Record & { - _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) => { - 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 ? : ; - 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 {f.ref_fallback_label || '外部引用'}; - if (label === null) return 无效引用; - if (label) return {label}; - } - return String(val ?? '-'); - }, - })), - { - title: '操作', - key: 'action', - width: hasDetailPage ? 200 : 150, - render: (_: unknown, record: Record) => ( - - {hasDetailPage && ( - - )} - - handleDelete(record)}> - - - - ), - }, - ], [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 ; - case 'boolean': - return ; - case 'date': - case 'datetime': - return ; - case 'select': - return ( - - ); - case 'textarea': - return