diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 5d8d79b..7367c6f 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -53,6 +53,8 @@ const OAuthClientList = lazy(() => import('./pages/health/OAuthClientList'));
const DialysisManageList = lazy(() => import('./pages/health/DialysisManageList'));
const ActionInbox = lazy(() => import('./pages/health/ActionInbox'));
const FollowUpTemplateList = lazy(() => import('./pages/health/FollowUpTemplateList'));
+const CarePlanList = lazy(() => import('./pages/health/CarePlanList'));
+const CarePlanDetail = lazy(() => import('./pages/health/CarePlanDetail'));
// 内容管理
const ArticleManageList = lazy(() => import('./pages/health/ArticleManageList'));
@@ -271,6 +273,8 @@ export default function App() {
} />
} />
} />
+ } />
+ } />
{/* 内容管理 */}
} />
} />
diff --git a/apps/web/src/api/health/carePlans.ts b/apps/web/src/api/health/carePlans.ts
new file mode 100644
index 0000000..6a8b2c5
--- /dev/null
+++ b/apps/web/src/api/health/carePlans.ts
@@ -0,0 +1,245 @@
+import client from '../client';
+import type { PaginatedResponse } from '../types';
+
+// --- Types ---
+
+export interface CarePlan {
+ id: string;
+ patient_id: string;
+ plan_type: string;
+ status: string;
+ title: string;
+ goals?: Record;
+ start_date?: string;
+ end_date?: string;
+ notes?: string;
+ created_at: string;
+ updated_at: string;
+ version: number;
+}
+
+export interface CarePlanItem {
+ id: string;
+ plan_id: string;
+ item_type: string;
+ title: string;
+ description?: string;
+ status: string;
+ schedule?: string;
+ sort_order?: number;
+ created_at: string;
+ updated_at: string;
+ version: number;
+}
+
+export interface CarePlanOutcome {
+ id: string;
+ plan_id: string;
+ item_id?: string;
+ metric: string;
+ baseline_value: string;
+ target_value: string;
+ current_value?: string;
+ measured_at?: string;
+ notes?: string;
+ created_at: string;
+ updated_at: string;
+ version: number;
+}
+
+export interface CreateCarePlanReq {
+ patient_id: string;
+ plan_type: string;
+ title: string;
+ goals?: Record;
+ start_date?: string;
+ end_date?: string;
+ notes?: string;
+}
+
+export interface UpdateCarePlanReq {
+ plan_type?: string;
+ title?: string;
+ status?: string;
+ goals?: Record;
+ start_date?: string;
+ end_date?: string;
+ notes?: string;
+}
+
+export interface CreateCarePlanItemReq {
+ item_type: string;
+ title: string;
+ description?: string;
+ schedule?: string;
+ sort_order?: number;
+}
+
+export interface UpdateCarePlanItemReq {
+ item_type?: string;
+ title?: string;
+ description?: string;
+ status?: string;
+ schedule?: string;
+ sort_order?: number;
+}
+
+export interface CreateCarePlanOutcomeReq {
+ item_id?: string;
+ metric: string;
+ baseline_value: string;
+ target_value: string;
+ current_value?: string;
+ measured_at?: string;
+ notes?: string;
+}
+
+export interface UpdateCarePlanOutcomeReq {
+ item_id?: string;
+ metric?: string;
+ baseline_value?: string;
+ target_value?: string;
+ current_value?: string;
+ measured_at?: string;
+ notes?: string;
+}
+
+export interface ListCarePlansParams {
+ page?: number;
+ page_size?: number;
+ patient_id?: string;
+ plan_type?: string;
+ status?: string;
+}
+
+// --- Constants ---
+
+export const PLAN_TYPE_OPTIONS = [
+ { label: '血液透析', value: 'hemodialysis' },
+ { label: '腹膜透析', value: 'peritoneal' },
+ { label: '慢性病管理', value: 'chronic_disease' },
+ { label: '康复计划', value: 'rehabilitation' },
+];
+
+export const PLAN_STATUS_OPTIONS = [
+ { label: '草稿', value: 'draft' },
+ { label: '进行中', value: 'active' },
+ { label: '已完成', value: 'completed' },
+ { label: '已取消', value: 'cancelled' },
+];
+
+export const ITEM_TYPE_OPTIONS = [
+ { label: '药物干预', value: 'medication' },
+ { label: '饮食管理', value: 'diet' },
+ { label: '运动计划', value: 'exercise' },
+ { label: '监测项目', value: 'monitoring' },
+ { label: '教育指导', value: 'education' },
+ { label: '其他', value: 'other' },
+];
+
+export const PLAN_STATUS_COLOR: Record = {
+ draft: 'default',
+ active: 'processing',
+ completed: 'success',
+ cancelled: 'error',
+};
+
+// --- API ---
+
+export const carePlanApi = {
+ list: async (params: ListCarePlansParams) => {
+ const { data } = await client.get<{
+ success: boolean;
+ data: PaginatedResponse;
+ }>('/health/care-plans', { params });
+ return data.data;
+ },
+
+ get: async (id: string) => {
+ const { data } = await client.get<{
+ success: boolean;
+ data: CarePlan;
+ }>(`/health/care-plans/${id}`);
+ return data.data;
+ },
+
+ create: async (req: CreateCarePlanReq) => {
+ const { data } = await client.post<{
+ success: boolean;
+ data: CarePlan;
+ }>('/health/care-plans', req);
+ return data.data;
+ },
+
+ update: async (id: string, req: UpdateCarePlanReq & { version: number }) => {
+ const { data } = await client.put<{
+ success: boolean;
+ data: CarePlan;
+ }>(`/health/care-plans/${id}`, req);
+ return data.data;
+ },
+
+ delete: async (id: string, version: number) => {
+ await client.delete(`/health/care-plans/${id}`, { data: { version } });
+ },
+
+ // --- Items ---
+
+ listItems: async (planId: string, params?: { page?: number; page_size?: number }) => {
+ const { data } = await client.get<{
+ success: boolean;
+ data: PaginatedResponse;
+ }>(`/health/care-plans/${planId}/items`, { params });
+ return data.data;
+ },
+
+ createItem: async (planId: string, req: CreateCarePlanItemReq) => {
+ const { data } = await client.post<{
+ success: boolean;
+ data: CarePlanItem;
+ }>(`/health/care-plans/${planId}/items`, req);
+ return data.data;
+ },
+
+ updateItem: async (planId: string, itemId: string, req: UpdateCarePlanItemReq & { version: number }) => {
+ const { data } = await client.put<{
+ success: boolean;
+ data: CarePlanItem;
+ }>(`/health/care-plans/${planId}/items/${itemId}`, req);
+ return data.data;
+ },
+
+ deleteItem: async (planId: string, itemId: string, version: number) => {
+ await client.delete(`/health/care-plans/${planId}/items/${itemId}`, { data: { version } });
+ },
+
+ // --- Outcomes ---
+
+ listOutcomes: async (planId: string, params?: { page?: number; page_size?: number }) => {
+ const { data } = await client.get<{
+ success: boolean;
+ data: PaginatedResponse;
+ }>(`/health/care-plans/${planId}/outcomes`, { params });
+ return data.data;
+ },
+
+ createOutcome: async (planId: string, req: CreateCarePlanOutcomeReq) => {
+ const { data } = await client.post<{
+ success: boolean;
+ data: CarePlanOutcome;
+ }>(`/health/care-plans/${planId}/outcomes`, req);
+ return data.data;
+ },
+
+ updateOutcome: async (planId: string, outcomeId: string, req: UpdateCarePlanOutcomeReq & { version: number }) => {
+ const { data } = await client.put<{
+ success: boolean;
+ data: CarePlanOutcome;
+ }>(`/health/care-plans/${planId}/outcomes/${outcomeId}`, req);
+ return data.data;
+ },
+
+ deleteOutcome: async (planId: string, outcomeId: string, version: number) => {
+ await client.delete(`/health/care-plans/${planId}/outcomes/${outcomeId}`, { data: { version } });
+ },
+};
diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx
index f4b7090..cca3029 100644
--- a/apps/web/src/layouts/MainLayout.tsx
+++ b/apps/web/src/layouts/MainLayout.tsx
@@ -111,6 +111,8 @@ const routeTitleFallback: Record = {
'/health/devices': '设备管理',
'/health/dialysis': '透析管理',
'/health/follow-up-templates': '随访模板管理',
+ '/health/care-plans': '护理计划',
+ '/health/care-plans/:id': '护理计划详情',
};
function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined {
diff --git a/apps/web/src/pages/health/CarePlanDetail.tsx b/apps/web/src/pages/health/CarePlanDetail.tsx
new file mode 100644
index 0000000..4f1fb92
--- /dev/null
+++ b/apps/web/src/pages/health/CarePlanDetail.tsx
@@ -0,0 +1,361 @@
+import { useState, useCallback, useEffect } from 'react';
+import {
+ Button, DatePicker, Descriptions, Form, Input, InputNumber, message,
+ Modal, Popconfirm, Result, Select, Space, Spin, Table, Tabs, Tag,
+} from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import dayjs from 'dayjs';
+import { useParams, useNavigate } from 'react-router-dom';
+
+import {
+ carePlanApi,
+ type CarePlan,
+ type CarePlanItem,
+ type CarePlanOutcome,
+ type CreateCarePlanItemReq,
+ type CreateCarePlanOutcomeReq,
+ PLAN_TYPE_OPTIONS,
+ PLAN_STATUS_OPTIONS,
+ PLAN_STATUS_COLOR,
+ ITEM_TYPE_OPTIONS,
+} from '../../api/health/carePlans';
+import { PageContainer } from '../../components/PageContainer';
+import { usePermission } from '../../hooks/usePermission';
+
+const PLAN_TYPE_LABEL: Record = Object.fromEntries(PLAN_TYPE_OPTIONS.map((o) => [o.value, o.label]));
+const PLAN_STATUS_LABEL: Record = Object.fromEntries(PLAN_STATUS_OPTIONS.map((o) => [o.value, o.label]));
+const ITEM_TYPE_LABEL: Record = Object.fromEntries(ITEM_TYPE_OPTIONS.map((o) => [o.value, o.label]));
+
+export default function CarePlanDetail() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const { hasPermission } = usePermission('health.care-plan.manage');
+
+ const [plan, setPlan] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ // Items
+ const [items, setItems] = useState([]);
+ const [itemsLoading, setItemsLoading] = useState(false);
+ const [itemModalOpen, setItemModalOpen] = useState(false);
+ const [editItem, setEditItem] = useState(null);
+ const [itemForm] = Form.useForm();
+
+ // Outcomes
+ const [outcomes, setOutcomes] = useState([]);
+ const [outcomesLoading, setOutcomesLoading] = useState(false);
+ const [outcomeModalOpen, setOutcomeModalOpen] = useState(false);
+ const [editOutcome, setEditOutcome] = useState(null);
+ const [outcomeForm] = Form.useForm();
+
+ const planId = id!;
+
+ const fetchPlan = useCallback(async () => {
+ setLoading(true);
+ try {
+ const data = await carePlanApi.get(planId);
+ setPlan(data);
+ } catch {
+ message.error('加载计划详情失败');
+ } finally {
+ setLoading(false);
+ }
+ }, [planId]);
+
+ const fetchItems = useCallback(async () => {
+ setItemsLoading(true);
+ try {
+ const resp = await carePlanApi.listItems(planId, { page: 1, page_size: 100 });
+ setItems(resp.data);
+ } catch {
+ message.error('加载干预项目失败');
+ } finally {
+ setItemsLoading(false);
+ }
+ }, [planId]);
+
+ const fetchOutcomes = useCallback(async () => {
+ setOutcomesLoading(true);
+ try {
+ const resp = await carePlanApi.listOutcomes(planId, { page: 1, page_size: 100 });
+ setOutcomes(resp.data);
+ } catch {
+ message.error('加载预后测量失败');
+ } finally {
+ setOutcomesLoading(false);
+ }
+ }, [planId]);
+
+ useEffect(() => {
+ fetchPlan();
+ fetchItems();
+ fetchOutcomes();
+ }, [fetchPlan, fetchItems, fetchOutcomes]);
+
+ // --- Item CRUD ---
+
+ const handleCreateItem = () => {
+ setEditItem(null);
+ itemForm.resetFields();
+ setItemModalOpen(true);
+ };
+
+ const handleEditItem = (record: CarePlanItem) => {
+ setEditItem(record);
+ itemForm.setFieldsValue({
+ item_type: record.item_type,
+ title: record.title,
+ description: record.description,
+ schedule: record.schedule,
+ sort_order: record.sort_order,
+ });
+ setItemModalOpen(true);
+ };
+
+ const handleSubmitItem = async () => {
+ try {
+ const values = await itemForm.validateFields();
+ if (editItem) {
+ await carePlanApi.updateItem(planId, editItem.id, { ...values, version: editItem.version });
+ message.success('干预项目已更新');
+ } else {
+ await carePlanApi.createItem(planId, values as CreateCarePlanItemReq);
+ message.success('干预项目已创建');
+ }
+ setItemModalOpen(false);
+ fetchItems();
+ } catch {
+ // validation
+ }
+ };
+
+ const handleDeleteItem = async (record: CarePlanItem) => {
+ try {
+ await carePlanApi.deleteItem(planId, record.id, record.version);
+ message.success('干预项目已删除');
+ fetchItems();
+ } catch {
+ message.error('删除失败');
+ }
+ };
+
+ // --- Outcome CRUD ---
+
+ const handleCreateOutcome = () => {
+ setEditOutcome(null);
+ outcomeForm.resetFields();
+ setOutcomeModalOpen(true);
+ };
+
+ const handleEditOutcome = (record: CarePlanOutcome) => {
+ setEditOutcome(record);
+ outcomeForm.setFieldsValue({
+ metric: record.metric,
+ baseline_value: record.baseline_value,
+ target_value: record.target_value,
+ current_value: record.current_value,
+ measured_at: record.measured_at ? dayjs(record.measured_at) : undefined,
+ notes: record.notes,
+ });
+ setOutcomeModalOpen(true);
+ };
+
+ const handleSubmitOutcome = async () => {
+ try {
+ const values = await outcomeForm.validateFields();
+ const req = {
+ ...values,
+ measured_at: values.measured_at?.format('YYYY-MM-DD'),
+ };
+ if (editOutcome) {
+ await carePlanApi.updateOutcome(planId, editOutcome.id, { ...req, version: editOutcome.version });
+ message.success('预后测量已更新');
+ } else {
+ await carePlanApi.createOutcome(planId, req as CreateCarePlanOutcomeReq);
+ message.success('预后测量已创建');
+ }
+ setOutcomeModalOpen(false);
+ fetchOutcomes();
+ } catch {
+ // validation
+ }
+ };
+
+ const handleDeleteOutcome = async (record: CarePlanOutcome) => {
+ try {
+ await carePlanApi.deleteOutcome(planId, record.id, record.version);
+ message.success('预后测量已删除');
+ fetchOutcomes();
+ } catch {
+ message.error('删除失败');
+ }
+ };
+
+ // --- Columns ---
+
+ const itemColumns: ColumnsType = [
+ { title: '类型', dataIndex: 'item_type', width: 120, render: (v: string) => ITEM_TYPE_LABEL[v] ?? v },
+ { title: '标题', dataIndex: 'title', width: 200 },
+ { title: '状态', dataIndex: 'status', width: 100, render: (v: string) => {v} },
+ { title: '排期', dataIndex: 'schedule', width: 140, render: (v: string) => v ?? '-' },
+ { title: '排序', dataIndex: 'sort_order', width: 70 },
+ {
+ title: '操作', width: 140, render: (_, record) => hasPermission ? (
+
+
+ handleDeleteItem(record)}>
+
+
+
+ ) : null,
+ },
+ ];
+
+ const outcomeColumns: ColumnsType = [
+ { title: '指标', dataIndex: 'metric', width: 140 },
+ { title: '基线值', dataIndex: 'baseline_value', width: 100 },
+ { title: '目标值', dataIndex: 'target_value', width: 100 },
+ { title: '当前值', dataIndex: 'current_value', width: 100, render: (v: string) => v ?? '-' },
+ { title: '测量日期', dataIndex: 'measured_at', width: 120, render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD') : '-' },
+ {
+ title: '操作', width: 140, render: (_, record) => hasPermission ? (
+
+
+ handleDeleteOutcome(record)}>
+
+
+
+ ) : null,
+ },
+ ];
+
+ if (loading) return ;
+ if (!plan) return ;
+ if (!hasPermission) return ;
+
+ return (
+ navigate('/health/care-plans')}
+ >
+
+ {PLAN_TYPE_LABEL[plan.plan_type] ?? plan.plan_type}
+
+ {PLAN_STATUS_LABEL[plan.status] ?? plan.status}
+
+ {plan.patient_id}
+ {plan.start_date ?? '-'}
+ {plan.end_date ?? '-'}
+ {dayjs(plan.updated_at).format('YYYY-MM-DD HH:mm')}
+ {plan.notes && {plan.notes}}
+
+
+
+ {hasPermission && (
+
+ )}
+
+ rowKey="id"
+ columns={itemColumns}
+ dataSource={items}
+ loading={itemsLoading}
+ pagination={false}
+ size="small"
+ />
+ >
+ ),
+ },
+ {
+ key: 'outcomes',
+ label: `预后测量 (${outcomes.length})`,
+ children: (
+ <>
+ {hasPermission && (
+
+ )}
+
+ rowKey="id"
+ columns={outcomeColumns}
+ dataSource={outcomes}
+ loading={outcomesLoading}
+ pagination={false}
+ size="small"
+ />
+ >
+ ),
+ },
+ ]}
+ />
+
+ {/* Item Modal */}
+ setItemModalOpen(false)}
+ width={520}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Outcome Modal */}
+ setOutcomeModalOpen(false)}
+ width={520}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/pages/health/CarePlanList.tsx b/apps/web/src/pages/health/CarePlanList.tsx
new file mode 100644
index 0000000..508e07c
--- /dev/null
+++ b/apps/web/src/pages/health/CarePlanList.tsx
@@ -0,0 +1,273 @@
+import { useState, useCallback, useEffect, useMemo } from 'react';
+import {
+ Button, DatePicker, Form, Input, message, Modal, Popconfirm,
+ Result, Select, Space, Table, Tag,
+} from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import dayjs from 'dayjs';
+import { useNavigate } from 'react-router-dom';
+
+import {
+ carePlanApi,
+ type CarePlan,
+ type CreateCarePlanReq,
+ type UpdateCarePlanReq,
+ PLAN_TYPE_OPTIONS,
+ PLAN_STATUS_OPTIONS,
+ PLAN_STATUS_COLOR,
+} from '../../api/health/carePlans';
+import { PageContainer } from '../../components/PageContainer';
+import { usePermission } from '../../hooks/usePermission';
+
+const PLAN_TYPE_LABEL: Record = Object.fromEntries(
+ PLAN_TYPE_OPTIONS.map((o) => [o.value, o.label]),
+);
+
+const PLAN_STATUS_LABEL: Record = Object.fromEntries(
+ PLAN_STATUS_OPTIONS.map((o) => [o.value, o.label]),
+);
+
+interface FilterValues {
+ plan_type?: string;
+ status?: string;
+}
+
+export default function CarePlanList() {
+ const { hasPermission } = usePermission('health.care-plan.manage');
+ const navigate = useNavigate();
+
+ const [data, setData] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [page, setPage] = useState(1);
+ const [loading, setLoading] = useState(false);
+ const [filters, setFilters] = useState({});
+
+ const [modalOpen, setModalOpen] = useState(false);
+ const [editRecord, setEditRecord] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+ const [form] = Form.useForm();
+
+ const pageSize = 20;
+
+ const fetchData = useCallback(async (p: number, f: FilterValues) => {
+ setLoading(true);
+ try {
+ const resp = await carePlanApi.list({
+ page: p,
+ page_size: pageSize,
+ plan_type: f.plan_type || undefined,
+ status: f.status || undefined,
+ });
+ setData(resp.data);
+ setTotal(resp.total);
+ setPage(p);
+ } catch {
+ message.error('加载护理计划失败');
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchData(1, filters);
+ }, [fetchData, filters]);
+
+ const handleFilterChange = (key: string, value: string | undefined) => {
+ setFilters((prev) => ({ ...prev, [key]: value }));
+ };
+
+ const handleResetFilters = () => {
+ setFilters({});
+ };
+
+ const handleCreate = () => {
+ setEditRecord(null);
+ form.resetFields();
+ setModalOpen(true);
+ };
+
+ const handleEdit = (record: CarePlan) => {
+ setEditRecord(record);
+ form.setFieldsValue({
+ title: record.title,
+ plan_type: record.plan_type,
+ start_date: record.start_date ? dayjs(record.start_date) : undefined,
+ end_date: record.end_date ? dayjs(record.end_date) : undefined,
+ notes: record.notes,
+ });
+ setModalOpen(true);
+ };
+
+ const handleSubmit = async () => {
+ try {
+ const values = await form.validateFields();
+ const req: CreateCarePlanReq | (UpdateCarePlanReq & { version: number }) = {
+ title: values.title,
+ plan_type: values.plan_type,
+ start_date: values.start_date?.format('YYYY-MM-DD'),
+ end_date: values.end_date?.format('YYYY-MM-DD'),
+ notes: values.notes,
+ ...(editRecord ? { version: editRecord.version } : { patient_id: values.patient_id }),
+ };
+
+ setSubmitting(true);
+ if (editRecord) {
+ await carePlanApi.update(editRecord.id, req as UpdateCarePlanReq & { version: number });
+ message.success('护理计划已更新');
+ } else {
+ await carePlanApi.create(req as CreateCarePlanReq);
+ message.success('护理计划已创建');
+ }
+ setModalOpen(false);
+ fetchData(page, filters);
+ } catch {
+ // form validation
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const handleDelete = async (record: CarePlan) => {
+ try {
+ await carePlanApi.delete(record.id, record.version);
+ message.success('护理计划已删除');
+ fetchData(page, filters);
+ } catch {
+ message.error('删除失败');
+ }
+ };
+
+ const columns: ColumnsType = useMemo(() => [
+ {
+ title: '计划名称',
+ dataIndex: 'title',
+ width: 200,
+ render: (title: string, record) => (
+ navigate(`/health/care-plans/${record.id}`)}>{title}
+ ),
+ },
+ {
+ title: '类型',
+ dataIndex: 'plan_type',
+ width: 120,
+ render: (v: string) => PLAN_TYPE_LABEL[v] ?? v,
+ },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ width: 100,
+ render: (v: string) => (
+ {PLAN_STATUS_LABEL[v] ?? v}
+ ),
+ },
+ {
+ title: '开始日期',
+ dataIndex: 'start_date',
+ width: 120,
+ render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD') : '-',
+ },
+ {
+ title: '结束日期',
+ dataIndex: 'end_date',
+ width: 120,
+ render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD') : '-',
+ },
+ {
+ title: '更新时间',
+ dataIndex: 'updated_at',
+ width: 170,
+ render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'),
+ },
+ {
+ title: '操作',
+ width: 160,
+ render: (_, record) => (
+
+
+ handleDelete(record)}>
+
+
+
+ ),
+ },
+ ], [navigate, page, filters]);
+
+ if (!hasPermission) {
+ return ;
+ }
+
+ return (
+ 新建计划}
+ >
+
+
+
+