From e68fe8c1b15172ba50d07b9b737ae6a3db3418b5 Mon Sep 17 00:00:00 2001 From: iven Date: Thu, 16 Apr 2026 12:35:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=8F=92=E4=BB=B6=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E5=85=A8=E9=9D=A2=E5=A2=9E=E5=BC=BA=20=E2=80=94=20?= =?UTF-8?q?=E6=90=9C=E7=B4=A2/=E7=AD=9B=E9=80=89/=E6=8E=92=E5=BA=8F/?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E9=A1=B5/=E6=9D=A1=E4=BB=B6=E8=A1=A8?= =?UTF-8?q?=E5=8D=95/timeline=20=E8=A7=86=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pluginData API: 支持 filter/search/sort_by/sort_order 参数 - plugins API: 新增 PluginFieldSchema/PluginEntitySchema/PluginPageSchema 类型 - PluginCRUDPage: 添加搜索框、筛选栏、视图切换(表格/时间线) - PluginCRUDPage: 添加详情 Drawer(Descriptions + 嵌套 CRUD) - PluginCRUDPage: 支持 visible_when 条件表单字段动态显示/隐藏 - PluginCRUDPage: 支持 compact 模式用于 detail 页面内嵌 --- apps/web/src/api/pluginData.ts | 19 +- apps/web/src/api/plugins.ts | 41 +++ apps/web/src/pages/PluginCRUDPage.tsx | 494 ++++++++++++++++++++++---- 3 files changed, 475 insertions(+), 79 deletions(-) diff --git a/apps/web/src/api/pluginData.ts b/apps/web/src/api/pluginData.ts index 4e629a8..b1b0e39 100644 --- a/apps/web/src/api/pluginData.ts +++ b/apps/web/src/api/pluginData.ts @@ -16,15 +16,32 @@ interface PaginatedDataResponse { total_pages: number; } +export interface PluginDataListOptions { + filter?: Record; + search?: string; + sort_by?: string; + sort_order?: 'asc' | 'desc'; +} + export async function listPluginData( pluginId: string, entity: string, page = 1, pageSize = 20, + options?: PluginDataListOptions, ) { + const params: Record = { + page: String(page), + page_size: String(pageSize), + }; + if (options?.filter) params.filter = JSON.stringify(options.filter); + if (options?.search) params.search = options.search; + if (options?.sort_by) params.sort_by = options.sort_by; + if (options?.sort_order) params.sort_order = options.sort_order; + const { data } = await client.get<{ success: boolean; data: PaginatedDataResponse }>( `/plugins/${pluginId}/${entity}`, - { params: { page, page_size: pageSize } }, + { params }, ); return data.data; } diff --git a/apps/web/src/api/plugins.ts b/apps/web/src/api/plugins.ts index bbceb47..1e4b19f 100644 --- a/apps/web/src/api/plugins.ts +++ b/apps/web/src/api/plugins.ts @@ -119,3 +119,44 @@ export async function getPluginSchema(id: string) { ); return data.data; } + +// ── Schema 类型定义 ── + +export interface PluginFieldSchema { + name: string; + field_type: string; + required: boolean; + display_name?: string; + ui_widget?: string; + options?: { label: string; value: string }[]; + searchable?: boolean; + filterable?: boolean; + sortable?: boolean; + visible_when?: string; + unique?: boolean; +} + +export interface PluginEntitySchema { + name: string; + display_name: string; + fields: PluginFieldSchema[]; +} + +export interface PluginSchemaResponse { + entities: PluginEntitySchema[]; + ui?: PluginUiSchema; +} + +export interface PluginUiSchema { + pages: PluginPageSchema[]; +} + +export type PluginPageSchema = + | { type: 'crud'; entity: string; label: string; icon?: string; enable_search?: boolean; enable_views?: string[] } + | { type: 'tree'; entity: string; label: string; icon?: string; id_field: string; parent_field: string; label_field: string } + | { type: 'detail'; entity: string; label: string; sections: PluginSectionSchema[] } + | { type: 'tabs'; label: string; icon?: string; tabs: PluginPageSchema[] }; + +export type PluginSectionSchema = + | { type: 'fields'; label: string; fields: string[] } + | { type: 'crud'; label: string; entity: string; filter_field?: string; enable_views?: string[] }; diff --git a/apps/web/src/pages/PluginCRUDPage.tsx b/apps/web/src/pages/PluginCRUDPage.tsx index 5977e90..e34acf5 100644 --- a/apps/web/src/pages/PluginCRUDPage.tsx +++ b/apps/web/src/pages/PluginCRUDPage.tsx @@ -14,81 +14,207 @@ import { Tag, message, Popconfirm, + Drawer, + Descriptions, + Segmented, + Timeline, } from 'antd'; -import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons'; +import { + PlusOutlined, + EditOutlined, + DeleteOutlined, + ReloadOutlined, + EyeOutlined, +} from '@ant-design/icons'; import { listPluginData, createPluginData, updatePluginData, deletePluginData, + PluginDataListOptions, } from '../api/pluginData'; -import { getPluginSchema } from '../api/plugins'; +import { + getPluginSchema, + PluginFieldSchema, + PluginEntitySchema, + PluginPageSchema, + PluginSectionSchema, +} from '../api/plugins'; -interface FieldDef { - name: string; - field_type: string; - required: boolean; - display_name?: string; - ui_widget?: string; - options?: { label: string; value: string }[]; +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] }; } -interface EntitySchema { - name: string; - display_name: string; - fields: FieldDef[]; +/** 判断字段是否应该显示 */ +function shouldShowField( + allValues: Record, + visibleWhen: string | undefined, +): boolean { + if (!visibleWhen) return true; + const parsed = parseVisibleWhen(visibleWhen); + if (!parsed) return true; + return String(allValues[parsed.field] ?? '') === parsed.value; } -export default function PluginCRUDPage() { - const { pluginId, entityName } = useParams<{ pluginId: string; entityName: string }>(); +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 [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'); + + // 详情 Drawer + const [detailOpen, setDetailOpen] = useState(false); + const [detailRecord, setDetailRecord] = useState | null>(null); + const [detailSections, setDetailSections] = useState([]); + const [allEntities, setAllEntities] = useState([]); + const [allPages, setAllPages] = useState([]); + + // 从 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 = (schema as { entities?: EntitySchema[] }).entities || []; + 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 result = await listPluginData(pluginId, entityName, p); - setRecords(result.data.map((r) => ({ ...r.data, _id: r.id, _version: r.version }))); - setTotal(result.total); - } catch { - message.error('加载数据失败'); - } - setLoading(false); - }, [pluginId, entityName, page]); + 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) => { if (!pluginId || !entityName) return; - // 去除内部字段 - const { _id, _version, ...data } = values as Record & { _id?: string; _version?: number }; + const { _id, _version, ...data } = values as Record & { + _id?: string; + _version?: number; + }; try { if (editRecord) { @@ -130,6 +256,7 @@ export default function PluginCRUDPage() { dataIndex: f.name, key: f.name, ellipsis: true, + sorter: f.sortable ? true : undefined, render: (val: unknown) => { if (typeof val === 'boolean') return val ? : ; return String(val ?? '-'); @@ -138,15 +265,28 @@ export default function PluginCRUDPage() { { title: '操作', key: 'action', - width: 150, + width: hasDetailPage ? 200 : 150, render: (_: unknown, record: Record) => ( + {hasDetailPage && ( + + )}