- 新增 FollowUpTemplateList.tsx 页面(列表/新建/编辑/详情弹窗) - 新增 followUpTemplates.ts API 客户端(list/get/create/update/delete) - 注册路由 /health/follow-up-templates + 菜单标题 fallback - 新增迁移 seed_follow_up_template_menu 注册菜单和权限
323 lines
11 KiB
TypeScript
323 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|