) => {
+ const next = [...value];
+ next[idx] = { ...next[idx], ...patch };
+ onChange(next);
+ };
+
+ return (
+
+ {value.map((f, i) => (
+
+
+ update(i, { label: e.target.value })} style={{ width: 120 }} />
+ update(i, { field_key: e.target.value })} style={{ width: 120 }} />
+
+ {f.field_type === 'select' && (
+ update(i, { options: e.target.value || undefined })}
+ style={{ marginTop: 8, width: '100%' }} />
+ )}
+
+ ))}
+ } onClick={add}>添加字段
+
+ );
+}
+
+export default function FollowUpTemplateList() {
+ const [data, setData] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [loading, setLoading] = useState(false);
+ const [page, setPage] = useState(1);
+ const [modalOpen, setModalOpen] = useState(false);
+ const [detailOpen, setDetailOpen] = useState(false);
+ const [detail, setDetail] = useState(null);
+ const [editingId, setEditingId] = useState(null);
+ const [form] = Form.useForm();
+
+ const fetchList = useCallback(async () => {
+ setLoading(true);
+ try {
+ const res = await followUpTemplateApi.list({ page, page_size: 20 });
+ setData(res.data);
+ setTotal(res.total);
+ } catch {
+ message.error('加载模板列表失败');
+ } finally {
+ setLoading(false);
+ }
+ }, [page]);
+
+ useEffect(() => { fetchList(); }, [fetchList]);
+
+ const openCreate = () => {
+ setEditingId(null);
+ form.resetFields();
+ form.setFieldsValue({ follow_up_type: 'phone', fields: [] });
+ setModalOpen(true);
+ };
+
+ const openEdit = async (id: string) => {
+ try {
+ const tpl = await followUpTemplateApi.get(id);
+ setEditingId(id);
+ form.setFieldsValue({
+ name: tpl.name,
+ description: tpl.description,
+ follow_up_type: tpl.follow_up_type,
+ applicable_scope: tpl.applicable_scope,
+ status: tpl.status,
+ fields: tpl.fields.map((f) => ({
+ label: f.label, field_key: f.field_key, field_type: f.field_type,
+ required: f.required, options: f.options, placeholder: f.placeholder,
+ sort_order: f.sort_order,
+ })),
+ });
+ setModalOpen(true);
+ } catch {
+ message.error('加载模板详情失败');
+ }
+ };
+
+ const openDetail = async (id: string) => {
+ try {
+ const tpl = await followUpTemplateApi.get(id);
+ setDetail(tpl);
+ setDetailOpen(true);
+ } catch {
+ message.error('加载模板详情失败');
+ }
+ };
+
+ const handleSubmit = async () => {
+ try {
+ const values = await form.validateFields();
+ const fields = (values.fields ?? []).map((f: TemplateFieldReq) => ({
+ ...f,
+ sort_order: f.sort_order ?? 0,
+ }));
+ if (editingId) {
+ await followUpTemplateApi.update(editingId, {
+ name: values.name,
+ description: values.description,
+ follow_up_type: values.follow_up_type,
+ applicable_scope: values.applicable_scope,
+ fields,
+ version: data.find((d) => d.id === editingId)?.version ?? 1,
+ });
+ message.success('模板已更新');
+ } else {
+ await followUpTemplateApi.create({
+ name: values.name,
+ description: values.description,
+ follow_up_type: values.follow_up_type,
+ applicable_scope: values.applicable_scope,
+ fields,
+ });
+ message.success('模板已创建');
+ }
+ setModalOpen(false);
+ fetchList();
+ } catch {
+ // validation error
+ }
+ };
+
+ const handleDelete = async (id: string, version: number) => {
+ try {
+ await followUpTemplateApi.delete(id, version);
+ message.success('模板已删除');
+ fetchList();
+ } catch {
+ message.error('删除失败');
+ }
+ };
+
+ const columns: ColumnsType = [
+ { title: '模板名称', dataIndex: 'name', width: 200 },
+ {
+ title: '随访方式', dataIndex: 'follow_up_type', width: 100,
+ render: (v: string) => TYPE_MAP[v] ?? v,
+ },
+ {
+ title: '状态', dataIndex: 'status', width: 80,
+ render: (v: string) => {
+ const cfg = STATUS_MAP[v] ?? { color: 'default', text: v };
+ return {cfg.text};
+ },
+ },
+ { title: '字段数', dataIndex: 'field_count', width: 80 },
+ {
+ title: '更新时间', dataIndex: 'updated_at', width: 170,
+ render: (v: string) => v ? new Date(v).toLocaleString('zh-CN') : '-',
+ },
+ {
+ title: '操作', width: 180,
+ render: (_, record) => (
+
+
+
+
+
+
+ handleDelete(record.id, record.version)}>
+
+
+
+
+ ),
+ },
+ ];
+
+ return (
+
+
+
随访模板
+
+ } onClick={openCreate}>新建模板
+
+
+
+
+ rowKey="id"
+ columns={columns}
+ dataSource={data}
+ loading={loading}
+ pagination={{
+ current: page, total, pageSize: 20,
+ showTotal: (t) => `共 ${t} 条`,
+ onChange: (p) => setPage(p),
+ }}
+ />
+
+ {/* 创建/编辑弹窗 */}
+ setModalOpen(false)}
+ width={720}
+ destroyOnClose
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* 详情弹窗 */}
+ setDetailOpen(false)}
+ footer={null}
+ width={640}
+ >
+ {detail && (
+
+
+ {TYPE_MAP[detail.follow_up_type] ?? detail.follow_up_type}
+
+ {STATUS_MAP[detail.status]?.text ?? detail.status}
+
+ {detail.applicable_scope && {detail.applicable_scope}}
+
+ {detail.description && (
+
{detail.description}
+ )}
+
+ 字段列表 ({detail.fields.length})
+
+
v ? '是' : '否',
+ },
+ ]}
+ />
+
+ )}
+
+
+ );
+}
diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs
index 06e7cf5..f0fbf75 100644
--- a/crates/erp-server/migration/src/lib.rs
+++ b/crates/erp-server/migration/src/lib.rs
@@ -102,6 +102,7 @@ mod m20260501_000099_create_ai_risk_threshold;
mod m20260501_000100_seed_action_inbox_menu;
mod m20260502_000101_seed_health_dictionaries;
mod m20260502_000102_seed_warning_thresholds;
+mod m20260502_000103_seed_follow_up_template_menu;
pub struct Migrator;
@@ -211,6 +212,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260501_000100_seed_action_inbox_menu::Migration),
Box::new(m20260502_000101_seed_health_dictionaries::Migration),
Box::new(m20260502_000102_seed_warning_thresholds::Migration),
+ Box::new(m20260502_000103_seed_follow_up_template_menu::Migration),
]
}
}
diff --git a/crates/erp-server/migration/src/m20260502_000103_seed_follow_up_template_menu.rs b/crates/erp-server/migration/src/m20260502_000103_seed_follow_up_template_menu.rs
new file mode 100644
index 0000000..723a479
--- /dev/null
+++ b/crates/erp-server/migration/src/m20260502_000103_seed_follow_up_template_menu.rs
@@ -0,0 +1,65 @@
+use sea_orm_migration::prelude::*;
+
+#[derive(DeriveMigrationName)]
+pub struct Migration;
+
+#[async_trait::async_trait]
+impl MigrationTrait for Migration {
+ async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ let db = manager.get_connection();
+
+ // 添加随访模板管理菜单(排在行动收件箱后面,sort_order=37)
+ db.execute_unprepared(
+ r#"
+ INSERT INTO menus (id, tenant_id, parent_id, title, path, icon, sort_order,
+ visible, menu_type, permission, created_at, updated_at, created_by, updated_by, version)
+ SELECT
+ 'b0000003-0000-7000-8000-000000000021'::uuid,
+ t.id,
+ (SELECT id FROM menus WHERE path = '/health' AND tenant_id = t.id LIMIT 1),
+ '随访模板管理',
+ '/health/follow-up-templates',
+ 'FormOutlined',
+ 37,
+ true, 'page', 'health.follow-up-templates.list',
+ NOW(), NOW(),
+ (SELECT id FROM users WHERE tenant_id = t.id LIMIT 1),
+ (SELECT id FROM users WHERE tenant_id = t.id LIMIT 1),
+ 1
+ FROM tenant t
+ WHERE NOT EXISTS (
+ SELECT 1 FROM menus
+ WHERE path = '/health/follow-up-templates' AND tenant_id = t.id
+ )
+ "#,
+ )
+ .await?;
+
+ // 给 admin 角色绑定 list + manage 权限
+ db.execute_unprepared(
+ r#"
+ INSERT INTO role_permissions (role_id, permission_id, tenant_id, created_by, updated_by, version)
+ SELECT r.id, p.id, t.id, r.id, r.id, 1
+ FROM tenant t
+ JOIN roles r ON r.tenant_id = t.id AND r.code = 'admin'
+ JOIN permissions p ON p.tenant_id = t.id AND p.code IN ('health.follow-up-templates.list', 'health.follow-up-templates.manage')
+ WHERE NOT EXISTS (
+ SELECT 1 FROM role_permissions rp
+ WHERE rp.permission_id = p.id AND rp.role_id = r.id
+ )
+ "#,
+ )
+ .await?;
+
+ Ok(())
+ }
+
+ async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ let db = manager.get_connection();
+ db.execute_unprepared(
+ "DELETE FROM menus WHERE path = '/health/follow-up-templates'",
+ )
+ .await?;
+ Ok(())
+ }
+}