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 页面内嵌
This commit is contained in:
iven
2026-04-16 12:35:24 +08:00
parent 0ad77693f4
commit e68fe8c1b1
3 changed files with 475 additions and 79 deletions

View File

@@ -16,15 +16,32 @@ interface PaginatedDataResponse {
total_pages: number; total_pages: number;
} }
export interface PluginDataListOptions {
filter?: Record<string, string>;
search?: string;
sort_by?: string;
sort_order?: 'asc' | 'desc';
}
export async function listPluginData( export async function listPluginData(
pluginId: string, pluginId: string,
entity: string, entity: string,
page = 1, page = 1,
pageSize = 20, pageSize = 20,
options?: PluginDataListOptions,
) { ) {
const params: Record<string, string> = {
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 }>( const { data } = await client.get<{ success: boolean; data: PaginatedDataResponse }>(
`/plugins/${pluginId}/${entity}`, `/plugins/${pluginId}/${entity}`,
{ params: { page, page_size: pageSize } }, { params },
); );
return data.data; return data.data;
} }

View File

@@ -119,3 +119,44 @@ export async function getPluginSchema(id: string) {
); );
return data.data; 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[] };

View File

@@ -14,81 +14,207 @@ import {
Tag, Tag,
message, message,
Popconfirm, Popconfirm,
Drawer,
Descriptions,
Segmented,
Timeline,
} from 'antd'; } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons'; import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
ReloadOutlined,
EyeOutlined,
} from '@ant-design/icons';
import { import {
listPluginData, listPluginData,
createPluginData, createPluginData,
updatePluginData, updatePluginData,
deletePluginData, deletePluginData,
PluginDataListOptions,
} from '../api/pluginData'; } from '../api/pluginData';
import { getPluginSchema } from '../api/plugins'; import {
getPluginSchema,
PluginFieldSchema,
PluginEntitySchema,
PluginPageSchema,
PluginSectionSchema,
} from '../api/plugins';
interface FieldDef { const { Search } = Input;
name: string; const { TextArea } = Input;
field_type: string;
required: boolean; /** visible_when 表达式解析 */
display_name?: string; function parseVisibleWhen(expression: string): { field: string; value: string } | null {
ui_widget?: string; const regex = /^(\w+)\s*==\s*'([^']*)'$/;
options?: { label: string; value: string }[]; const match = expression.trim().match(regex);
if (!match) return null;
return { field: match[1], value: match[2] };
} }
interface EntitySchema { /** 判断字段是否应该显示 */
name: string; function shouldShowField(
display_name: string; allValues: Record<string, unknown>,
fields: FieldDef[]; 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() { interface PluginCRUDPageProps {
const { pluginId, entityName } = useParams<{ pluginId: string; entityName: string }>(); /** 如果从 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 [records, setRecords] = useState<Record<string, unknown>[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [fields, setFields] = useState<FieldDef[]>([]); const [fields, setFields] = useState<PluginFieldSchema[]>([]);
const [displayName, setDisplayName] = useState(entityName || ''); const [displayName, setDisplayName] = useState(entityName || '');
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editRecord, setEditRecord] = useState<Record<string, unknown> | null>(null); const [editRecord, setEditRecord] = useState<Record<string, unknown> | null>(null);
const [form] = Form.useForm(); 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 // 加载 schema
useEffect(() => { useEffect(() => {
if (!pluginId) return; if (!pluginId) return;
getPluginSchema(pluginId) getPluginSchema(pluginId)
.then((schema) => { .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); const entity = entities.find((e) => e.name === entityName);
if (entity) { if (entity) {
setFields(entity.fields); setFields(entity.fields);
setDisplayName(entity.display_name || entityName || ''); 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(() => { .catch(() => {
// schema 加载失败时仍可使用 // schema 加载失败时仍可使用
}); });
}, [pluginId, entityName]); }, [pluginId, entityName]);
const fetchData = useCallback(async (p = page) => { const fetchData = useCallback(
async (p = page) => {
if (!pluginId || !entityName) return; if (!pluginId || !entityName) return;
setLoading(true); setLoading(true);
try { try {
const result = await listPluginData(pluginId, entityName, p); const options: PluginDataListOptions = {};
setRecords(result.data.map((r) => ({ ...r.data, _id: r.id, _version: r.version }))); // 自动添加 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); setTotal(result.total);
} catch { } catch {
message.error('加载数据失败'); message.error('加载数据失败');
} }
setLoading(false); setLoading(false);
}, [pluginId, entityName, page]); },
[pluginId, entityName, page, filters, searchText, sortBy, sortOrder, filterField, filterValue],
);
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
}, [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>) => { const handleSubmit = async (values: Record<string, unknown>) => {
if (!pluginId || !entityName) return; if (!pluginId || !entityName) return;
// 去除内部字段 const { _id, _version, ...data } = values as Record<string, unknown> & {
const { _id, _version, ...data } = values as Record<string, unknown> & { _id?: string; _version?: number }; _id?: string;
_version?: number;
};
try { try {
if (editRecord) { if (editRecord) {
@@ -130,6 +256,7 @@ export default function PluginCRUDPage() {
dataIndex: f.name, dataIndex: f.name,
key: f.name, key: f.name,
ellipsis: true, ellipsis: true,
sorter: f.sortable ? true : undefined,
render: (val: unknown) => { render: (val: unknown) => {
if (typeof val === 'boolean') return val ? <Tag color="green"></Tag> : <Tag></Tag>; if (typeof val === 'boolean') return val ? <Tag color="green"></Tag> : <Tag></Tag>;
return String(val ?? '-'); return String(val ?? '-');
@@ -138,15 +265,28 @@ export default function PluginCRUDPage() {
{ {
title: '操作', title: '操作',
key: 'action', key: 'action',
width: 150, width: hasDetailPage ? 200 : 150,
render: (_: unknown, record: Record<string, unknown>) => ( render: (_: unknown, record: Record<string, unknown>) => (
<Space size="small"> <Space size="small">
{hasDetailPage && (
<Button
size="small"
icon={<EyeOutlined />}
onClick={() => {
setDetailRecord(record);
setDetailOpen(true);
}}
>
</Button>
)}
<Button <Button
size="small" size="small"
icon={<EditOutlined />} icon={<EditOutlined />}
onClick={() => { onClick={() => {
setEditRecord(record); setEditRecord(record);
form.setFieldsValue(record); form.setFieldsValue(record);
setFormValues(record);
setModalOpen(true); setModalOpen(true);
}} }}
> >
@@ -163,7 +303,7 @@ export default function PluginCRUDPage() {
]; ];
// 动态生成表单字段 // 动态生成表单字段
const renderFormField = (field: FieldDef) => { const renderFormField = (field: PluginFieldSchema) => {
const widget = field.ui_widget || field.field_type; const widget = field.ui_widget || field.field_type;
switch (widget) { switch (widget) {
case 'number': case 'number':
@@ -186,22 +326,162 @@ export default function PluginCRUDPage() {
))} ))}
</Select> </Select>
); );
case 'textarea':
return <TextArea rows={3} />;
default: default:
return <Input />; 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 ( return (
<div style={{ padding: 24 }}> <Timeline
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> 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> <h2 style={{ margin: 0 }}>{displayName}</h2>
<Space> <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 <Button
icon={<PlusOutlined />} icon={<PlusOutlined />}
type="primary" type="primary"
onClick={() => { onClick={() => {
setEditRecord(null); setEditRecord(null);
form.resetFields(); form.resetFields();
setFormValues({});
setModalOpen(true); setModalOpen(true);
}} }}
> >
@@ -212,45 +492,103 @@ export default function PluginCRUDPage() {
</Button> </Button>
</Space> </Space>
</div> </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 <Table
columns={columns} columns={columns}
dataSource={records} dataSource={records}
rowKey="_id" rowKey="_id"
loading={loading} loading={loading}
pagination={{ size={compact ? 'small' : undefined}
pagination={
compact
? { pageSize: 5, showTotal: (t) => `${t}` }
: {
current: page, current: page,
total, total,
pageSize: 20, pageSize: 20,
onChange: (p) => setPage(p), onChange: (p) => setPage(p),
showTotal: (t) => `${t}`, showTotal: (t) => `${t}`,
}} }
}
/> />
) : viewMode === 'timeline' ? (
renderTimeline()
) : null}
{/* 新增/编辑弹窗 */}
<Modal <Modal
title={editRecord ? '编辑' : '新增'} title={editRecord ? '编辑' : '新增'}
open={modalOpen} open={modalOpen}
onCancel={() => { onCancel={() => {
setModalOpen(false); setModalOpen(false);
setEditRecord(null); setEditRecord(null);
setFormValues({});
}} }}
onOk={() => form.submit()} onOk={() => form.submit()}
destroyOnClose destroyOnClose
> >
<Form form={form} layout="vertical" onFinish={handleSubmit}> <Form
{fields.map((field) => ( 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 <Form.Item
key={field.name} key={field.name}
name={field.name} name={field.name}
label={field.display_name || field.name} label={field.display_name || field.name}
rules={field.required ? [{ required: true, message: `请输入${field.display_name || field.name}` }] : []} rules={
field.required
? [{ required: true, message: `请输入${field.display_name || field.name}` }]
: []
}
valuePropName={field.field_type === 'boolean' ? 'checked' : 'value'} valuePropName={field.field_type === 'boolean' ? 'checked' : 'value'}
> >
{renderFormField(field)} {renderFormField(field)}
</Form.Item> </Form.Item>
))} );
})}
</Form> </Form>
</Modal> </Modal>
{/* 详情 Drawer */}
{renderDetailDrawer()}
</div> </div>
); );
} }