From 32df9c065501363ad348a190dd40d192a8e30406 Mon Sep 17 00:00:00 2001 From: iven Date: Sun, 3 May 2026 09:31:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E9=9A=8F=E8=AE=BF=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E7=AE=A1=E7=90=86=E9=A1=B5=E9=9D=A2=20=E2=80=94=20CRU?= =?UTF-8?q?D=20+=20=E8=B7=AF=E7=94=B1=20+=20=E8=8F=9C=E5=8D=95=E8=BF=81?= =?UTF-8?q?=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 FollowUpTemplateList.tsx 页面(列表/新建/编辑/详情弹窗) - 新增 followUpTemplates.ts API 客户端(list/get/create/update/delete) - 注册路由 /health/follow-up-templates + 菜单标题 fallback - 新增迁移 seed_follow_up_template_menu 注册菜单和权限 --- apps/web/src/App.tsx | 2 + apps/web/src/api/health/followUpTemplates.ts | 119 +++++++ apps/web/src/layouts/MainLayout.tsx | 1 + .../src/pages/health/FollowUpTemplateList.tsx | 322 ++++++++++++++++++ crates/erp-server/migration/src/lib.rs | 2 + ...502_000103_seed_follow_up_template_menu.rs | 65 ++++ 6 files changed, 511 insertions(+) create mode 100644 apps/web/src/api/health/followUpTemplates.ts create mode 100644 apps/web/src/pages/health/FollowUpTemplateList.tsx create mode 100644 crates/erp-server/migration/src/m20260502_000103_seed_follow_up_template_menu.rs diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 03eb7ce..93a6c8a 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -50,6 +50,7 @@ const AlertRuleList = lazy(() => import('./pages/health/AlertRuleList')); const DeviceManage = lazy(() => import('./pages/health/DeviceManage')); const DialysisManageList = lazy(() => import('./pages/health/DialysisManageList')); const ActionInbox = lazy(() => import('./pages/health/ActionInbox')); +const FollowUpTemplateList = lazy(() => import('./pages/health/FollowUpTemplateList')); // 内容管理 const ArticleManageList = lazy(() => import('./pages/health/ArticleManageList')); @@ -258,6 +259,7 @@ export default function App() { } /> } /> } /> + } /> {/* 内容管理 */} } /> } /> diff --git a/apps/web/src/api/health/followUpTemplates.ts b/apps/web/src/api/health/followUpTemplates.ts new file mode 100644 index 0000000..626e72a --- /dev/null +++ b/apps/web/src/api/health/followUpTemplates.ts @@ -0,0 +1,119 @@ +import client from '../client'; +import type { PaginatedResponse } from '../types'; + +export type FollowUpType = 'phone' | 'outpatient' | 'home_visit' | 'online' | 'wechat'; +export type TemplateStatus = 'active' | 'draft' | 'archived'; + +export interface TemplateField { + id: string; + template_id: string; + label: string; + field_key: string; + field_type: string; + required: boolean; + options?: string; + placeholder?: string; + validation?: string; + sort_order: number; + created_at: string; + updated_at: string; + version: number; +} + +export interface TemplateFieldReq { + label: string; + field_key: string; + field_type: string; + required?: boolean; + options?: string; + placeholder?: string; + validation?: string; + sort_order?: number; +} + +export interface FollowUpTemplate { + id: string; + name: string; + description?: string; + follow_up_type: string; + applicable_scope?: string; + status: string; + fields: TemplateField[]; + created_at: string; + updated_at: string; + version: number; +} + +export interface FollowUpTemplateListItem { + id: string; + name: string; + description?: string; + follow_up_type: string; + status: string; + field_count: number; + created_at: string; + updated_at: string; + version: number; +} + +export interface CreateTemplateReq { + name: string; + description?: string; + follow_up_type: string; + applicable_scope?: string; + fields: TemplateFieldReq[]; +} + +export interface UpdateTemplateReq { + name?: string; + description?: string; + follow_up_type?: string; + applicable_scope?: string; + status?: string; + fields?: TemplateFieldReq[]; +} + +export const followUpTemplateApi = { + list: async (params?: { + page?: number; + page_size?: number; + follow_up_type?: string; + status?: string; + }) => { + const { data } = await client.get<{ + success: boolean; + data: PaginatedResponse; + }>('/health/follow-up-templates', { params }); + return data.data; + }, + + get: async (id: string) => { + const { data } = await client.get<{ + success: boolean; + data: FollowUpTemplate; + }>(`/health/follow-up-templates/${id}`); + return data.data; + }, + + create: async (req: CreateTemplateReq) => { + const { data } = await client.post<{ + success: boolean; + data: FollowUpTemplate; + }>('/health/follow-up-templates', req); + return data.data; + }, + + update: async (id: string, req: UpdateTemplateReq & { version: number }) => { + const { data } = await client.put<{ + success: boolean; + data: FollowUpTemplate; + }>(`/health/follow-up-templates/${id}`, req); + return data.data; + }, + + delete: async (id: string, version: number) => { + await client.delete(`/health/follow-up-templates/${id}`, { + data: { version }, + }); + }, +}; diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx index d7c0b5f..f4b7090 100644 --- a/apps/web/src/layouts/MainLayout.tsx +++ b/apps/web/src/layouts/MainLayout.tsx @@ -110,6 +110,7 @@ const routeTitleFallback: Record = { '/health/alert-rules': '告警规则', '/health/devices': '设备管理', '/health/dialysis': '透析管理', + '/health/follow-up-templates': '随访模板管理', }; function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined { diff --git a/apps/web/src/pages/health/FollowUpTemplateList.tsx b/apps/web/src/pages/health/FollowUpTemplateList.tsx new file mode 100644 index 0000000..bdb14a4 --- /dev/null +++ b/apps/web/src/pages/health/FollowUpTemplateList.tsx @@ -0,0 +1,322 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + Button, Form, Input, Select, Table, Tag, Space, message, Modal, + Popconfirm, InputNumber, Switch, Card, Typography, +} from 'antd'; +import { + PlusOutlined, DeleteOutlined, EditOutlined, +} from '@ant-design/icons'; +import type { ColumnsType } from 'antd/es/table'; +import { + followUpTemplateApi, + type FollowUpTemplateListItem, + type FollowUpTemplate, + type TemplateFieldReq, +} from '../../../api/health/followUpTemplates'; +import { AuthButton } from '../../../components/AuthButton'; + +const TYPE_MAP: Record = { + phone: '电话', outpatient: '门诊', home_visit: '家访', + online: '线上', wechat: '微信', +}; +const TYPE_OPTIONS = Object.entries(TYPE_MAP).map(([v, l]) => ({ value: v, label: l })); +const STATUS_MAP: Record = { + active: { color: 'green', text: '启用' }, + draft: { color: 'orange', text: '草稿' }, + archived: { color: 'default', text: '归档' }, +}; +const FIELD_TYPE_OPTIONS = [ + { value: 'text', label: '文本' }, + { value: 'number', label: '数字' }, + { value: 'date', label: '日期' }, + { value: 'select', label: '下拉选择' }, + { value: 'checkbox', label: '复选' }, + { value: 'textarea', label: '多行文本' }, + { value: 'scale', label: '量表' }, +]; + +function FieldEditor({ value, onChange }: { + value: TemplateFieldReq[]; + onChange: (v: TemplateFieldReq[]) => void; +}) { + const add = () => { + onChange([...value, { + label: '', field_key: '', field_type: 'text', required: false, sort_order: value.length, + }]); + }; + const remove = (idx: number) => onChange(value.filter((_, i) => i !== idx)); + const update = (idx: number, patch: Partial) => { + 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 }} /> + update(i, { options: e.target.value || undefined })} + style={{ marginTop: 8, width: '100%' }} /> + )} + + ))} + +
+ ); +} + +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 ( +
+
+

随访模板

+ + + +
+ + + 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(()) + } +}