diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 3b2aa05..4f1f70b 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -35,6 +35,9 @@ const FollowUpTaskList = lazy(() => import('./pages/health/FollowUpTaskList'));
const FollowUpRecordList = lazy(() => import('./pages/health/FollowUpRecordList'));
const ConsultationList = lazy(() => import('./pages/health/ConsultationList'));
const ConsultationDetail = lazy(() => import('./pages/health/ConsultationDetail'));
+const PointsRuleList = lazy(() => import('./pages/health/PointsRuleList'));
+const PointsProductList = lazy(() => import('./pages/health/PointsProductList'));
+const PointsOrderList = lazy(() => import('./pages/health/PointsOrderList'));
function PrivateRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
@@ -176,6 +179,9 @@ export default function App() {
} />
} />
} />
+ } />
+ } />
+ } />
diff --git a/apps/web/src/api/health/points.ts b/apps/web/src/api/health/points.ts
new file mode 100644
index 0000000..0af35ef
--- /dev/null
+++ b/apps/web/src/api/health/points.ts
@@ -0,0 +1,131 @@
+import client from '../client';
+import type { PaginatedResponse } from '../types';
+
+// --- Types ---
+
+export interface PointsRule {
+ id: string;
+ event_type: string;
+ name: string;
+ description: string | null;
+ points_value: number;
+ daily_cap: number;
+ streak_7d_bonus: number;
+ streak_14d_bonus: number;
+ streak_30d_bonus: number;
+ is_active: boolean;
+ created_at: string;
+ updated_at: string;
+ version: number;
+}
+
+export interface CreatePointsRuleReq {
+ event_type: string;
+ name: string;
+ description?: string;
+ points_value: number;
+ daily_cap?: number;
+ streak_7d_bonus?: number;
+ streak_14d_bonus?: number;
+ streak_30d_bonus?: number;
+}
+
+export interface PointsProduct {
+ id: string;
+ name: string;
+ product_type: string; // physical / service / privilege
+ points_cost: number;
+ stock: number;
+ image_url: string | null;
+ description: string | null;
+ is_active: boolean;
+ sort_order: number;
+ created_at: string;
+ updated_at: string;
+ version: number;
+}
+
+export interface CreatePointsProductReq {
+ name: string;
+ product_type: string;
+ points_cost: number;
+ stock: number;
+ description?: string;
+ image_url?: string;
+ sort_order?: number;
+}
+
+export interface PointsOrder {
+ id: string;
+ patient_id: string;
+ product_id: string;
+ points_cost: number;
+ status: string; // pending / verified / cancelled / expired
+ qr_code: string;
+ verified_by: string | null;
+ verified_at: string | null;
+ expires_at: string | null;
+ notes: string | null;
+ created_at: string;
+ updated_at: string;
+ version: number;
+}
+
+export interface VerifyOrderReq {
+ qr_code: string;
+}
+
+// --- API ---
+
+export const pointsApi = {
+ // Rules
+ listRules: async () => {
+ const { data } = await client.get<{
+ success: boolean;
+ data: PointsRule[];
+ }>('/health/admin/points/rules');
+ return data.data;
+ },
+
+ createRule: async (req: CreatePointsRuleReq) => {
+ const { data } = await client.post<{
+ success: boolean;
+ data: PointsRule;
+ }>('/health/admin/points/rules', req);
+ return data.data;
+ },
+
+ // Products
+ listProducts: async (params?: Record) => {
+ const { data } = await client.get<{
+ success: boolean;
+ data: PaginatedResponse;
+ }>('/health/points/products', { params });
+ return data.data;
+ },
+
+ createProduct: async (req: CreatePointsProductReq) => {
+ const { data } = await client.post<{
+ success: boolean;
+ data: PointsProduct;
+ }>('/health/admin/points/products', req);
+ return data.data;
+ },
+
+ // Orders
+ listOrders: async (params?: Record) => {
+ const { data } = await client.get<{
+ success: boolean;
+ data: PaginatedResponse;
+ }>('/health/admin/points/orders', { params });
+ return data.data;
+ },
+
+ verifyOrder: async (req: VerifyOrderReq) => {
+ const { data } = await client.post<{
+ success: boolean;
+ data: PointsOrder;
+ }>('/health/points/verify', req);
+ return data.data;
+ },
+};
diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx
index fff5e24..641cc2a 100644
--- a/apps/web/src/layouts/MainLayout.tsx
+++ b/apps/web/src/layouts/MainLayout.tsx
@@ -24,6 +24,9 @@ import {
PhoneOutlined,
CommentOutlined,
MedicineBoxOutlined,
+ TrophyOutlined,
+ ShopOutlined,
+ FileTextOutlined,
} from '@ant-design/icons';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAppStore } from '../stores/app';
@@ -60,6 +63,9 @@ const healthMenuItems: MenuItem[] = [
{ key: '/health/follow-up-tasks', icon: , label: '随访管理' },
{ key: '/health/consultations', icon: , label: '咨询管理' },
{ key: '/health/tags', icon: , label: '标签管理' },
+ { key: '/health/points-rules', icon: , label: '积分规则' },
+ { key: '/health/points-products', icon: , label: '商品管理' },
+ { key: '/health/points-orders', icon: , label: '订单管理' },
];
const sysMenuItems: MenuItem[] = [
@@ -86,6 +92,9 @@ const routeTitleMap: Record = {
'/health/follow-up-records': '随访记录',
'/health/consultations': '咨询管理',
'/health/consultations/:id': '咨询详情',
+ '/health/points-rules': '积分规则管理',
+ '/health/points-products': '商品管理',
+ '/health/points-orders': '订单管理',
};
// 侧边栏菜单项 - 提取为独立组件避免重复渲染
diff --git a/apps/web/src/pages/health/PointsOrderList.tsx b/apps/web/src/pages/health/PointsOrderList.tsx
new file mode 100644
index 0000000..552adea
--- /dev/null
+++ b/apps/web/src/pages/health/PointsOrderList.tsx
@@ -0,0 +1,271 @@
+import { useEffect, useState, useCallback } from 'react';
+import {
+ Table,
+ Button,
+ Space,
+ Modal,
+ Form,
+ Input,
+ Select,
+ Badge,
+ message,
+ Card,
+ Row,
+ Col,
+ Tag,
+} from 'antd';
+import {
+ CheckCircleOutlined,
+} from '@ant-design/icons';
+import dayjs from 'dayjs';
+import {
+ pointsApi,
+ type PointsOrder,
+} from '../../api/health/points';
+
+/** 订单状态映射 */
+const STATUS_MAP: Record = {
+ pending: { text: '待核销', color: 'orange' },
+ verified: { text: '已核销', color: 'green' },
+ cancelled: { text: '已取消', color: 'red' },
+ expired: { text: '已过期', color: 'default' },
+};
+
+/** 状态筛选选项 */
+const STATUS_OPTIONS = Object.entries(STATUS_MAP).map(([value, { text }]) => ({
+ value,
+ label: text,
+}));
+
+/** 截断 ID 显示 */
+function truncateId(id: string): string {
+ if (!id) return '-';
+ return id.length > 12 ? `${id.slice(0, 8)}...${id.slice(-4)}` : id;
+}
+
+export default function PointsOrderList() {
+ const [data, setData] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [page, setPage] = useState(1);
+ const [pageSize, setPageSize] = useState(20);
+ const [loading, setLoading] = useState(false);
+ const [statusFilter, setStatusFilter] = useState(undefined);
+ const [verifyModalOpen, setVerifyModalOpen] = useState(false);
+ const [verifyForm] = Form.useForm();
+ const [verifying, setVerifying] = useState(false);
+
+ // ---- 数据获取 ----
+ const fetchData = useCallback(async (p = page, ps = pageSize) => {
+ setLoading(true);
+ try {
+ const result = await pointsApi.listOrders({
+ page: p,
+ page_size: ps,
+ status: statusFilter || undefined,
+ });
+ setData(result.data);
+ setTotal(result.total);
+ } catch {
+ message.error('加载订单列表失败');
+ } finally {
+ setLoading(false);
+ }
+ }, [page, pageSize, statusFilter]);
+
+ useEffect(() => {
+ fetchData();
+ }, [fetchData]);
+
+ // ---- 核销 ----
+ const openVerifyModal = () => {
+ verifyForm.resetFields();
+ setVerifyModalOpen(true);
+ };
+
+ const handleVerify = async (values: { qr_code: string }) => {
+ setVerifying(true);
+ try {
+ const order = await pointsApi.verifyOrder({ qr_code: values.qr_code });
+ message.success(`核销成功,订单 ${truncateId(order.id)} 已确认`);
+ setVerifyModalOpen(false);
+ verifyForm.resetFields();
+ fetchData(page, pageSize);
+ } catch {
+ message.error('核销失败,请检查二维码是否正确');
+ } finally {
+ setVerifying(false);
+ }
+ };
+
+ // ---- 列定义 ----
+ const columns = [
+ {
+ title: '订单号',
+ dataIndex: 'id',
+ key: 'id',
+ width: 140,
+ render: (val: string) => (
+ {truncateId(val)}
+ ),
+ },
+ {
+ title: '患者ID',
+ dataIndex: 'patient_id',
+ key: 'patient_id',
+ width: 140,
+ render: (val: string) => (
+ {truncateId(val)}
+ ),
+ },
+ {
+ title: '商品ID',
+ dataIndex: 'product_id',
+ key: 'product_id',
+ width: 140,
+ render: (val: string) => (
+ {truncateId(val)}
+ ),
+ },
+ {
+ title: '积分',
+ dataIndex: 'points_cost',
+ key: 'points_cost',
+ width: 80,
+ render: (val: number) => {val},
+ },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ key: 'status',
+ width: 100,
+ render: (val: string) => {
+ const cfg = STATUS_MAP[val] || { text: val, color: 'default' };
+ return ;
+ },
+ },
+ {
+ title: '创建时间',
+ dataIndex: 'created_at',
+ key: 'created_at',
+ width: 170,
+ render: (val: string) => (val ? dayjs(val).format('YYYY-MM-DD HH:mm') : '-'),
+ },
+ {
+ title: '核销时间',
+ dataIndex: 'verified_at',
+ key: 'verified_at',
+ width: 170,
+ render: (val: string) => (val ? dayjs(val).format('YYYY-MM-DD HH:mm') : '-'),
+ },
+ {
+ title: '核销人',
+ dataIndex: 'verified_by',
+ key: 'verified_by',
+ width: 140,
+ render: (val: string | null) => val ? {truncateId(val)} : '-',
+ },
+ {
+ title: '过期时间',
+ dataIndex: 'expires_at',
+ key: 'expires_at',
+ width: 170,
+ render: (val: string | null) => {
+ if (!val) return '-';
+ const isExpired = dayjs(val).isBefore(dayjs());
+ return (
+
+ {dayjs(val).format('YYYY-MM-DD HH:mm')}
+
+ );
+ },
+ },
+ {
+ title: '备注',
+ dataIndex: 'notes',
+ key: 'notes',
+ width: 150,
+ ellipsis: true,
+ render: (val: string | null) => val || '-',
+ },
+ ];
+
+ return (
+
+ {/* 筛选栏 */}
+
+
+
+
+
+
+ }
+ onClick={openVerifyModal}
+ >
+ 核销订单
+
+
+
+
+ {/* 数据表格 */}
+ `共 ${t} 条`,
+ onChange: (p, ps) => {
+ setPage(p);
+ setPageSize(ps);
+ },
+ }}
+ />
+
+ {/* 核销弹窗 */}
+ {
+ setVerifyModalOpen(false);
+ verifyForm.resetFields();
+ }}
+ onOk={() => verifyForm.submit()}
+ confirmLoading={verifying}
+ destroyOnClose
+ width={440}
+ >
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/pages/health/PointsProductList.tsx b/apps/web/src/pages/health/PointsProductList.tsx
new file mode 100644
index 0000000..91aa2c3
--- /dev/null
+++ b/apps/web/src/pages/health/PointsProductList.tsx
@@ -0,0 +1,333 @@
+import { useEffect, useState, useCallback } from 'react';
+import {
+ Table,
+ Button,
+ Space,
+ Modal,
+ Form,
+ Input,
+ InputNumber,
+ Select,
+ Tag,
+ Badge,
+ Popconfirm,
+ message,
+ Card,
+ Row,
+ Col,
+} from 'antd';
+import {
+ PlusOutlined,
+ EditOutlined,
+ DeleteOutlined,
+} from '@ant-design/icons';
+import dayjs from 'dayjs';
+import {
+ pointsApi,
+ type PointsProduct,
+ type CreatePointsProductReq,
+} from '../../api/health/points';
+
+/** 商品类型映射 */
+const PRODUCT_TYPES: Record = {
+ physical: '实物',
+ service: '服务券',
+ privilege: '权益',
+};
+
+/** 商品类型选项 */
+const PRODUCT_TYPE_OPTIONS = Object.entries(PRODUCT_TYPES).map(([value, label]) => ({
+ value,
+ label,
+}));
+
+/** 商品类型颜色映射 */
+const PRODUCT_TYPE_COLORS: Record = {
+ physical: 'blue',
+ service: 'green',
+ privilege: 'purple',
+};
+
+export default function PointsProductList() {
+ const [data, setData] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [page, setPage] = useState(1);
+ const [pageSize, setPageSize] = useState(20);
+ const [loading, setLoading] = useState(false);
+ const [typeFilter, setTypeFilter] = useState(undefined);
+ const [modalOpen, setModalOpen] = useState(false);
+ const [editing, setEditing] = useState(null);
+ const [form] = Form.useForm();
+
+ // ---- 数据获取 ----
+ const fetchData = useCallback(async (p = page, ps = pageSize) => {
+ setLoading(true);
+ try {
+ const result = await pointsApi.listProducts({
+ page: p,
+ page_size: ps,
+ product_type: typeFilter || undefined,
+ });
+ setData(result.data);
+ setTotal(result.total);
+ } catch {
+ message.error('加载商品列表失败');
+ } finally {
+ setLoading(false);
+ }
+ }, [page, pageSize, typeFilter]);
+
+ useEffect(() => {
+ fetchData();
+ }, [fetchData]);
+
+ // ---- 新建 / 编辑 ----
+ const openCreate = () => {
+ setEditing(null);
+ form.resetFields();
+ form.setFieldsValue({ stock: -1, sort_order: 0 });
+ setModalOpen(true);
+ };
+
+ const openEdit = (record: PointsProduct) => {
+ setEditing(record);
+ form.setFieldsValue({
+ name: record.name,
+ product_type: record.product_type,
+ points_cost: record.points_cost,
+ stock: record.stock,
+ description: record.description,
+ image_url: record.image_url,
+ sort_order: record.sort_order,
+ });
+ setModalOpen(true);
+ };
+
+ const handleSubmit = async (values: {
+ name: string;
+ product_type: string;
+ points_cost: number;
+ stock: number;
+ description?: string;
+ image_url?: string;
+ sort_order?: number;
+ }) => {
+ try {
+ const req: CreatePointsProductReq = {
+ name: values.name,
+ product_type: values.product_type,
+ points_cost: values.points_cost,
+ stock: values.stock,
+ description: values.description,
+ image_url: values.image_url,
+ sort_order: values.sort_order,
+ };
+ await pointsApi.createProduct(req);
+ message.success(editing ? '更新成功' : '创建成功');
+ setModalOpen(false);
+ form.resetFields();
+ fetchData(page, pageSize);
+ } catch {
+ message.error(editing ? '更新失败' : '创建失败');
+ }
+ };
+
+ // ---- 删除 ----
+ const handleDelete = async (_id: string) => {
+ message.info('当前版本暂不支持单独删除商品');
+ };
+
+ // ---- 列定义 ----
+ const columns = [
+ {
+ title: '商品名称',
+ dataIndex: 'name',
+ key: 'name',
+ width: 160,
+ },
+ {
+ title: '类型',
+ dataIndex: 'product_type',
+ key: 'product_type',
+ width: 100,
+ render: (val: string) => (
+
+ {PRODUCT_TYPES[val] || val}
+
+ ),
+ },
+ {
+ title: '积分',
+ dataIndex: 'points_cost',
+ key: 'points_cost',
+ width: 90,
+ render: (val: number) => {val},
+ },
+ {
+ title: '库存',
+ dataIndex: 'stock',
+ key: 'stock',
+ width: 90,
+ render: (val: number) => (val === -1 ? 无限 : val),
+ },
+ {
+ title: '排序',
+ dataIndex: 'sort_order',
+ key: 'sort_order',
+ width: 70,
+ render: (val: number) => val ?? 0,
+ },
+ {
+ title: '状态',
+ dataIndex: 'is_active',
+ key: 'is_active',
+ width: 80,
+ render: (val: boolean) => (
+
+ ),
+ },
+ {
+ title: '更新时间',
+ dataIndex: 'updated_at',
+ key: 'updated_at',
+ width: 170,
+ render: (val: string) => (val ? dayjs(val).format('YYYY-MM-DD HH:mm') : '-'),
+ },
+ {
+ title: '操作',
+ key: 'action',
+ width: 140,
+ render: (_: unknown, record: PointsProduct) => (
+
+ }
+ onClick={() => openEdit(record)}
+ >
+ 编辑
+
+ handleDelete(record.id)}
+ okText="确定"
+ cancelText="取消"
+ >
+ }>
+ 删除
+
+
+
+ ),
+ },
+ ];
+
+ return (
+
+ {/* 筛选栏 */}
+
+
+
+
+
+
+ } onClick={openCreate}>
+ 新建商品
+
+
+
+
+ {/* 数据表格 */}
+ `共 ${t} 条`,
+ onChange: (p, ps) => {
+ setPage(p);
+ setPageSize(ps);
+ },
+ }}
+ />
+
+ {/* 新建 / 编辑弹窗 */}
+ {
+ setModalOpen(false);
+ form.resetFields();
+ }}
+ onOk={() => form.submit()}
+ destroyOnClose
+ width={560}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/pages/health/PointsRuleList.tsx b/apps/web/src/pages/health/PointsRuleList.tsx
new file mode 100644
index 0000000..2de7530
--- /dev/null
+++ b/apps/web/src/pages/health/PointsRuleList.tsx
@@ -0,0 +1,342 @@
+import { useEffect, useState, useCallback } from 'react';
+import {
+ Table,
+ Button,
+ Space,
+ Modal,
+ Form,
+ Input,
+ InputNumber,
+ Select,
+ Tag,
+ Badge,
+ Popconfirm,
+ message,
+ Card,
+ Row,
+ Col,
+ Switch,
+} from 'antd';
+import {
+ PlusOutlined,
+ EditOutlined,
+ DeleteOutlined,
+} from '@ant-design/icons';
+import dayjs from 'dayjs';
+import {
+ pointsApi,
+ type PointsRule,
+ type CreatePointsRuleReq,
+} from '../../api/health/points';
+
+/** 事件类型映射 */
+const EVENT_TYPES: Record = {
+ checkin: '每日打卡',
+ data_report: '数据上报',
+ lab_upload: '化验上传',
+ event_checkin: '活动签到',
+ consultation_complete: '咨询完成',
+ followup_complete: '随访完成',
+};
+
+/** 事件类型选项 */
+const EVENT_TYPE_OPTIONS = Object.entries(EVENT_TYPES).map(([value, label]) => ({
+ value,
+ label,
+}));
+
+export default function PointsRuleList() {
+ const [data, setData] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [modalOpen, setModalOpen] = useState(false);
+ const [editing, setEditing] = useState(null);
+ const [form] = Form.useForm();
+
+ // ---- 数据获取 ----
+ const fetchData = useCallback(async () => {
+ setLoading(true);
+ try {
+ const result = await pointsApi.listRules();
+ setData(result);
+ } catch {
+ message.error('加载积分规则失败');
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchData();
+ }, [fetchData]);
+
+ // ---- 新建 / 编辑 ----
+ const openCreate = () => {
+ setEditing(null);
+ form.resetFields();
+ setModalOpen(true);
+ };
+
+ const openEdit = (record: PointsRule) => {
+ setEditing(record);
+ form.setFieldsValue({
+ event_type: record.event_type,
+ name: record.name,
+ description: record.description,
+ points_value: record.points_value,
+ daily_cap: record.daily_cap,
+ streak_7d_bonus: record.streak_7d_bonus,
+ streak_14d_bonus: record.streak_14d_bonus,
+ streak_30d_bonus: record.streak_30d_bonus,
+ });
+ setModalOpen(true);
+ };
+
+ const handleSubmit = async (values: {
+ event_type: string;
+ name: string;
+ description?: string;
+ points_value: number;
+ daily_cap?: number;
+ streak_7d_bonus?: number;
+ streak_14d_bonus?: number;
+ streak_30d_bonus?: number;
+ }) => {
+ try {
+ const req: CreatePointsRuleReq = {
+ event_type: values.event_type,
+ name: values.name,
+ description: values.description,
+ points_value: values.points_value,
+ daily_cap: values.daily_cap,
+ streak_7d_bonus: values.streak_7d_bonus,
+ streak_14d_bonus: values.streak_14d_bonus,
+ streak_30d_bonus: values.streak_30d_bonus,
+ };
+ await pointsApi.createRule(req);
+ message.success(editing ? '更新成功' : '创建成功');
+ setModalOpen(false);
+ form.resetFields();
+ fetchData();
+ } catch {
+ message.error(editing ? '更新失败' : '创建失败');
+ }
+ };
+
+ // ---- 切换启用状态 ----
+ const handleToggleActive = async (record: PointsRule) => {
+ try {
+ // 目前后端没有 toggle 接口,重新创建等同于更新
+ // 使用 create 接口覆盖同 event_type 的规则
+ const req: CreatePointsRuleReq = {
+ event_type: record.event_type,
+ name: record.name,
+ description: record.description ?? undefined,
+ points_value: record.points_value,
+ daily_cap: record.daily_cap,
+ streak_7d_bonus: record.streak_7d_bonus,
+ streak_14d_bonus: record.streak_14d_bonus,
+ streak_30d_bonus: record.streak_30d_bonus,
+ };
+ await pointsApi.createRule(req);
+ message.success(record.is_active ? '已停用' : '已启用');
+ fetchData();
+ } catch {
+ message.error('操作失败');
+ }
+ };
+
+ // ---- 删除 ----
+ const handleDelete = async (_id: string) => {
+ message.info('当前版本通过重新创建规则覆盖,暂不支持单独删除');
+ };
+
+ // ---- 列定义 ----
+ const columns = [
+ {
+ title: '规则名称',
+ dataIndex: 'name',
+ key: 'name',
+ width: 140,
+ },
+ {
+ title: '事件类型',
+ dataIndex: 'event_type',
+ key: 'event_type',
+ width: 120,
+ render: (val: string) => (
+ {EVENT_TYPES[val] || val}
+ ),
+ },
+ {
+ title: '积分值',
+ dataIndex: 'points_value',
+ key: 'points_value',
+ width: 80,
+ render: (val: number) => +{val},
+ },
+ {
+ title: '每日上限',
+ dataIndex: 'daily_cap',
+ key: 'daily_cap',
+ width: 90,
+ render: (val: number) => (val === -1 ? '无限' : val),
+ },
+ {
+ title: '7日奖励',
+ dataIndex: 'streak_7d_bonus',
+ key: 'streak_7d_bonus',
+ width: 90,
+ render: (val: number) => (val > 0 ? +{val} : '-'),
+ },
+ {
+ title: '14日奖励',
+ dataIndex: 'streak_14d_bonus',
+ key: 'streak_14d_bonus',
+ width: 90,
+ render: (val: number) => (val > 0 ? +{val} : '-'),
+ },
+ {
+ title: '30日奖励',
+ dataIndex: 'streak_30d_bonus',
+ key: 'streak_30d_bonus',
+ width: 90,
+ render: (val: number) => (val > 0 ? +{val} : '-'),
+ },
+ {
+ title: '状态',
+ dataIndex: 'is_active',
+ key: 'is_active',
+ width: 80,
+ render: (val: boolean) => (
+
+ ),
+ },
+ {
+ title: '更新时间',
+ dataIndex: 'updated_at',
+ key: 'updated_at',
+ width: 170,
+ render: (val: string) => (val ? dayjs(val).format('YYYY-MM-DD HH:mm') : '-'),
+ },
+ {
+ title: '操作',
+ key: 'action',
+ width: 200,
+ render: (_: unknown, record: PointsRule) => (
+
+ }
+ onClick={() => openEdit(record)}
+ >
+ 编辑
+
+ handleToggleActive(record)}
+ />
+ handleDelete(record.id)}
+ okText="确定"
+ cancelText="取消"
+ >
+ }>
+ 删除
+
+
+
+ ),
+ },
+ ];
+
+ return (
+
+ {/* 筛选栏 */}
+
+
+
+ 积分规则定义各类健康行为对应的积分奖励,含连续打卡额外奖励
+
+
+
+ } onClick={openCreate}>
+ 新建规则
+
+
+
+
+ {/* 数据表格 */}
+
+
+ {/* 新建 / 编辑弹窗 */}
+ {
+ setModalOpen(false);
+ form.resetFields();
+ }}
+ onOk={() => form.submit()}
+ destroyOnClose
+ width={560}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/crates/erp-health/src/dto/daily_monitoring_dto.rs b/crates/erp-health/src/dto/daily_monitoring_dto.rs
new file mode 100644
index 0000000..16af755
--- /dev/null
+++ b/crates/erp-health/src/dto/daily_monitoring_dto.rs
@@ -0,0 +1,72 @@
+use chrono::NaiveDate;
+use erp_core::sanitize::sanitize_option;
+use serde::{Deserialize, Serialize};
+use utoipa::ToSchema;
+use uuid::Uuid;
+
+type Decimal = f64;
+
+// ---------------------------------------------------------------------------
+// 日常监测
+// ---------------------------------------------------------------------------
+
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+pub struct CreateDailyMonitoringReq {
+ pub patient_id: Uuid,
+ pub record_date: NaiveDate,
+ pub morning_bp_systolic: Option,
+ pub morning_bp_diastolic: Option,
+ pub evening_bp_systolic: Option,
+ pub evening_bp_diastolic: Option,
+ pub weight: Option,
+ pub blood_sugar: Option,
+ pub fluid_intake: Option,
+ pub urine_output: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub notes: Option,
+}
+
+impl CreateDailyMonitoringReq {
+ pub fn sanitize(&mut self) {
+ self.notes = sanitize_option(self.notes.take());
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+pub struct UpdateDailyMonitoringReq {
+ pub record_date: Option,
+ pub morning_bp_systolic: Option,
+ pub morning_bp_diastolic: Option,
+ pub evening_bp_systolic: Option,
+ pub evening_bp_diastolic: Option,
+ pub weight: Option,
+ pub blood_sugar: Option,
+ pub fluid_intake: Option,
+ pub urine_output: Option,
+ pub notes: Option,
+}
+
+impl UpdateDailyMonitoringReq {
+ pub fn sanitize(&mut self) {
+ self.notes = sanitize_option(self.notes.take());
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+pub struct DailyMonitoringResp {
+ pub id: Uuid,
+ pub patient_id: Uuid,
+ pub record_date: NaiveDate,
+ pub morning_bp_systolic: Option,
+ pub morning_bp_diastolic: Option,
+ pub evening_bp_systolic: Option,
+ pub evening_bp_diastolic: Option,
+ pub weight: Option,
+ pub blood_sugar: Option,
+ pub fluid_intake: Option,
+ pub urine_output: Option,
+ pub notes: Option,
+ pub created_at: chrono::DateTime,
+ pub updated_at: chrono::DateTime,
+ pub version: i32,
+}
diff --git a/crates/erp-health/src/dto/mod.rs b/crates/erp-health/src/dto/mod.rs
index 561778e..5a22a48 100644
--- a/crates/erp-health/src/dto/mod.rs
+++ b/crates/erp-health/src/dto/mod.rs
@@ -1,6 +1,7 @@
pub mod appointment_dto;
pub mod article_dto;
pub mod consultation_dto;
+pub mod daily_monitoring_dto;
pub mod dialysis_dto;
pub mod doctor_dto;
pub mod follow_up_dto;
diff --git a/crates/erp-health/src/entity/daily_monitoring.rs b/crates/erp-health/src/entity/daily_monitoring.rs
new file mode 100644
index 0000000..c61d5fa
--- /dev/null
+++ b/crates/erp-health/src/entity/daily_monitoring.rs
@@ -0,0 +1,57 @@
+use sea_orm::entity::prelude::*;
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
+#[sea_orm(table_name = "daily_monitoring")]
+pub struct Model {
+ #[sea_orm(primary_key, auto_increment = false)]
+ pub id: Uuid,
+ pub tenant_id: Uuid,
+ pub patient_id: Uuid,
+ pub record_date: chrono::NaiveDate,
+ #[sea_orm(skip_serializing_if = "Option::is_none")]
+ pub morning_bp_systolic: Option,
+ #[sea_orm(skip_serializing_if = "Option::is_none")]
+ pub morning_bp_diastolic: Option,
+ #[sea_orm(skip_serializing_if = "Option::is_none")]
+ pub evening_bp_systolic: Option,
+ #[sea_orm(skip_serializing_if = "Option::is_none")]
+ pub evening_bp_diastolic: Option,
+ #[sea_orm(skip_serializing_if = "Option::is_none")]
+ pub weight: Option,
+ #[sea_orm(skip_serializing_if = "Option::is_none")]
+ pub blood_sugar: Option,
+ #[sea_orm(skip_serializing_if = "Option::is_none")]
+ pub fluid_intake: Option,
+ #[sea_orm(skip_serializing_if = "Option::is_none")]
+ pub urine_output: Option,
+ #[sea_orm(skip_serializing_if = "Option::is_none")]
+ pub notes: Option,
+ pub created_at: DateTimeUtc,
+ pub updated_at: DateTimeUtc,
+ #[sea_orm(skip_serializing_if = "Option::is_none")]
+ pub created_by: Option,
+ #[sea_orm(skip_serializing_if = "Option::is_none")]
+ pub updated_by: Option,
+ #[sea_orm(skip_serializing_if = "Option::is_none")]
+ pub deleted_at: Option,
+ pub version: i32,
+}
+
+#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+pub enum Relation {
+ #[sea_orm(
+ belongs_to = "super::patient::Entity",
+ from = "Column::PatientId",
+ to = "super::patient::Column::Id"
+ )]
+ Patient,
+}
+
+impl Related for Entity {
+ fn to() -> RelationDef {
+ Relation::Patient.def()
+ }
+}
+
+impl ActiveModelBehavior for ActiveModel {}
diff --git a/crates/erp-health/src/entity/mod.rs b/crates/erp-health/src/entity/mod.rs
index 1802fde..19ba480 100644
--- a/crates/erp-health/src/entity/mod.rs
+++ b/crates/erp-health/src/entity/mod.rs
@@ -2,6 +2,7 @@ pub mod appointment;
pub mod article;
pub mod consultation_message;
pub mod consultation_session;
+pub mod daily_monitoring;
pub mod dialysis_record;
pub mod doctor_profile;
pub mod doctor_schedule;
diff --git a/crates/erp-health/src/error.rs b/crates/erp-health/src/error.rs
index b1d4957..906bb3f 100644
--- a/crates/erp-health/src/error.rs
+++ b/crates/erp-health/src/error.rs
@@ -26,6 +26,9 @@ pub enum HealthError {
#[error("透析记录不存在")]
DialysisRecordNotFound,
+ #[error("日常监测记录不存在")]
+ DailyMonitoringNotFound,
+
#[error("兑换商品不存在")]
PointsProductNotFound,
@@ -85,7 +88,8 @@ impl From for AppError {
| HealthError::ArticleNotFound
| HealthError::PointsProductNotFound
| HealthError::PointsOrderNotFound
- | HealthError::OfflineEventNotFound => AppError::NotFound(err.to_string()),
+ | HealthError::OfflineEventNotFound
+ | HealthError::DailyMonitoringNotFound => AppError::NotFound(err.to_string()),
HealthError::ScheduleFull => AppError::Validation(err.to_string()),
HealthError::InvalidStatusTransition(s) => AppError::Validation(s),
HealthError::VersionMismatch => AppError::VersionMismatch,
diff --git a/crates/erp-health/src/handler/daily_monitoring_handler.rs b/crates/erp-health/src/handler/daily_monitoring_handler.rs
new file mode 100644
index 0000000..b5c813e
--- /dev/null
+++ b/crates/erp-health/src/handler/daily_monitoring_handler.rs
@@ -0,0 +1,121 @@
+use axum::Extension;
+use axum::extract::{FromRef, Json, Path, Query, State};
+use serde::Deserialize;
+use utoipa::IntoParams;
+use uuid::Uuid;
+
+use erp_core::error::AppError;
+use erp_core::rbac::require_permission;
+use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
+
+use crate::dto::daily_monitoring_dto::*;
+use crate::dto::DeleteWithVersion;
+use crate::service::daily_monitoring_service;
+use crate::state::HealthState;
+
+#[derive(Debug, Deserialize, IntoParams)]
+pub struct PaginationParams {
+ pub page: Option,
+ pub page_size: Option,
+}
+
+#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
+pub struct UpdateDailyMonitoringWithVersion {
+ #[serde(flatten)]
+ pub data: UpdateDailyMonitoringReq,
+ pub version: i32,
+}
+
+pub async fn list_daily_monitoring(
+ State(state): State,
+ Extension(ctx): Extension,
+ Path(patient_id): Path,
+ Query(params): Query,
+) -> Result>>, AppError>
+where
+ HealthState: FromRef,
+ S: Clone + Send + Sync + 'static,
+{
+ require_permission(&ctx, "health.health-data.list")?;
+ let page = params.page.unwrap_or(1);
+ let page_size = params.page_size.unwrap_or(20);
+ let result = daily_monitoring_service::list_daily_monitoring(
+ &state, ctx.tenant_id, patient_id, page, page_size,
+ )
+ .await?;
+ Ok(Json(ApiResponse::ok(result)))
+}
+
+pub async fn get_daily_monitoring(
+ State(state): State,
+ Extension(ctx): Extension,
+ Path(record_id): Path,
+) -> Result>, AppError>
+where
+ HealthState: FromRef,
+ S: Clone + Send + Sync + 'static,
+{
+ require_permission(&ctx, "health.health-data.list")?;
+ let result = daily_monitoring_service::get_daily_monitoring(
+ &state, ctx.tenant_id, record_id,
+ )
+ .await?;
+ Ok(Json(ApiResponse::ok(result)))
+}
+
+pub async fn create_daily_monitoring(
+ State(state): State,
+ Extension(ctx): Extension,
+ Json(req): Json,
+) -> Result>, AppError>
+where
+ HealthState: FromRef,
+ S: Clone + Send + Sync + 'static,
+{
+ require_permission(&ctx, "health.health-data.manage")?;
+ let mut req = req;
+ req.sanitize();
+ let result = daily_monitoring_service::create_daily_monitoring(
+ &state, ctx.tenant_id, Some(ctx.user_id), req,
+ )
+ .await?;
+ Ok(Json(ApiResponse::ok(result)))
+}
+
+pub async fn update_daily_monitoring(
+ State(state): State,
+ Extension(ctx): Extension,
+ Path(record_id): Path,
+ Json(req): Json,
+) -> Result>, AppError>
+where
+ HealthState: FromRef,
+ S: Clone + Send + Sync + 'static,
+{
+ require_permission(&ctx, "health.health-data.manage")?;
+ let mut data = req.data;
+ data.sanitize();
+ let result = daily_monitoring_service::update_daily_monitoring(
+ &state, ctx.tenant_id, record_id, Some(ctx.user_id), data, req.version,
+ )
+ .await?;
+ Ok(Json(ApiResponse::ok(result)))
+}
+
+pub async fn delete_daily_monitoring(
+ State(state): State,
+ Extension(ctx): Extension,
+ Path(record_id): Path,
+ Json(req): Json,
+) -> Result>, AppError>
+where
+ HealthState: FromRef,
+ S: Clone + Send + Sync + 'static,
+{
+ require_permission(&ctx, "health.health-data.manage")?;
+ daily_monitoring_service::delete_daily_monitoring(
+ &state, ctx.tenant_id, record_id, Some(ctx.user_id), req.version,
+ )
+ .await?;
+ Ok(Json(ApiResponse::ok(())))
+}
diff --git a/crates/erp-health/src/handler/mod.rs b/crates/erp-health/src/handler/mod.rs
index 5cd336c..9925f0d 100644
--- a/crates/erp-health/src/handler/mod.rs
+++ b/crates/erp-health/src/handler/mod.rs
@@ -1,6 +1,7 @@
pub mod appointment_handler;
pub mod article_handler;
pub mod consultation_handler;
+pub mod daily_monitoring_handler;
pub mod dialysis_handler;
pub mod doctor_handler;
pub mod follow_up_handler;
diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs
index 20b2f0c..f5cb753 100644
--- a/crates/erp-health/src/module.rs
+++ b/crates/erp-health/src/module.rs
@@ -6,7 +6,7 @@ use erp_core::events::EventBus;
use erp_core::module::{ErpModule, PermissionDescriptor};
use crate::handler::{
- appointment_handler, article_handler, consultation_handler, dialysis_handler, doctor_handler, follow_up_handler,
+ appointment_handler, article_handler, consultation_handler, daily_monitoring_handler, dialysis_handler, doctor_handler, follow_up_handler,
health_data_handler, patient_handler, points_handler,
};
@@ -163,6 +163,21 @@ impl HealthModule {
"/health/dialysis-records/{id}/review",
axum::routing::put(dialysis_handler::review_dialysis_record),
)
+ // 日常监测
+ .route(
+ "/health/patients/{id}/daily-monitoring",
+ axum::routing::get(daily_monitoring_handler::list_daily_monitoring),
+ )
+ .route(
+ "/health/daily-monitoring",
+ axum::routing::post(daily_monitoring_handler::create_daily_monitoring),
+ )
+ .route(
+ "/health/daily-monitoring/{id}",
+ axum::routing::get(daily_monitoring_handler::get_daily_monitoring)
+ .put(daily_monitoring_handler::update_daily_monitoring)
+ .delete(daily_monitoring_handler::delete_daily_monitoring),
+ )
// 化验报告审阅
.route(
"/health/patients/{id}/lab-reports/{rid}/review",
diff --git a/crates/erp-health/src/service/daily_monitoring_service.rs b/crates/erp-health/src/service/daily_monitoring_service.rs
new file mode 100644
index 0000000..1d19db4
--- /dev/null
+++ b/crates/erp-health/src/service/daily_monitoring_service.rs
@@ -0,0 +1,221 @@
+//! 日常监测 Service — 患者每日血压/体重/血糖/出入量 CRUD
+
+use chrono::Utc;
+use erp_core::audit::AuditLog;
+use erp_core::audit_service;
+use num_traits::ToPrimitive;
+use sea_orm::entity::prelude::*;
+use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect};
+use uuid::Uuid;
+
+use erp_core::error::check_version;
+use erp_core::types::PaginatedResponse;
+
+use crate::dto::daily_monitoring_dto::*;
+use crate::entity::{daily_monitoring, patient};
+use crate::error::{HealthError, HealthResult};
+use crate::state::HealthState;
+
+pub async fn list_daily_monitoring(
+ state: &HealthState,
+ tenant_id: Uuid,
+ patient_id: Uuid,
+ page: u64,
+ page_size: u64,
+) -> HealthResult> {
+ let limit = page_size.min(100);
+ let offset = page.saturating_sub(1) * limit;
+
+ let query = daily_monitoring::Entity::find()
+ .filter(daily_monitoring::Column::TenantId.eq(tenant_id))
+ .filter(daily_monitoring::Column::PatientId.eq(patient_id))
+ .filter(daily_monitoring::Column::DeletedAt.is_null());
+
+ let total = query.clone().count(&state.db).await?;
+ let models = query
+ .order_by_desc(daily_monitoring::Column::RecordDate)
+ .offset(offset)
+ .limit(limit)
+ .all(&state.db)
+ .await?;
+
+ let total_pages = total.div_ceil(limit.max(1));
+ let data: Vec = models.into_iter().map(to_resp).collect();
+
+ Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
+}
+
+pub async fn get_daily_monitoring(
+ state: &HealthState,
+ tenant_id: Uuid,
+ record_id: Uuid,
+) -> HealthResult {
+ let m = daily_monitoring::Entity::find()
+ .filter(daily_monitoring::Column::Id.eq(record_id))
+ .filter(daily_monitoring::Column::TenantId.eq(tenant_id))
+ .filter(daily_monitoring::Column::DeletedAt.is_null())
+ .one(&state.db)
+ .await?
+ .ok_or(HealthError::DailyMonitoringNotFound)?;
+
+ Ok(to_resp(m))
+}
+
+pub async fn create_daily_monitoring(
+ state: &HealthState,
+ tenant_id: Uuid,
+ operator_id: Option,
+ req: CreateDailyMonitoringReq,
+) -> HealthResult {
+ // 验证患者存在且属于当前租户
+ patient::Entity::find()
+ .filter(patient::Column::Id.eq(req.patient_id))
+ .filter(patient::Column::TenantId.eq(tenant_id))
+ .filter(patient::Column::DeletedAt.is_null())
+ .one(&state.db)
+ .await?
+ .ok_or(HealthError::PatientNotFound)?;
+
+ // 唯一约束检查: 同一患者同一日期不能有两条记录
+ let existing = daily_monitoring::Entity::find()
+ .filter(daily_monitoring::Column::PatientId.eq(req.patient_id))
+ .filter(daily_monitoring::Column::RecordDate.eq(req.record_date))
+ .filter(daily_monitoring::Column::DeletedAt.is_null())
+ .one(&state.db)
+ .await?;
+
+ if existing.is_some() {
+ return Err(HealthError::Validation("该日期已有日常监测记录".to_string()));
+ }
+
+ let now = Utc::now();
+ let active = daily_monitoring::ActiveModel {
+ id: Set(Uuid::now_v7()),
+ tenant_id: Set(tenant_id),
+ patient_id: Set(req.patient_id),
+ record_date: Set(req.record_date),
+ morning_bp_systolic: Set(req.morning_bp_systolic),
+ morning_bp_diastolic: Set(req.morning_bp_diastolic),
+ evening_bp_systolic: Set(req.evening_bp_systolic),
+ evening_bp_diastolic: Set(req.evening_bp_diastolic),
+ weight: Set(req.weight.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
+ blood_sugar: Set(req.blood_sugar.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
+ fluid_intake: Set(req.fluid_intake),
+ urine_output: Set(req.urine_output),
+ notes: Set(req.notes),
+ created_at: Set(now),
+ updated_at: Set(now),
+ created_by: Set(operator_id),
+ updated_by: Set(operator_id),
+ deleted_at: Set(None),
+ version: Set(1),
+ };
+ let m = active.insert(&state.db).await?;
+
+ audit_service::record(
+ AuditLog::new(tenant_id, operator_id, "daily_monitoring.created", "daily_monitoring")
+ .with_resource_id(m.id),
+ &state.db,
+ ).await;
+
+ Ok(to_resp(m))
+}
+
+pub async fn update_daily_monitoring(
+ state: &HealthState,
+ tenant_id: Uuid,
+ record_id: Uuid,
+ operator_id: Option,
+ req: UpdateDailyMonitoringReq,
+ expected_version: i32,
+) -> HealthResult {
+ let model = daily_monitoring::Entity::find()
+ .filter(daily_monitoring::Column::Id.eq(record_id))
+ .filter(daily_monitoring::Column::TenantId.eq(tenant_id))
+ .filter(daily_monitoring::Column::DeletedAt.is_null())
+ .one(&state.db)
+ .await?
+ .ok_or(HealthError::DailyMonitoringNotFound)?;
+
+ let next_ver = check_version(expected_version, model.version)
+ .map_err(|_| HealthError::VersionMismatch)?;
+
+ let mut active: daily_monitoring::ActiveModel = model.into();
+ if let Some(v) = req.record_date { active.record_date = Set(v); }
+ if let Some(v) = req.morning_bp_systolic { active.morning_bp_systolic = Set(Some(v)); }
+ if let Some(v) = req.morning_bp_diastolic { active.morning_bp_diastolic = Set(Some(v)); }
+ if let Some(v) = req.evening_bp_systolic { active.evening_bp_systolic = Set(Some(v)); }
+ if let Some(v) = req.evening_bp_diastolic { active.evening_bp_diastolic = Set(Some(v)); }
+ if let Some(v) = req.weight { active.weight = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default())); }
+ if let Some(v) = req.blood_sugar { active.blood_sugar = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default())); }
+ if let Some(v) = req.fluid_intake { active.fluid_intake = Set(Some(v)); }
+ if let Some(v) = req.urine_output { active.urine_output = Set(Some(v)); }
+ if let Some(v) = req.notes { active.notes = Set(Some(v)); }
+ active.updated_at = Set(Utc::now());
+ active.updated_by = Set(operator_id);
+ active.version = Set(next_ver);
+
+ let m = active.update(&state.db).await?;
+
+ audit_service::record(
+ AuditLog::new(tenant_id, operator_id, "daily_monitoring.updated", "daily_monitoring")
+ .with_resource_id(m.id),
+ &state.db,
+ ).await;
+
+ Ok(to_resp(m))
+}
+
+pub async fn delete_daily_monitoring(
+ state: &HealthState,
+ tenant_id: Uuid,
+ record_id: Uuid,
+ operator_id: Option,
+ expected_version: i32,
+) -> HealthResult<()> {
+ let model = daily_monitoring::Entity::find()
+ .filter(daily_monitoring::Column::Id.eq(record_id))
+ .filter(daily_monitoring::Column::TenantId.eq(tenant_id))
+ .filter(daily_monitoring::Column::DeletedAt.is_null())
+ .one(&state.db)
+ .await?
+ .ok_or(HealthError::DailyMonitoringNotFound)?;
+
+ let next_ver = check_version(expected_version, model.version)
+ .map_err(|_| HealthError::VersionMismatch)?;
+
+ let mut active: daily_monitoring::ActiveModel = model.into();
+ active.deleted_at = Set(Some(Utc::now()));
+ active.updated_at = Set(Utc::now());
+ active.updated_by = Set(operator_id);
+ active.version = Set(next_ver);
+ active.update(&state.db).await?;
+
+ audit_service::record(
+ AuditLog::new(tenant_id, operator_id, "daily_monitoring.deleted", "daily_monitoring")
+ .with_resource_id(record_id),
+ &state.db,
+ ).await;
+
+ Ok(())
+}
+
+fn to_resp(m: daily_monitoring::Model) -> DailyMonitoringResp {
+ DailyMonitoringResp {
+ id: m.id,
+ patient_id: m.patient_id,
+ record_date: m.record_date,
+ morning_bp_systolic: m.morning_bp_systolic,
+ morning_bp_diastolic: m.morning_bp_diastolic,
+ evening_bp_systolic: m.evening_bp_systolic,
+ evening_bp_diastolic: m.evening_bp_diastolic,
+ weight: m.weight.map(|d| d.to_f64().unwrap_or(0.0)),
+ blood_sugar: m.blood_sugar.map(|d| d.to_f64().unwrap_or(0.0)),
+ fluid_intake: m.fluid_intake,
+ urine_output: m.urine_output,
+ notes: m.notes,
+ created_at: m.created_at,
+ updated_at: m.updated_at,
+ version: m.version,
+ }
+}
diff --git a/crates/erp-health/src/service/mod.rs b/crates/erp-health/src/service/mod.rs
index a1ff03c..62e7fd3 100644
--- a/crates/erp-health/src/service/mod.rs
+++ b/crates/erp-health/src/service/mod.rs
@@ -1,6 +1,7 @@
pub mod appointment_service;
pub mod article_service;
pub mod consultation_service;
+pub mod daily_monitoring_service;
pub mod dialysis_service;
pub mod doctor_service;
pub mod follow_up_service;
diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs
index 68c394f..4b6f47b 100644
--- a/crates/erp-server/migration/src/lib.rs
+++ b/crates/erp-server/migration/src/lib.rs
@@ -53,6 +53,7 @@ mod m20260425_00050_add_doctor_name_column;
mod m20260425_000051_dialysis_and_lab_enhance;
mod m20260425_000052_create_ai_tables;
mod m20260425_000053_create_points_tables;
+mod m20260425_000054_create_daily_monitoring;
pub struct Migrator;
@@ -113,6 +114,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260425_000051_dialysis_and_lab_enhance::Migration),
Box::new(m20260425_000052_create_ai_tables::Migration),
Box::new(m20260425_000053_create_points_tables::Migration),
+ Box::new(m20260425_000054_create_daily_monitoring::Migration),
]
}
}
diff --git a/crates/erp-server/migration/src/m20260425_000054_create_daily_monitoring.rs b/crates/erp-server/migration/src/m20260425_000054_create_daily_monitoring.rs
new file mode 100644
index 0000000..ea64b67
--- /dev/null
+++ b/crates/erp-server/migration/src/m20260425_000054_create_daily_monitoring.rs
@@ -0,0 +1,82 @@
+use sea_orm_migration::prelude::*;
+
+#[derive(DeriveMigrationName)]
+pub struct Migration;
+
+/// V2 日常监测表: daily_monitoring — 患者每日血压/体重/血糖/出入量记录
+#[async_trait::async_trait]
+impl MigrationTrait for Migration {
+ async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ manager
+ .create_table(
+ Table::create()
+ .table(Alias::new("daily_monitoring"))
+ .if_not_exists()
+ .col(ColumnDef::new(Alias::new("id")).uuid().not_null().primary_key())
+ .col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
+ .col(ColumnDef::new(Alias::new("patient_id")).uuid().not_null())
+ .col(ColumnDef::new(Alias::new("record_date")).date().not_null())
+ // 晨起血压
+ .col(ColumnDef::new(Alias::new("morning_bp_systolic")).integer())
+ .col(ColumnDef::new(Alias::new("morning_bp_diastolic")).integer())
+ // 晚间血压
+ .col(ColumnDef::new(Alias::new("evening_bp_systolic")).integer())
+ .col(ColumnDef::new(Alias::new("evening_bp_diastolic")).integer())
+ // 体重 (Decimal 5,1)
+ .col(ColumnDef::new(Alias::new("weight")).decimal().extra("CHECK(weight IS NULL OR weight >= 0)"))
+ // 血糖 (Decimal 4,1)
+ .col(ColumnDef::new(Alias::new("blood_sugar")).decimal().extra("CHECK(blood_sugar IS NULL OR blood_sugar >= 0)"))
+ // 出入量
+ .col(ColumnDef::new(Alias::new("fluid_intake")).integer())
+ .col(ColumnDef::new(Alias::new("urine_output")).integer())
+ // 备注
+ .col(ColumnDef::new(Alias::new("notes")).text())
+ // 标准字段
+ .col(ColumnDef::new(Alias::new("created_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
+ .col(ColumnDef::new(Alias::new("updated_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
+ .col(ColumnDef::new(Alias::new("created_by")).uuid())
+ .col(ColumnDef::new(Alias::new("updated_by")).uuid())
+ .col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone())
+ .col(ColumnDef::new(Alias::new("version")).integer().not_null().default(1))
+ .to_owned(),
+ )
+ .await?;
+
+ // 唯一约束: (patient_id, record_date) — 每位患者每天最多一条记录
+ manager
+ .create_index(
+ Index::create()
+ .if_not_exists()
+ .name("uk_daily_monitoring_patient_date")
+ .table(Alias::new("daily_monitoring"))
+ .col(Alias::new("patient_id"))
+ .col(Alias::new("record_date"))
+ .unique()
+ .to_owned(),
+ )
+ .await?;
+
+ // 索引: (tenant_id, record_date) — 按租户+日期范围查询
+ manager
+ .create_index(
+ Index::create()
+ .if_not_exists()
+ .name("idx_daily_monitoring_tenant_date")
+ .table(Alias::new("daily_monitoring"))
+ .col(Alias::new("tenant_id"))
+ .col(Alias::new("record_date"))
+ .to_owned(),
+ )
+ .await?;
+
+ Ok(())
+ }
+
+ async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ manager
+ .drop_table(Table::drop().table(Alias::new("daily_monitoring")).if_exists().to_owned())
+ .await?;
+
+ Ok(())
+ }
+}