删除内容: - 前端: health/(67文件), ai/(2文件), Copilot, MediaPicker, 相关API/Store/Hook - 后端: wechat_handler, wechat_service, wechat_user entity, analytics handler, ai_workflow_seed - 配置: WechatConfig, AppConfig.wechat, AuthState wechat 字段 - 启动: 微信凭据检查块, ensure_ai_workflows() 调用 - 迁移: 新增 m20260613_000170_drop_wechat_users.rs - 脚本: api_test_health_alert.py, api_test_mp.py, mpsync.sh/ps1 - E2E: health-data page, flows/ 目录 保留: erp-core/auth/workflow/message/config/plugin + 基座前端 + 通用组件
489 lines
17 KiB
TypeScript
489 lines
17 KiB
TypeScript
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()}
|
|
destroyOnHidden
|
|
>
|
|
<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>
|
|
);
|
|
}
|