feat(web): 随访模板管理页面 — CRUD + 路由 + 菜单迁移
- 新增 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:
@@ -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 />} />
|
||||
|
||||
119
apps/web/src/api/health/followUpTemplates.ts
Normal file
119
apps/web/src/api/health/followUpTemplates.ts
Normal 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 },
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
322
apps/web/src/pages/health/FollowUpTemplateList.tsx
Normal file
322
apps/web/src/pages/health/FollowUpTemplateList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user