feat(web): 随访模板管理页面 — CRUD + 路由 + 菜单迁移
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- 新增 FollowUpTemplateList.tsx 页面(列表/新建/编辑/详情弹窗)
- 新增 followUpTemplates.ts API 客户端(list/get/create/update/delete)
- 注册路由 /health/follow-up-templates + 菜单标题 fallback
- 新增迁移 seed_follow_up_template_menu 注册菜单和权限
This commit is contained in:
iven
2026-05-03 09:31:43 +08:00
parent 2e4d98c479
commit 32df9c0655
6 changed files with 511 additions and 0 deletions

View File

@@ -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() {
<Route path="/health/devices" element={<DeviceManage />} />
<Route path="/health/dialysis" element={<DialysisManageList />} />
<Route path="/health/action-inbox" element={<ActionInbox />} />
<Route path="/health/follow-up-templates" element={<FollowUpTemplateList />} />
{/* 内容管理 */}
<Route path="/health/articles" element={<ArticleManageList />} />
<Route path="/health/articles/new" element={<ArticleEditor />} />

View File

@@ -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<FollowUpTemplateListItem>;
}>('/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 },
});
},
};

View File

@@ -110,6 +110,7 @@ const routeTitleFallback: Record<string, string> = {
'/health/alert-rules': '告警规则',
'/health/devices': '设备管理',
'/health/dialysis': '透析管理',
'/health/follow-up-templates': '随访模板管理',
};
function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined {

View File

@@ -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<string, string> = {
phone: '电话', outpatient: '门诊', home_visit: '家访',
online: '线上', wechat: '微信',
};
const TYPE_OPTIONS = Object.entries(TYPE_MAP).map(([v, l]) => ({ value: v, label: l }));
const STATUS_MAP: Record<string, { color: string; text: string }> = {
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<TemplateFieldReq>) => {
const next = [...value];
next[idx] = { ...next[idx], ...patch };
onChange(next);
};
return (
<div>
{value.map((f, i) => (
<Card key={i} size="small" style={{ marginBottom: 8 }}>
<Space wrap>
<Input placeholder="字段标签" value={f.label}
onChange={(e) => update(i, { label: e.target.value })} style={{ width: 120 }} />
<Input placeholder="字段key" value={f.field_key}
onChange={(e) => update(i, { field_key: e.target.value })} style={{ width: 120 }} />
<Select value={f.field_type} options={FIELD_TYPE_OPTIONS}
onChange={(v) => update(i, { field_type: v })} style={{ width: 100 }} />
<Switch checked={f.required} onChange={(v) => update(i, { required: v })}
checkedChildren="必填" unCheckedChildren="选填" />
<InputNumber placeholder="排序" value={f.sort_order} min={0}
onChange={(v) => update(i, { sort_order: v ?? 0 })} style={{ width: 70 }} />
<Button icon={<DeleteOutlined />} danger size="small" onClick={() => remove(i)} />
</Space>
{f.field_type === 'select' && (
<Input placeholder="选项(逗号分隔)" value={f.options ?? ''}
onChange={(e) => update(i, { options: e.target.value || undefined })}
style={{ marginTop: 8, width: '100%' }} />
)}
</Card>
))}
<Button type="dashed" block icon={<PlusOutlined />} onClick={add}></Button>
</div>
);
}
export default function FollowUpTemplateList() {
const [data, setData] = useState<FollowUpTemplateListItem[]>([]);
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<FollowUpTemplate | null>(null);
const [editingId, setEditingId] = useState<string | null>(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<FollowUpTemplateListItem> = [
{ 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 <Tag color={cfg.color}>{cfg.text}</Tag>;
},
},
{ 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) => (
<Space>
<Button type="link" size="small" onClick={() => openDetail(record.id)}></Button>
<AuthButton code="health.follow-up-templates.manage">
<Button type="link" size="small" onClick={() => openEdit(record.id)}></Button>
</AuthButton>
<AuthButton code="health.follow-up-templates.manage">
<Popconfirm title="确定删除此模板?" onConfirm={() => handleDelete(record.id, record.version)}>
<Button type="link" size="small" danger></Button>
</Popconfirm>
</AuthButton>
</Space>
),
},
];
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<h2 style={{ margin: 0 }}>访</h2>
<AuthButton code="health.follow-up-templates.manage">
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}></Button>
</AuthButton>
</div>
<Table<FollowUpTemplateListItem>
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{
current: page, total, pageSize: 20,
showTotal: (t) => `${t}`,
onChange: (p) => setPage(p),
}}
/>
{/* 创建/编辑弹窗 */}
<Modal
title={editingId ? '编辑模板' : '新建模板'}
open={modalOpen}
onOk={handleSubmit}
onCancel={() => setModalOpen(false)}
width={720}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="模板名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Space style={{ width: '100%' }} size="large">
<Form.Item name="follow_up_type" label="随访方式" rules={[{ required: true }]}>
<Select style={{ width: 160 }} options={TYPE_OPTIONS} />
</Form.Item>
<Form.Item name="applicable_scope" label="适用范围">
<Input style={{ width: 200 }} placeholder="如: 高血压患者" />
</Form.Item>
</Space>
<Form.Item name="description" label="描述">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name="fields" label="模板字段">
<FieldEditor />
</Form.Item>
</Form>
</Modal>
{/* 详情弹窗 */}
<Modal
title={detail?.name ?? '模板详情'}
open={detailOpen}
onCancel={() => setDetailOpen(false)}
footer={null}
width={640}
>
{detail && (
<div>
<Space style={{ marginBottom: 12 }}>
<Tag>{TYPE_MAP[detail.follow_up_type] ?? detail.follow_up_type}</Tag>
<Tag color={STATUS_MAP[detail.status]?.color ?? 'default'}>
{STATUS_MAP[detail.status]?.text ?? detail.status}
</Tag>
{detail.applicable_scope && <Typography.Text type="secondary">{detail.applicable_scope}</Typography.Text>}
</Space>
{detail.description && (
<Typography.Paragraph type="secondary">{detail.description}</Typography.Paragraph>
)}
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>
({detail.fields.length})
</Typography.Text>
<Table
size="small"
dataSource={detail.fields}
rowKey="id"
pagination={false}
columns={[
{ title: '标签', dataIndex: 'label', width: 120 },
{ title: 'Key', dataIndex: 'field_key', width: 100 },
{ title: '类型', dataIndex: 'field_type', width: 80 },
{
title: '必填', dataIndex: 'required', width: 60,
render: (v: boolean) => v ? '是' : '否',
},
]}
/>
</div>
)}
</Modal>
</div>
);
}

View File

@@ -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),
]
}
}

View File

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