From d2baacae7edf97f799fe89cf98afa6f73b8c5acd Mon Sep 17 00:00:00 2001 From: iven Date: Sat, 25 Apr 2026 20:10:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(health):=20Phase=204=20=E8=B7=A8=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E9=9B=86=E6=88=90=E4=B8=8E=E6=9E=B6=E6=9E=84=E4=BC=98?= =?UTF-8?q?=E5=8C=96=20=E2=80=94=20=E9=80=9A=E7=9F=A5/=E6=A0=87=E7=AD=BE/?= =?UTF-8?q?=E5=BE=85=E5=8A=9E/=E6=95=B0=E6=8D=AE=E5=BD=95=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - erp-message: 添加 appointment.created/confirmed/cancelled 事件监听,自动发送站内通知 - erp-health: 新增 GET /health/patient-tags 标签列表端点 + list_tags service - wechat-templates: 添加 isTemplateConfigured 运行时校验 前端: - 新增 Zustand useHealthStore 共享患者/医生名称缓存 - PatientTagManage: UUID 输入替换为 Checkbox 标签选择器 - VitalSignsTab: 添加体征数据录入 Modal (血压/心率/体重/血糖) - LabReportsTab: 添加化验报告创建 Modal - HealthRecordsTab: 添加健康记录创建 Modal - patients API: 添加 TagItem 类型 + listTags 方法 小程序: - 首页待办事项接入预约和随访 API,替换硬编码 EmptyState --- apps/miniprogram/src/pages/index/index.scss | 37 +++++ apps/miniprogram/src/pages/index/index.tsx | 81 ++++++++- .../src/services/wechat-templates.ts | 14 +- apps/web/src/api/health/patients.ts | 15 ++ .../web/src/pages/health/PatientTagManage.tsx | 108 ++++++------ .../health/components/HealthRecordsTab.tsx | 135 ++++++++------- .../pages/health/components/LabReportsTab.tsx | 135 ++++++++------- .../pages/health/components/VitalSignsTab.tsx | 155 +++++++++++------- apps/web/src/stores/health.ts | 75 +++++++++ crates/erp-health/src/dto/patient_dto.rs | 8 + .../erp-health/src/handler/patient_handler.rs | 13 ++ crates/erp-health/src/module.rs | 4 + .../erp-health/src/service/patient_service.rs | 24 +++ crates/erp-message/src/module.rs | 85 ++++++++++ 14 files changed, 667 insertions(+), 222 deletions(-) create mode 100644 apps/web/src/stores/health.ts diff --git a/apps/miniprogram/src/pages/index/index.scss b/apps/miniprogram/src/pages/index/index.scss index c96782d..1b4f98d 100644 --- a/apps/miniprogram/src/pages/index/index.scss +++ b/apps/miniprogram/src/pages/index/index.scss @@ -127,6 +127,43 @@ margin: 0 24px; } +.upcoming-list { + background: $card; + border-radius: $r; + overflow: hidden; +} + +.upcoming-item { + display: flex; + align-items: center; + padding: 24px 28px; + border-bottom: 1px solid $bd; + &:last-child { border-bottom: none; } +} + +.upcoming-item-main { + flex: 1; +} + +.upcoming-item-title { + font-size: 28px; + color: $tx; + display: block; + margin-bottom: 6px; +} + +.upcoming-item-sub { + font-size: 22px; + color: $tx3; + display: block; +} + +.upcoming-item-arrow { + font-size: 36px; + color: $tx3; + padding-left: 12px; +} + .empty-hint { background: $card; border-radius: $r; diff --git a/apps/miniprogram/src/pages/index/index.tsx b/apps/miniprogram/src/pages/index/index.tsx index 9f50538..54da82c 100644 --- a/apps/miniprogram/src/pages/index/index.tsx +++ b/apps/miniprogram/src/pages/index/index.tsx @@ -1,22 +1,75 @@ import { View, Text } from '@tarojs/components'; +import { useState } from 'react'; import Taro, { useDidShow } from '@tarojs/taro'; import { useAuthStore } from '../../stores/auth'; import { useHealthStore } from '../../stores/health'; import EmptyState from '../../components/EmptyState'; import Loading from '../../components/Loading'; import { trackPageView } from '@/services/analytics'; +import * as appointmentApi from '@/services/appointment'; +import * as followupApi from '@/services/followup'; import './index.scss'; +interface UpcomingItem { + id: string; + title: string; + subtitle: string; + type: 'appointment' | 'followup'; +} + export default function Index() { const { user, currentPatient, restore: restoreAuth } = useAuthStore(); const { todaySummary, loading, refreshToday } = useHealthStore(); + const [upcomingItems, setUpcomingItems] = useState([]); + const [upcomingLoading, setUpcomingLoading] = useState(false); useDidShow(() => { restoreAuth(); refreshToday(); + loadUpcoming(); trackPageView('home'); }); + const loadUpcoming = async () => { + const patientId = useAuthStore.getState().currentPatient?.id; + if (!patientId) return; + setUpcomingLoading(true); + try { + const items: UpcomingItem[] = []; + const [apptRes, taskRes] = await Promise.allSettled([ + appointmentApi.listAppointments(patientId, 1), + followupApi.listTasks(patientId, 'pending'), + ]); + if (apptRes.status === 'fulfilled') { + for (const a of apptRes.value.data.slice(0, 3)) { + if (a.status === 'pending' || a.status === 'confirmed') { + items.push({ + id: a.id, + title: `预约: ${a.appointment_date} ${a.start_time}`, + subtitle: `${a.doctor_name || '医护'} · ${a.status === 'pending' ? '待确认' : '已确认'}`, + type: 'appointment', + }); + } + } + } + if (taskRes.status === 'fulfilled') { + for (const t of taskRes.value.data.slice(0, 2)) { + items.push({ + id: t.id, + title: `随访: ${t.task_type}`, + subtitle: `${t.description?.slice(0, 30) || ''} · 截止 ${t.due_date}`, + type: 'followup', + }); + } + } + setUpcomingItems(items); + } catch { + setUpcomingItems([]); + } finally { + setUpcomingLoading(false); + } + }; + const hour = new Date().getHours(); const greeting = hour < 12 ? '早上好' : hour < 18 ? '下午好' : '晚上好'; const displayName = user?.display_name || currentPatient?.name || '访客'; @@ -97,7 +150,33 @@ export default function Index() { 待办事项 - + {upcomingLoading ? ( + + ) : upcomingItems.length === 0 ? ( + + ) : ( + + {upcomingItems.map((item) => ( + { + if (item.type === 'appointment') { + Taro.navigateTo({ url: `/pages/appointment/index` }); + } else { + Taro.navigateTo({ url: `/pages/followup/detail/index?id=${item.id}` }); + } + }} + > + + {item.title} + {item.subtitle} + + + + ))} + + )} ); diff --git a/apps/miniprogram/src/services/wechat-templates.ts b/apps/miniprogram/src/services/wechat-templates.ts index 7d3c2aa..c09336a 100644 --- a/apps/miniprogram/src/services/wechat-templates.ts +++ b/apps/miniprogram/src/services/wechat-templates.ts @@ -1,5 +1,17 @@ +// 微信订阅消息模板 ID — 需在微信公众平台注册后填入 +// 注册路径:公众平台 → 功能 → 订阅消息 → 添加模板 +// TODO: 上线前必须配置 export const TEMPLATE_IDS = { APPOINTMENT_REMINDER: '', FOLLOWUP_REMINDER: '', REPORT_NOTIFICATION: '', -}; +} as const; + +/** 检查模板 ID 是否已配置,未配置时返回 false 并打印警告 */ +export function isTemplateConfigured(key: keyof typeof TEMPLATE_IDS): boolean { + if (!TEMPLATE_IDS[key]) { + console.warn(`[wechat-templates] 模板 ${key} 未配置,请在微信公众平台注册并填入 ID`); + return false; + } + return true; +} diff --git a/apps/web/src/api/health/patients.ts b/apps/web/src/api/health/patients.ts index 7eb20fc..cb7496e 100644 --- a/apps/web/src/api/health/patients.ts +++ b/apps/web/src/api/health/patients.ts @@ -76,6 +76,13 @@ export interface CreateFamilyMemberReq { notes?: string; } +export interface TagItem { + id: string; + name: string; + color: string | null; + description: string | null; +} + // --- API --- export const patientApi = { list: async (params: { @@ -180,4 +187,12 @@ export const patientApi = { removeDoctor: async (id: string, doctorId: string) => { await client.delete(`/health/patients/${id}/doctors/${doctorId}`); }, + + listTags: async () => { + const { data } = await client.get<{ + success: boolean; + data: TagItem[]; + }>('/health/patient-tags'); + return data.data; + }, }; diff --git a/apps/web/src/pages/health/PatientTagManage.tsx b/apps/web/src/pages/health/PatientTagManage.tsx index 8fef153..e990514 100644 --- a/apps/web/src/pages/health/PatientTagManage.tsx +++ b/apps/web/src/pages/health/PatientTagManage.tsx @@ -4,14 +4,14 @@ import { Button, Space, Modal, - Input, Tag, Card, + Checkbox, message, Typography, } from 'antd'; import { TagsOutlined, AppstoreOutlined } from '@ant-design/icons'; -import { patientApi } from '../../api/health/patients'; +import { patientApi, type TagItem } from '../../api/health/patients'; import type { PatientListItem } from '../../api/health/patients'; import { useThemeMode } from '../../hooks/useThemeMode'; @@ -22,7 +22,8 @@ export default function PatientTagManage() { const [loading, setLoading] = useState(false); const [tagModalOpen, setTagModalOpen] = useState(false); const [selectedPatient, setSelectedPatient] = useState(null); - const [tagInput, setTagInput] = useState(''); + const [selectedTagIds, setSelectedTagIds] = useState([]); + const [allTags, setAllTags] = useState([]); const [saving, setSaving] = useState(false); const isDark = useThemeMode(); @@ -42,28 +43,34 @@ export default function PatientTagManage() { [page], ); + const fetchTags = useCallback(async () => { + try { + const tags = await patientApi.listTags(); + setAllTags(tags); + } catch { + // 标签列表加载失败不阻塞页面 + } + }, []); + useEffect(() => { fetchPatients(); - }, [fetchPatients]); + fetchTags(); + }, [fetchPatients, fetchTags]); const openTagModal = (record: PatientListItem) => { setSelectedPatient(record); - setTagInput(''); + const existingTags = (record as PatientListItem & { tag_ids?: string[] }).tag_ids || []; + setSelectedTagIds(existingTags); setTagModalOpen(true); }; const handleSaveTags = async () => { if (!selectedPatient) return; - const tagIds = tagInput - .split(',') - .map((s) => s.trim()) - .filter(Boolean); setSaving(true); try { - await patientApi.manageTags(selectedPatient.id, tagIds); + await patientApi.manageTags(selectedPatient.id, selectedTagIds); message.success('标签更新成功'); setTagModalOpen(false); - setTagInput(''); fetchPatients(); } catch { message.error('标签更新失败'); @@ -72,6 +79,12 @@ export default function PatientTagManage() { } }; + const toggleTag = (tagId: string) => { + setSelectedTagIds((prev) => + prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId], + ); + }; + const columns = [ { title: '患者姓名', @@ -121,18 +134,22 @@ export default function PatientTagManage() { } return ( - {tagIds.map((t) => ( - - {t} - - ))} + {tagIds.map((t) => { + const tagDef = allTags.find((at) => at.id === t); + return ( + + {tagDef?.name || t.slice(0, 8)} + + ); + })} ); }, @@ -175,7 +192,6 @@ export default function PatientTagManage() { return (
- {/* 说明卡片 */}
- +
标签管理说明 - - 标签通过患者管理页面进行关联。您可以在下方列表中为每位患者管理标签,输入标签 - ID(逗号分隔)进行批量设置。 + + 为患者分配分类标签,便于快速筛选和管理。勾选需要关联的标签后保存即可。
- {/* 页面标题 */}

标签管理

@@ -211,7 +220,6 @@ export default function PatientTagManage() {
- {/* 表格容器 */}
- {/* 标签管理弹窗 */} { - setTagModalOpen(false); - setTagInput(''); - }} + onCancel={() => setTagModalOpen(false)} onOk={handleSaveTags} confirmLoading={saving} okText="保存" width={440} >
- - 请输入标签 ID,多个标签用英文逗号分隔。 - - setTagInput(e.target.value)} - onPressEnter={handleSaveTags} - /> + {allTags.length === 0 ? ( + 暂无可用标签 + ) : ( +
+ {allTags.map((tag) => ( + toggleTag(tag.id)} + > + {tag.name} + + ))} +
+ )}
diff --git a/apps/web/src/pages/health/components/HealthRecordsTab.tsx b/apps/web/src/pages/health/components/HealthRecordsTab.tsx index 0841338..0fbe033 100644 --- a/apps/web/src/pages/health/components/HealthRecordsTab.tsx +++ b/apps/web/src/pages/health/components/HealthRecordsTab.tsx @@ -1,6 +1,7 @@ -import { useCallback } from 'react'; -import { Table, Tag } from 'antd'; -import type { ColumnsType } from 'antd/es/table'; +import { useCallback, useState } from 'react'; +import { Table, Tag, Button, Modal, Form, Input, DatePicker, message } from 'antd'; +import { PlusOutlined } from '@ant-design/icons'; +import type { Dayjs } from 'dayjs'; import { healthDataApi } from '../../../api/health/healthData'; import type { HealthRecord } from '../../../api/health/healthData'; import { usePaginatedData } from '../../../hooks/usePaginatedData'; @@ -9,69 +10,91 @@ interface Props { patientId: string; } -const columns: ColumnsType = [ - { - title: '记录类型', - dataIndex: 'record_type', - key: 'record_type', - width: 120, - render: (v: string) => {v}, - }, - { - title: '记录日期', - dataIndex: 'record_date', - key: 'record_date', - width: 120, - }, - { - title: '内容', - dataIndex: 'content', - key: 'content', - ellipsis: true, - }, - { - title: '创建时间', - dataIndex: 'created_at', - key: 'created_at', - width: 170, - render: (v: string) => new Date(v).toLocaleString('zh-CN'), - }, +const columns = [ + { title: '记录类型', dataIndex: 'record_type', key: 'record_type', width: 120, render: (v: string) => {v} }, + { title: '记录日期', dataIndex: 'record_date', key: 'record_date', width: 120 }, + { title: '内容', dataIndex: 'content', key: 'content', ellipsis: true }, + { title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: (v: string) => new Date(v).toLocaleString('zh-CN') }, ]; -/** - * 健康档案标签页 — 分页表格 - */ export function HealthRecordsTab({ patientId }: Props) { + const [modalOpen, setModalOpen] = useState(false); + const [form] = Form.useForm(); + const [submitting, setSubmitting] = useState(false); + const fetcher = useCallback( async (page: number, pageSize: number) => { - return healthDataApi.listHealthRecords(patientId, { - page, - page_size: pageSize, - }); + return healthDataApi.listHealthRecords(patientId, { page, page_size: pageSize }); }, [patientId], ); - const { data, total, page, loading, refresh } = usePaginatedData( - fetcher, - 10, - ); + const { data, total, page, loading, refresh } = usePaginatedData(fetcher, 10); + + const handleCreate = async (values: { + record_type: string; + record_date: Dayjs; + content?: string; + }) => { + setSubmitting(true); + try { + await healthDataApi.createHealthRecord(patientId, { + record_type: values.record_type, + record_date: values.record_date.format('YYYY-MM-DD'), + content: values.content, + }); + message.success('健康记录添加成功'); + setModalOpen(false); + form.resetFields(); + refresh(); + } catch { + message.error('添加失败'); + } finally { + setSubmitting(false); + } + }; return ( - refresh(p), - showTotal: (t) => `共 ${t} 条`, - style: { margin: 0 }, - }} - /> +
+
+ +
+
refresh(p), + showTotal: (t) => `共 ${t} 条`, + style: { margin: 0 }, + }} + /> + setModalOpen(false)} + onOk={() => form.submit()} + confirmLoading={submitting} + destroyOnClose + width={520} + > +
+ + + + + + + + + + +
+ ); } diff --git a/apps/web/src/pages/health/components/LabReportsTab.tsx b/apps/web/src/pages/health/components/LabReportsTab.tsx index c4dc847..077a376 100644 --- a/apps/web/src/pages/health/components/LabReportsTab.tsx +++ b/apps/web/src/pages/health/components/LabReportsTab.tsx @@ -1,6 +1,7 @@ -import { useCallback } from 'react'; -import { Table, Tag } from 'antd'; -import type { ColumnsType } from 'antd/es/table'; +import { useCallback, useState } from 'react'; +import { Table, Tag, Button, Modal, Form, Input, DatePicker, message } from 'antd'; +import { PlusOutlined } from '@ant-design/icons'; +import type { Dayjs } from 'dayjs'; import { healthDataApi } from '../../../api/health/healthData'; import type { LabReport } from '../../../api/health/healthData'; import { usePaginatedData } from '../../../hooks/usePaginatedData'; @@ -9,69 +10,91 @@ interface Props { patientId: string; } -const columns: ColumnsType = [ - { - title: '报告日期', - dataIndex: 'report_date', - key: 'report_date', - width: 120, - }, - { - title: '报告类型', - dataIndex: 'report_type', - key: 'report_type', - width: 120, - render: (v: string) => {v}, - }, - { - title: '医生解读', - dataIndex: 'doctor_interpretation', - key: 'doctor_interpretation', - ellipsis: true, - }, - { - title: '创建时间', - dataIndex: 'created_at', - key: 'created_at', - width: 170, - render: (v: string) => new Date(v).toLocaleString('zh-CN'), - }, +const columns = [ + { title: '报告日期', dataIndex: 'report_date', key: 'report_date', width: 120 }, + { title: '报告类型', dataIndex: 'report_type', key: 'report_type', width: 120, render: (v: string) => {v} }, + { title: '医生解读', dataIndex: 'doctor_interpretation', key: 'doctor_interpretation', ellipsis: true }, + { title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: (v: string) => new Date(v).toLocaleString('zh-CN') }, ]; -/** - * 化验报告标签页 — 分页表格 - */ export function LabReportsTab({ patientId }: Props) { + const [modalOpen, setModalOpen] = useState(false); + const [form] = Form.useForm(); + const [submitting, setSubmitting] = useState(false); + const fetcher = useCallback( async (page: number, pageSize: number) => { - return healthDataApi.listLabReports(patientId, { - page, - page_size: pageSize, - }); + return healthDataApi.listLabReports(patientId, { page, page_size: pageSize }); }, [patientId], ); - const { data, total, page, loading, refresh } = usePaginatedData( - fetcher, - 10, - ); + const { data, total, page, loading, refresh } = usePaginatedData(fetcher, 10); + + const handleCreate = async (values: { + report_date: Dayjs; + report_type: string; + doctor_interpretation?: string; + }) => { + setSubmitting(true); + try { + await healthDataApi.createLabReport(patientId, { + report_date: values.report_date.format('YYYY-MM-DD'), + report_type: values.report_type, + doctor_interpretation: values.doctor_interpretation, + }); + message.success('化验报告添加成功'); + setModalOpen(false); + form.resetFields(); + refresh(); + } catch { + message.error('添加失败'); + } finally { + setSubmitting(false); + } + }; return ( -
refresh(p), - showTotal: (t) => `共 ${t} 条`, - style: { margin: 0 }, - }} - /> +
+
+ +
+
refresh(p), + showTotal: (t) => `共 ${t} 条`, + style: { margin: 0 }, + }} + /> + setModalOpen(false)} + onOk={() => form.submit()} + confirmLoading={submitting} + destroyOnClose + width={520} + > +
+ + + + + + + + + + +
+ ); } diff --git a/apps/web/src/pages/health/components/VitalSignsTab.tsx b/apps/web/src/pages/health/components/VitalSignsTab.tsx index 9907eab..707bc7b 100644 --- a/apps/web/src/pages/health/components/VitalSignsTab.tsx +++ b/apps/web/src/pages/health/components/VitalSignsTab.tsx @@ -1,6 +1,7 @@ -import { useCallback } from 'react'; -import { Table } from 'antd'; -import type { ColumnsType } from 'antd/es/table'; +import { useCallback, useState } from 'react'; +import { Table, Button, Modal, Form, InputNumber, DatePicker, Input, message } from 'antd'; +import { PlusOutlined } from '@ant-design/icons'; +import type { Dayjs } from 'dayjs'; import { healthDataApi } from '../../../api/health/healthData'; import type { VitalSigns } from '../../../api/health/healthData'; import { VitalSignsChart } from './VitalSignsChart'; @@ -10,71 +11,71 @@ interface Props { patientId: string; } -const columns: ColumnsType = [ - { - title: '记录日期', - dataIndex: 'record_date', - key: 'record_date', - width: 120, - }, - { - title: '收缩压(晨)', - dataIndex: 'systolic_bp_morning', - key: 'systolic_bp_morning', - width: 110, - render: (v?: number) => (v != null ? `${v} mmHg` : '-'), - }, - { - title: '舒张压(晨)', - dataIndex: 'diastolic_bp_morning', - key: 'diastolic_bp_morning', - width: 110, - render: (v?: number) => (v != null ? `${v} mmHg` : '-'), - }, - { - title: '心率', - dataIndex: 'heart_rate', - key: 'heart_rate', - width: 80, - render: (v?: number) => (v != null ? `${v} bpm` : '-'), - }, - { - title: '体重', - dataIndex: 'weight', - key: 'weight', - width: 80, - render: (v?: number) => (v != null ? `${v} kg` : '-'), - }, - { - title: '血糖', - dataIndex: 'blood_sugar', - key: 'blood_sugar', - width: 80, - render: (v?: number) => (v != null ? `${v} mmol/L` : '-'), - }, +const columns = [ + { title: '记录日期', dataIndex: 'record_date', key: 'record_date', width: 120 }, + { title: '收缩压(晨)', dataIndex: 'systolic_bp_morning', key: 'systolic_bp_morning', width: 110, render: (v?: number) => (v != null ? `${v} mmHg` : '-') }, + { title: '舒张压(晨)', dataIndex: 'diastolic_bp_morning', key: 'diastolic_bp_morning', width: 110, render: (v?: number) => (v != null ? `${v} mmHg` : '-') }, + { title: '心率', dataIndex: 'heart_rate', key: 'heart_rate', width: 80, render: (v?: number) => (v != null ? `${v} bpm` : '-') }, + { title: '体重', dataIndex: 'weight', key: 'weight', width: 80, render: (v?: number) => (v != null ? `${v} kg` : '-') }, + { title: '血糖', dataIndex: 'blood_sugar', key: 'blood_sugar', width: 80, render: (v?: number) => (v != null ? `${v} mmol/L` : '-') }, ]; -/** - * 体征数据标签页 — 含趋势图 + 分页表格 - */ export function VitalSignsTab({ patientId }: Props) { + const [modalOpen, setModalOpen] = useState(false); + const [form] = Form.useForm(); + const [submitting, setSubmitting] = useState(false); + const fetcher = useCallback( async (page: number, pageSize: number) => { - return healthDataApi.listVitalSigns(patientId, { - page, - page_size: pageSize, - }); + return healthDataApi.listVitalSigns(patientId, { page, page_size: pageSize }); }, [patientId], ); - const { data, total, page, loading, refresh } = usePaginatedData( - fetcher, - 10, - ); + const { data, total, page, loading, refresh } = usePaginatedData(fetcher, 10); + + const handleCreate = async (values: { + record_date: Dayjs; + systolic_bp_morning?: number; + diastolic_bp_morning?: number; + heart_rate?: number; + weight?: number; + blood_sugar?: number; + water_intake_ml?: number; + urine_output_ml?: number; + notes?: string; + }) => { + setSubmitting(true); + try { + await healthDataApi.createVitalSigns(patientId, { + record_date: values.record_date.format('YYYY-MM-DD'), + systolic_bp_morning: values.systolic_bp_morning, + diastolic_bp_morning: values.diastolic_bp_morning, + heart_rate: values.heart_rate, + weight: values.weight, + blood_sugar: values.blood_sugar, + water_intake_ml: values.water_intake_ml, + urine_output_ml: values.urine_output_ml, + notes: values.notes, + }); + message.success('体征数据录入成功'); + setModalOpen(false); + form.resetFields(); + refresh(); + } catch { + message.error('录入失败'); + } finally { + setSubmitting(false); + } + }; return (
+
+ +
@@ -85,14 +86,50 @@ export function VitalSignsTab({ patientId }: Props) { loading={loading} size="small" pagination={{ - current: page, - total, - pageSize: 10, + current: page, total, pageSize: 10, onChange: (p) => refresh(p), showTotal: (t) => `共 ${t} 条`, style: { margin: 0 }, }} /> + setModalOpen(false)} + onOk={() => form.submit()} + confirmLoading={submitting} + destroyOnClose + width={600} + > +
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + +
); } diff --git a/apps/web/src/stores/health.ts b/apps/web/src/stores/health.ts new file mode 100644 index 0000000..5793834 --- /dev/null +++ b/apps/web/src/stores/health.ts @@ -0,0 +1,75 @@ +import { create } from 'zustand'; +import { patientApi } from '../api/health/patients'; +import { doctorApi } from '../api/health/doctors'; + +interface HealthState { + patientNames: Record; + doctorNames: Record; + loadingIds: Set; + + resolvePatientName: (id: string) => Promise; + resolveDoctorName: (id: string) => Promise; + getPatientName: (id: string) => string; + getDoctorName: (id: string) => string; +} + +export const useHealthStore = create((set, get) => ({ + patientNames: {}, + doctorNames: {}, + loadingIds: new Set(), + + resolvePatientName: async (id: string) => { + const { patientNames, loadingIds } = get(); + if (patientNames[id]) return patientNames[id]; + if (loadingIds.has(`p:${id}`)) return id.slice(0, 8); + + const newLoading = new Set(loadingIds); + newLoading.add(`p:${id}`); + set({ loadingIds: newLoading }); + + try { + const detail = await patientApi.get(id); + const name = detail.name; + set((s) => ({ + patientNames: { ...s.patientNames, [id]: name }, + loadingIds: new Set([...s.loadingIds].filter((k) => k !== `p:${id}`)), + })); + return name; + } catch { + set((s) => ({ + patientNames: { ...s.patientNames, [id]: id.slice(0, 8) }, + loadingIds: new Set([...s.loadingIds].filter((k) => k !== `p:${id}`)), + })); + return id.slice(0, 8); + } + }, + + resolveDoctorName: async (id: string) => { + const { doctorNames, loadingIds } = get(); + if (doctorNames[id]) return doctorNames[id]; + if (loadingIds.has(`d:${id}`)) return id.slice(0, 8); + + const newLoading = new Set(loadingIds); + newLoading.add(`d:${id}`); + set({ loadingIds: newLoading }); + + try { + const detail = await doctorApi.get(id); + const name = detail.name; + set((s) => ({ + doctorNames: { ...s.doctorNames, [id]: name }, + loadingIds: new Set([...s.loadingIds].filter((k) => k !== `d:${id}`)), + })); + return name; + } catch { + set((s) => ({ + doctorNames: { ...s.doctorNames, [id]: id.slice(0, 8) }, + loadingIds: new Set([...s.loadingIds].filter((k) => k !== `d:${id}`)), + })); + return id.slice(0, 8); + } + }, + + getPatientName: (id: string) => get().patientNames[id] || id.slice(0, 8), + getDoctorName: (id: string) => get().doctorNames[id] || id.slice(0, 8), +})); diff --git a/crates/erp-health/src/dto/patient_dto.rs b/crates/erp-health/src/dto/patient_dto.rs index af80885..5881a3a 100644 --- a/crates/erp-health/src/dto/patient_dto.rs +++ b/crates/erp-health/src/dto/patient_dto.rs @@ -130,3 +130,11 @@ pub struct PatientListQuery { pub tag_id: Option, pub status: Option, } + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct TagResp { + pub id: Uuid, + pub name: String, + pub color: Option, + pub description: Option, +} diff --git a/crates/erp-health/src/handler/patient_handler.rs b/crates/erp-health/src/handler/patient_handler.rs index c3a21d9..cd1d63c 100644 --- a/crates/erp-health/src/handler/patient_handler.rs +++ b/crates/erp-health/src/handler/patient_handler.rs @@ -306,3 +306,16 @@ pub struct FamilyMemberUpdateWithVersion { pub notes: Option, pub version: i32, } + +pub async fn list_tags( + State(state): State, + Extension(ctx): Extension, +) -> Result>>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.patient.list")?; + let tags = patient_service::list_tags(&state, ctx.tenant_id).await?; + Ok(Json(ApiResponse::ok(tags))) +} diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs index e50e877..1e4ff8d 100644 --- a/crates/erp-health/src/module.rs +++ b/crates/erp-health/src/module.rs @@ -69,6 +69,10 @@ impl HealthModule { "/health/patients/{id}/tags", axum::routing::post(patient_handler::manage_patient_tags), ) + .route( + "/health/patient-tags", + axum::routing::get(patient_handler::list_tags), + ) .route( "/health/patients/{id}/health-summary", axum::routing::get(patient_handler::get_health_summary), diff --git a/crates/erp-health/src/service/patient_service.rs b/crates/erp-health/src/service/patient_service.rs index ef48ff8..63b2903 100644 --- a/crates/erp-health/src/service/patient_service.rs +++ b/crates/erp-health/src/service/patient_service.rs @@ -760,3 +760,27 @@ fn model_to_resp_decrypted(crypto: &crate::crypto::HealthCrypto, m: patient::Mod } } + +pub async fn list_tags( + state: &crate::state::HealthState, + tenant_id: Uuid, +) -> HealthResult> { + use crate::entity::patient_tag; + let tags = patient_tag::Entity::find() + .filter(patient_tag::Column::TenantId.eq(tenant_id)) + .filter(patient_tag::Column::DeletedAt.is_null()) + .order_by_asc(patient_tag::Column::Name) + .all(&state.db) + .await + .map_err(|e| crate::error::HealthError::DbError(e.to_string()))?; + + Ok(tags + .into_iter() + .map(|t| crate::dto::patient_dto::TagResp { + id: t.id, + name: t.name, + color: t.color, + description: t.description, + }) + .collect()) +} diff --git a/crates/erp-message/src/module.rs b/crates/erp-message/src/module.rs index eea2de8..bdcf4dd 100644 --- a/crates/erp-message/src/module.rs +++ b/crates/erp-message/src/module.rs @@ -195,6 +195,91 @@ async fn handle_workflow_event( .map_err(|e| e.to_string())?; } } + // 预约事件通知 + "appointment.created" => { + let appointment_id = event + .payload + .get("appointment_id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let patient_id = event + .payload + .get("patient_id") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + + if let Some(pid) = patient_id { + let _ = crate::service::message_service::MessageService::send_system( + event.tenant_id, + pid, + "预约已创建".to_string(), + format!("您的新预约 {} 已创建,请等待确认。", &appointment_id[..8.min(appointment_id.len())]), + "normal", + Some("appointment".to_string()), + uuid::Uuid::parse_str(appointment_id).ok(), + db, + event_bus, + ) + .await + .map_err(|e| e.to_string())?; + } + } + "appointment.confirmed" => { + let appointment_id = event + .payload + .get("appointment_id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let patient_id = event + .payload + .get("patient_id") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + + if let Some(pid) = patient_id { + let _ = crate::service::message_service::MessageService::send_system( + event.tenant_id, + pid, + "预约已确认".to_string(), + format!("您的预约 {} 已确认,请按时就诊。", &appointment_id[..8.min(appointment_id.len())]), + "important", + Some("appointment".to_string()), + uuid::Uuid::parse_str(appointment_id).ok(), + db, + event_bus, + ) + .await + .map_err(|e| e.to_string())?; + } + } + "appointment.cancelled" => { + let appointment_id = event + .payload + .get("appointment_id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let patient_id = event + .payload + .get("patient_id") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + + if let Some(pid) = patient_id { + let _ = crate::service::message_service::MessageService::send_system( + event.tenant_id, + pid, + "预约已取消".to_string(), + format!("您的预约 {} 已被取消。", &appointment_id[..8.min(appointment_id.len())]), + "normal", + Some("appointment".to_string()), + uuid::Uuid::parse_str(appointment_id).ok(), + db, + event_bus, + ) + .await + .map_err(|e| e.to_string())?; + } + } _ => {} } Ok(())