From 0bf1822fa942de9792ba30c9ea75ac757c615523 Mon Sep 17 00:00:00 2001 From: iven Date: Sat, 25 Apr 2026 10:22:44 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20QA=20=E7=AC=AC=E4=BA=8C=E8=BD=AE?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20=E2=80=94=20PatientDetail=20=E9=87=8D?= =?UTF-8?q?=E6=9E=84/=E6=B5=8B=E8=AF=95=E8=A6=86=E7=9B=96/id=5Fnumber=20?= =?UTF-8?q?=E5=88=97=E5=AE=BD/=E5=B0=8F=E7=A8=8B=E5=BA=8F=20URL=20?= =?UTF-8?q?=E8=A7=84=E8=8C=83=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - refactor(web): PatientDetail.tsx 拆分为 4 个子组件(737→334行) - refactor(web): 提取 usePaginatedData hook 消除重复分页状态 - feat(db): patient.id_number varchar(20)→varchar(255) 容纳加密值 - test(health): 添加预约模块集成测试(创建/列表/租户隔离) - test(plugin): 添加 6 个 SQL 注入 sanitize 测试 - fix(miniprogram): 7 个 service 文件 URL 构建规范化(params 对象) - fix(miniprogram): 跨平台字段名对齐(birth_date/start_time/end_time) --- apps/miniprogram/src/app.tsx | 16 +- apps/miniprogram/src/pages/article/index.tsx | 2 +- .../src/pages/followup/detail/index.tsx | 4 +- .../src/pages/health/trend/index.tsx | 2 +- apps/miniprogram/src/pages/index/index.tsx | 9 +- apps/miniprogram/src/pages/login/index.scss | 8 +- apps/miniprogram/src/pages/login/index.tsx | 85 +-- apps/miniprogram/src/pages/profile/index.tsx | 7 - apps/miniprogram/src/services/appointment.ts | 27 +- apps/miniprogram/src/services/article.ts | 5 +- apps/miniprogram/src/services/followup.ts | 18 +- apps/miniprogram/src/services/health.ts | 3 +- apps/miniprogram/src/services/patient.ts | 5 +- apps/miniprogram/src/services/report.ts | 3 +- apps/miniprogram/src/services/request.ts | 30 +- apps/web/src/api/health/appointments.ts | 2 + apps/web/src/hooks/usePaginatedData.ts | 65 ++- apps/web/src/pages/health/AppointmentList.tsx | 5 +- apps/web/src/pages/health/PatientDetail.tsx | 524 +++--------------- .../pages/health/components/ChatBubble.tsx | 74 --- .../pages/health/components/ExportButton.tsx | 11 +- .../pages/health/components/FollowUpTab.tsx | 83 +++ .../health/components/HealthRecordsTab.tsx | 77 +++ .../pages/health/components/LabReportsTab.tsx | 77 +++ .../pages/health/components/VitalSignsTab.tsx | 98 ++++ .../erp-health/src/service/trend_service.rs | 100 +++- crates/erp-plugin/src/dynamic_table.rs | 87 +++ crates/erp-server/migration/src/lib.rs | 2 + ...20260425_000049_widen_patient_id_number.rs | 51 ++ crates/erp-server/tests/integration.rs | 2 + .../integration/health_appointment_tests.rs | 248 +++++++++ wiki/frontend.md | 3 + wiki/index.md | 7 + wiki/testing.md | 11 + 34 files changed, 1110 insertions(+), 641 deletions(-) delete mode 100644 apps/web/src/pages/health/components/ChatBubble.tsx create mode 100644 apps/web/src/pages/health/components/FollowUpTab.tsx create mode 100644 apps/web/src/pages/health/components/HealthRecordsTab.tsx create mode 100644 apps/web/src/pages/health/components/LabReportsTab.tsx create mode 100644 apps/web/src/pages/health/components/VitalSignsTab.tsx create mode 100644 crates/erp-server/migration/src/m20260425_000049_widen_patient_id_number.rs create mode 100644 crates/erp-server/tests/integration/health_appointment_tests.rs diff --git a/apps/miniprogram/src/app.tsx b/apps/miniprogram/src/app.tsx index 35b8829..61681ca 100644 --- a/apps/miniprogram/src/app.tsx +++ b/apps/miniprogram/src/app.tsx @@ -1,8 +1,22 @@ -import { PropsWithChildren } from 'react'; +import { useEffect, PropsWithChildren } from 'react'; +import Taro from '@tarojs/taro'; import ErrorBoundary from './components/ErrorBoundary'; +import { flushEvents } from './services/analytics'; import './app.scss'; function App({ children }: PropsWithChildren>) { + useEffect(() => { + const timer = setInterval(() => { + flushEvents(); + }, 30000); + const onHide = () => { flushEvents(); }; + Taro.eventCenter.on('appHide', onHide); + return () => { + clearInterval(timer); + Taro.eventCenter.off('appHide', onHide); + }; + }, []); + return {children}; } diff --git a/apps/miniprogram/src/pages/article/index.tsx b/apps/miniprogram/src/pages/article/index.tsx index c4ad4c0..224d3e6 100644 --- a/apps/miniprogram/src/pages/article/index.tsx +++ b/apps/miniprogram/src/pages/article/index.tsx @@ -29,7 +29,7 @@ export default function ArticleList() { useDidShow(() => { fetchData(1); - }, [fetchData]); + }); usePullDownRefresh(() => { fetchData(1).finally(() => { diff --git a/apps/miniprogram/src/pages/followup/detail/index.tsx b/apps/miniprogram/src/pages/followup/detail/index.tsx index 7998f37..a4a03cf 100644 --- a/apps/miniprogram/src/pages/followup/detail/index.tsx +++ b/apps/miniprogram/src/pages/followup/detail/index.tsx @@ -46,7 +46,7 @@ export default function FollowUpDetail() { trackEvent('followup_submit', { task_id: id }); const tmplId = TEMPLATE_IDS.FOLLOWUP_REMINDER; if (tmplId) { - try { await Taro.requestSubscribeMessage({ tmplIds: [tmplId] }); } catch { /* 用户拒绝 */ } + try { await (Taro.requestSubscribeMessage as any)({ tmplIds: [tmplId] }); } catch { /* 用户拒绝 */ } } setContent(''); } catch { @@ -91,7 +91,7 @@ export default function FollowUpDetail() { if (error || !task) { return ( - + ); } diff --git a/apps/miniprogram/src/pages/health/trend/index.tsx b/apps/miniprogram/src/pages/health/trend/index.tsx index 5ccb7da..a9278d2 100644 --- a/apps/miniprogram/src/pages/health/trend/index.tsx +++ b/apps/miniprogram/src/pages/health/trend/index.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { View, Text } from '@tarojs/components'; -import Taro, { useRouter } from '@tarojs/taro'; +import { useRouter } from '@tarojs/taro'; import { useHealthStore } from '@/stores/health'; import TrendChart from '@/components/TrendChart'; import './index.scss'; diff --git a/apps/miniprogram/src/pages/index/index.tsx b/apps/miniprogram/src/pages/index/index.tsx index 86a5797..9f50538 100644 --- a/apps/miniprogram/src/pages/index/index.tsx +++ b/apps/miniprogram/src/pages/index/index.tsx @@ -29,7 +29,14 @@ export default function Index() { ]; const handleServiceClick = (path: string) => { - Taro.navigateTo({ url: path }); + // tabBar 页面必须使用 switchTab,其他页面用 navigateTo + const isTabBar = ['pages/index/index', 'pages/health/index', 'pages/appointment/index', 'pages/article/index', 'pages/profile/index'] + .some((p) => path.includes(p)); + if (isTabBar) { + Taro.switchTab({ url: path }); + } else { + Taro.navigateTo({ url: path }); + } }; const healthItems = [ diff --git a/apps/miniprogram/src/pages/login/index.scss b/apps/miniprogram/src/pages/login/index.scss index 54b01b6..ed2abc8 100644 --- a/apps/miniprogram/src/pages/login/index.scss +++ b/apps/miniprogram/src/pages/login/index.scss @@ -1,13 +1,16 @@ @import '../../styles/variables.scss'; +.login-scroll { + height: 100vh; +} + .login-page { min-height: 100vh; background: linear-gradient(135deg, $pri 0%, $pri-d 100%); display: flex; flex-direction: column; align-items: center; - justify-content: center; - padding: 0 60px; + padding: 120px 60px 60px; } .login-header { @@ -69,6 +72,7 @@ align-items: flex-start; margin-top: 32px; gap: 12px; + width: 100%; } .checkbox { diff --git a/apps/miniprogram/src/pages/login/index.tsx b/apps/miniprogram/src/pages/login/index.tsx index d5d3819..af2176b 100644 --- a/apps/miniprogram/src/pages/login/index.tsx +++ b/apps/miniprogram/src/pages/login/index.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { View, Text, Button, Image } from '@tarojs/components'; +import { View, Text, Button, ScrollView } from '@tarojs/components'; import Taro from '@tarojs/taro'; import { useAuthStore } from '../../stores/auth'; import './index.scss'; @@ -16,15 +16,16 @@ export default function Login() { } try { const { code } = await Taro.login(); - const success = await login(code); - if (success) { + const result = await login(code); + if (result) { Taro.switchTab({ url: '/pages/index/index' }); } else { - // 未绑定,需要获取手机号(openid 已由 store 缓存到 Storage) setNeedBind(true); + Taro.showToast({ title: '请授权手机号完成绑定', icon: 'none' }); } - } catch { - Taro.showToast({ title: '登录失败', icon: 'none' }); + } catch (err: any) { + const msg = err?.message || '登录失败,请重试'; + Taro.showToast({ title: msg.substring(0, 20), icon: 'none', duration: 3000 }); } }; @@ -42,48 +43,50 @@ export default function Login() { if (success) { Taro.switchTab({ url: '/pages/index/index' }); } else { - Taro.showToast({ title: '绑定失败', icon: 'none' }); + Taro.showToast({ title: '绑定失败,请重试', icon: 'none' }); } }; return ( - - - - + + + + + + + + + 健康管理 + 您的专属健康管家 - 健康管理 - 您的专属健康管家 - - - {!needBind ? ( - - ) : ( - - )} - - - - setAgreed(!agreed)}> - {agreed && } + + {!needBind ? ( + + ) : ( + + )} + + + + setAgreed(!agreed)}> + {agreed && } + + + 我已阅读并同意 + Taro.navigateTo({ url: '/pages/legal/user-agreement' })}>《用户服务协议》 + 和 + Taro.navigateTo({ url: '/pages/legal/privacy-policy' })}>《隐私政策》 + - - 我已阅读并同意 - Taro.navigateTo({ url: '/pages/legal/user-agreement' })}>《用户服务协议》 - 和 - Taro.navigateTo({ url: '/pages/legal/privacy-policy' })}>《隐私政策》 - - + ); } diff --git a/apps/miniprogram/src/pages/profile/index.tsx b/apps/miniprogram/src/pages/profile/index.tsx index 3c13c41..0dc817a 100644 --- a/apps/miniprogram/src/pages/profile/index.tsx +++ b/apps/miniprogram/src/pages/profile/index.tsx @@ -4,7 +4,6 @@ import { useAuthStore } from '../../stores/auth'; import './index.scss'; const MENU_ITEMS = [ - { label: '消息中心', icon: '🔔', path: '/pages/profile/messages/index', badge: true }, { label: '就诊人管理', icon: '👥', path: '/pages/profile/family/index' }, { label: '我的报告', icon: '📋', path: '/pages/profile/reports/index' }, { label: '我的随访', icon: '💬', path: '/pages/profile/followups/index' }, @@ -14,7 +13,6 @@ const MENU_ITEMS = [ export default function Profile() { const { user, restore: restoreAuth, logout } = useAuthStore(); - const unreadCount = 0; // MVP 占位,后续对接 erp-message API useDidShow(() => { restoreAuth(); @@ -56,11 +54,6 @@ export default function Profile() { > {item.icon} {item.label} - {item.badge && unreadCount > 0 && ( - - {unreadCount > 99 ? '99+' : unreadCount} - - )} ))} diff --git a/apps/miniprogram/src/services/appointment.ts b/apps/miniprogram/src/services/appointment.ts index 0a58168..633c345 100644 --- a/apps/miniprogram/src/services/appointment.ts +++ b/apps/miniprogram/src/services/appointment.ts @@ -29,7 +29,10 @@ export interface DoctorSchedule { } export async function listAppointments(page = 1) { - return api.get<{ data: Appointment[]; total: number }>(`/health/appointments?page=${page}&page_size=20`); + return api.get<{ data: Appointment[]; total: number }>('/health/appointments', { + page, + page_size: 20, + }); } export async function getAppointment(id: string) { @@ -56,17 +59,25 @@ export async function cancelAppointment(id: string, version: number) { } export async function getDoctorSchedules(doctorId: string, startDate: string, endDate: string) { - return api.get<{ data: DoctorSchedule[]; total: number }>( - `/health/doctor-schedules?doctor_id=${doctorId}&start_date=${startDate}&end_date=${endDate}&page_size=50` - ); + return api.get<{ data: DoctorSchedule[]; total: number }>('/health/doctor-schedules', { + doctor_id: doctorId, + start_date: startDate, + end_date: endDate, + page_size: 50, + }); } export async function listDoctors(department?: string) { - const deptParam = department ? `&department=${department}` : ''; - return api.get<{ data: Doctor[]; total: number }>(`/health/doctors?page_size=100${deptParam}`); + return api.get<{ data: Doctor[]; total: number }>('/health/doctors', { + page_size: 100, + ...(department && { department }), + }); } export async function calendarView(startDate: string, endDate: string, doctorId?: string) { - const docParam = doctorId ? `&doctor_id=${doctorId}` : ''; - return api.get(`/health/doctor-schedules/calendar?start_date=${startDate}&end_date=${endDate}${docParam}`); + return api.get('/health/doctor-schedules/calendar', { + start_date: startDate, + end_date: endDate, + ...(doctorId && { doctor_id: doctorId }), + }); } diff --git a/apps/miniprogram/src/services/article.ts b/apps/miniprogram/src/services/article.ts index 17c12d1..5667296 100644 --- a/apps/miniprogram/src/services/article.ts +++ b/apps/miniprogram/src/services/article.ts @@ -12,7 +12,10 @@ export interface Article { } export async function listArticles(page = 1) { - return api.get<{ data: Article[]; total: number }>(`/health/articles?page=${page}&page_size=20`); + return api.get<{ data: Article[]; total: number }>('/health/articles', { + page, + page_size: 20, + }); } export async function getArticleDetail(id: string) { diff --git a/apps/miniprogram/src/services/followup.ts b/apps/miniprogram/src/services/followup.ts index 00b0185..8c63fca 100644 --- a/apps/miniprogram/src/services/followup.ts +++ b/apps/miniprogram/src/services/followup.ts @@ -23,10 +23,11 @@ export interface FollowUpRecord { } export async function listTasks(status?: string) { - const statusParam = status ? `&status=${status}` : ''; - return api.get<{ data: FollowUpTask[]; total: number }>( - `/health/follow-up-tasks?page=1&page_size=50${statusParam}` - ); + return api.get<{ data: FollowUpTask[]; total: number }>('/health/follow-up-tasks', { + page: 1, + page_size: 50, + ...(status && { status }), + }); } export async function getTaskDetail(id: string) { @@ -38,8 +39,9 @@ export async function submitRecord(data: { task_id: string; content: FollowUpCon } export async function listRecords(taskId?: string) { - const taskParam = taskId ? `&task_id=${taskId}` : ''; - return api.get<{ data: FollowUpRecord[]; total: number }>( - `/health/follow-up-records?page=1&page_size=50${taskParam}` - ); + return api.get<{ data: FollowUpRecord[]; total: number }>('/health/follow-up-records', { + page: 1, + page_size: 50, + ...(taskId && { task_id: taskId }), + }); } diff --git a/apps/miniprogram/src/services/health.ts b/apps/miniprogram/src/services/health.ts index 07d5f06..a9bb6c7 100644 --- a/apps/miniprogram/src/services/health.ts +++ b/apps/miniprogram/src/services/health.ts @@ -25,6 +25,7 @@ export async function inputVitalSign(patientId: string, data: VitalSignInput) { export async function getTrend(indicator: string, range: string) { return api.get<{ indicator: string; data_points: { date: string; value: number }[] }>( - `/health/vital-signs/trend?indicator=${indicator}&range=${range}` + '/health/vital-signs/trend', + { indicator, range }, ); } diff --git a/apps/miniprogram/src/services/patient.ts b/apps/miniprogram/src/services/patient.ts index 7f6063c..35e049b 100644 --- a/apps/miniprogram/src/services/patient.ts +++ b/apps/miniprogram/src/services/patient.ts @@ -12,7 +12,10 @@ export interface Patient { } export async function listPatients() { - return api.get<{ data: Patient[]; total: number }>('/health/patients?page=1&page_size=100'); + return api.get<{ data: Patient[]; total: number }>('/health/patients', { + page: 1, + page_size: 100, + }); } export async function createPatient(data: { diff --git a/apps/miniprogram/src/services/report.ts b/apps/miniprogram/src/services/report.ts index 195ca4c..4c56258 100644 --- a/apps/miniprogram/src/services/report.ts +++ b/apps/miniprogram/src/services/report.ts @@ -20,7 +20,8 @@ export interface LabReport { export async function listReports(patientId: string, page = 1) { return api.get<{ data: LabReport[]; total: number }>( - `/health/patients/${patientId}/lab-reports?page=${page}&page_size=20` + `/health/patients/${patientId}/lab-reports`, + { page, page_size: 20 }, ); } diff --git a/apps/miniprogram/src/services/request.ts b/apps/miniprogram/src/services/request.ts index b835eb1..995acdc 100644 --- a/apps/miniprogram/src/services/request.ts +++ b/apps/miniprogram/src/services/request.ts @@ -2,6 +2,7 @@ import Taro from '@tarojs/taro'; import { secureGet, secureSet, secureRemove } from '@/utils/secure-storage'; const BASE_URL = process.env.TARO_APP_API_URL || 'http://localhost:3000/api/v1'; +const IS_DEV = process.env.NODE_ENV !== 'production'; interface ApiResponse { success: boolean; @@ -44,12 +45,23 @@ async function tryRefreshToken(): Promise { export async function request(method: string, path: string, data?: unknown): Promise { const headers = await getHeaders(); - const res = await Taro.request({ url: `${BASE_URL}${path}`, method, data, header: headers }); + const url = `${BASE_URL}${path}`; + if (IS_DEV) { + console.log(`[API] ${method} ${path}`, data ?? ''); + } + const res = await Taro.request({ url, method: method as any, data, header: headers, timeout: 30000 }); + if (IS_DEV) { + console.log(`[API] ${method} ${path} → ${res.statusCode}`); + } if (res.statusCode === 401) { const refreshed = await tryRefreshToken(); if (refreshed) return request(method, path, data); - Taro.redirectTo({ url: '/pages/login/index' }); + const pages = Taro.getCurrentPages(); + const currentPath = pages[pages.length - 1]?.path || ''; + if (!currentPath.includes('pages/login')) { + Taro.redirectTo({ url: '/pages/login/index' }); + } throw new Error('登录已过期'); } @@ -58,8 +70,20 @@ export async function request(method: string, path: string, data?: unknown): return body.data as T; } +function buildQuery(params?: Record): string { + if (!params) return ''; + const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== ''); + return entries.length > 0 + ? '?' + + entries + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`) + .join('&') + : ''; +} + export const api = { - get: (path: string) => request('GET', path), + get: (path: string, params?: Record) => + request('GET', `${path}${buildQuery(params)}`), post: (path: string, data?: unknown) => request('POST', path, data), put: (path: string, data?: unknown) => request('PUT', path, data), delete: (path: string) => request('DELETE', path), diff --git a/apps/web/src/api/health/appointments.ts b/apps/web/src/api/health/appointments.ts index b2832b2..7df9d5a 100644 --- a/apps/web/src/api/health/appointments.ts +++ b/apps/web/src/api/health/appointments.ts @@ -13,6 +13,8 @@ export interface Appointment { status: string; cancel_reason?: string; notes?: string; + patient_name?: string; + doctor_name?: string; created_at: string; updated_at: string; version: number; diff --git a/apps/web/src/hooks/usePaginatedData.ts b/apps/web/src/hooks/usePaginatedData.ts index b5f9fe0..06c93cd 100644 --- a/apps/web/src/hooks/usePaginatedData.ts +++ b/apps/web/src/hooks/usePaginatedData.ts @@ -1,4 +1,4 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef, useEffect } from 'react'; import { message } from 'antd'; interface PaginatedState { @@ -8,9 +8,23 @@ interface PaginatedState { loading: boolean; } +/** + * 通用分页数据 Hook,封装 data / total / page / loading / fetch 逻辑。 + * + * 支持两种签名: + * 1. 三参数 (page, pageSize, search) — 带搜索的列表页 + * 2. 两参数 (page, pageSize) — 纯分页,不含搜索 + * + * @param fetchFn - 数据获取函数 + * @param pageSize - 每页条数,默认 20 + * @param autoFetch - 是否在 mount / fetchFn 变化时自动请求第一页,默认 true + */ export function usePaginatedData( - fetchFn: (page: number, pageSize: number, search: string) => Promise<{ data: T[]; total: number }>, + fetchFn: + | ((page: number, pageSize: number, search: string) => Promise<{ data: T[]; total: number }>) + | ((page: number, pageSize: number) => Promise<{ data: T[]; total: number }>), pageSize = 20, + autoFetch = true, ) { const [state, setState] = useState>({ data: [], @@ -20,17 +34,44 @@ export function usePaginatedData( }); const [searchText, setSearchText] = useState(''); - const refresh = useCallback(async (p?: number) => { - const targetPage = p ?? state.page; - setState(s => ({ ...s, loading: true })); - try { - const result = await fetchFn(targetPage, pageSize, searchText); - setState({ data: result.data, total: result.total, page: targetPage, loading: false }); - } catch { - message.error('加载数据失败'); - setState(s => ({ ...s, loading: false })); + // 用 ref 保存最新 fetchFn,避免 refresh 因闭包引用过期 fetchFn 而频繁重建 + const fetchFnRef = useRef(fetchFn); + fetchFnRef.current = fetchFn; + + // 用 ref 保存最新 searchText,同理 + const searchTextRef = useRef(searchText); + searchTextRef.current = searchText; + + const refresh = useCallback( + async (p?: number) => { + const targetPage = p ?? state.page; + setState((s) => ({ ...s, loading: true })); + try { + // 统一按三参数调用;若 fetchFn 只接受两参数,第三个参数会被忽略 + const result = await (fetchFnRef.current as ( + page: number, + pageSize: number, + search: string, + ) => Promise<{ data: T[]; total: number }>)( + targetPage, + pageSize, + searchTextRef.current, + ); + setState({ data: result.data, total: result.total, page: targetPage, loading: false }); + } catch { + message.error('加载数据失败'); + setState((s) => ({ ...s, loading: false })); + } + }, + [pageSize, state.page], + ); + + // mount 或 fetchFn 变化时自动请求 + useEffect(() => { + if (autoFetch) { + refresh(1); } - }, [fetchFn, pageSize, searchText, state.page]); + }, [autoFetch, refresh]); return { ...state, searchText, setSearchText, refresh }; } diff --git a/apps/web/src/pages/health/AppointmentList.tsx b/apps/web/src/pages/health/AppointmentList.tsx index b5e7539..49e7383 100644 --- a/apps/web/src/pages/health/AppointmentList.tsx +++ b/apps/web/src/pages/health/AppointmentList.tsx @@ -168,7 +168,7 @@ export default function AppointmentList() { key: 'patient_name', width: 100, render: (_: unknown, record: Appointment) => - (record as unknown as Record).patient_name as string || record.patient_id.slice(0, 8), + record.patient_name ?? record.patient_id.slice(0, 8), }, { title: '医护', @@ -176,8 +176,7 @@ export default function AppointmentList() { key: 'doctor_name', width: 100, render: (_: unknown, record: Appointment) => { - const name = (record as unknown as Record).doctor_name as string | undefined; - return name || record.doctor_id?.slice(0, 8) || '-'; + return record.doctor_name || record.doctor_id?.slice(0, 8) || '-'; }, }, { diff --git a/apps/web/src/pages/health/PatientDetail.tsx b/apps/web/src/pages/health/PatientDetail.tsx index b07c587..2abfb1d 100644 --- a/apps/web/src/pages/health/PatientDetail.tsx +++ b/apps/web/src/pages/health/PatientDetail.tsx @@ -4,7 +4,6 @@ import { Card, Descriptions, Tabs, - Table, Button, Space, Modal, @@ -12,29 +11,20 @@ import { Input, Select, DatePicker, - Tag, message, Spin, } from 'antd'; -import { - ArrowLeftOutlined, - EditOutlined, -} from '@ant-design/icons'; +import { ArrowLeftOutlined, EditOutlined } from '@ant-design/icons'; import { patientApi } from '../../api/health/patients'; import type { PatientDetail as PatientDetailType, UpdatePatientReq, } from '../../api/health/patients'; -import { healthDataApi } from '../../api/health/healthData'; -import type { - VitalSigns, - LabReport, - HealthRecord, -} from '../../api/health/healthData'; -import { followUpApi } from '../../api/health/followUp'; -import type { FollowUpRecord } from '../../api/health/followUp'; import { StatusTag } from './components/StatusTag'; -import { VitalSignsChart } from './components/VitalSignsChart'; +import { VitalSignsTab } from './components/VitalSignsTab'; +import { LabReportsTab } from './components/LabReportsTab'; +import { HealthRecordsTab } from './components/HealthRecordsTab'; +import { FollowUpTab } from './components/FollowUpTab'; import { GENDER_OPTIONS, BLOOD_TYPE_OPTIONS } from '../../constants/health'; import { useThemeMode } from '../../hooks/useThemeMode'; @@ -53,28 +43,7 @@ export default function PatientDetail() { const [form] = Form.useForm(); const isDark = useThemeMode(); - // 健康数据子 tab 的状态 - const [vitalSigns, setVitalSigns] = useState([]); - const [vitalSignsTotal, setVitalSignsTotal] = useState(0); - const [vitalSignsPage, setVitalSignsPage] = useState(1); - const [vitalSignsLoading, setVitalSignsLoading] = useState(false); - - const [labReports, setLabReports] = useState([]); - const [labReportsTotal, setLabReportsTotal] = useState(0); - const [labReportsPage, setLabReportsPage] = useState(1); - const [labReportsLoading, setLabReportsLoading] = useState(false); - - const [healthRecords, setHealthRecords] = useState([]); - const [healthRecordsTotal, setHealthRecordsTotal] = useState(0); - const [healthRecordsPage, setHealthRecordsPage] = useState(1); - const [healthRecordsLoading, setHealthRecordsLoading] = useState(false); - - // 随访记录状态 - const [followUpRecords, setFollowUpRecords] = useState([]); - const [followUpTotal, setFollowUpTotal] = useState(0); - const [followUpPage, setFollowUpPage] = useState(1); - const [followUpLoading, setFollowUpLoading] = useState(false); - + // --- 加载患者基本信息 --- const fetchPatient = useCallback(async () => { if (!id) return; setLoading(true); @@ -87,103 +56,11 @@ export default function PatientDetail() { setLoading(false); }, [id]); - const fetchVitalSigns = useCallback( - async (p = vitalSignsPage) => { - if (!id) return; - setVitalSignsLoading(true); - try { - const result = await healthDataApi.listVitalSigns(id, { - page: p, - page_size: 10, - }); - setVitalSigns(result.data); - setVitalSignsTotal(result.total); - } catch { - message.error('加载体征数据失败'); - } - setVitalSignsLoading(false); - }, - [id, vitalSignsPage], - ); - - const fetchLabReports = useCallback( - async (p = labReportsPage) => { - if (!id) return; - setLabReportsLoading(true); - try { - const result = await healthDataApi.listLabReports(id, { - page: p, - page_size: 10, - }); - setLabReports(result.data); - setLabReportsTotal(result.total); - } catch { - message.error('加载化验报告失败'); - } - setLabReportsLoading(false); - }, - [id, labReportsPage], - ); - - const fetchHealthRecords = useCallback( - async (p = healthRecordsPage) => { - if (!id) return; - setHealthRecordsLoading(true); - try { - const result = await healthDataApi.listHealthRecords(id, { - page: p, - page_size: 10, - }); - setHealthRecords(result.data); - setHealthRecordsTotal(result.total); - } catch { - message.error('加载健康档案失败'); - } - setHealthRecordsLoading(false); - }, - [id, healthRecordsPage], - ); - - const fetchFollowUpRecords = useCallback( - async (p = followUpPage) => { - if (!id) return; - setFollowUpLoading(true); - try { - const result = await followUpApi.listRecords({ - patient_id: id, - page: p, - page_size: 10, - }); - setFollowUpRecords(result.data); - setFollowUpTotal(result.total); - } catch { - message.error('加载随访记录失败'); - } - setFollowUpLoading(false); - }, - [id, followUpPage], - ); - useEffect(() => { fetchPatient(); }, [fetchPatient]); - useEffect(() => { - fetchVitalSigns(); - }, [fetchVitalSigns]); - - useEffect(() => { - fetchLabReports(); - }, [fetchLabReports]); - - useEffect(() => { - fetchHealthRecords(); - }, [fetchHealthRecords]); - - useEffect(() => { - fetchFollowUpRecords(); - }, [fetchFollowUpRecords]); - + // --- 编辑患者 --- const handleEdit = async (values: { name?: string; gender?: string; @@ -231,146 +108,7 @@ export default function PatientDetail() { setEditModalOpen(true); }; - // 体征数据列定义 - const vitalSignsColumns = [ - { - 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 labReportColumns = [ - { - 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 healthRecordColumns = [ - { - 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 followUpColumns = [ - { - title: '执行日期', - dataIndex: 'executed_date', - key: 'executed_date', - width: 120, - }, - { - title: '随访结果', - dataIndex: 'result', - key: 'result', - ellipsis: true, - }, - { - title: '患者状况', - dataIndex: 'patient_condition', - key: 'patient_condition', - ellipsis: true, - }, - { - title: '医嘱', - dataIndex: 'medical_advice', - key: 'medical_advice', - ellipsis: true, - }, - { - title: '下次随访日期', - dataIndex: 'next_follow_up_date', - key: 'next_follow_up_date', - width: 130, - render: (v?: string) => v || '-', - }, - ]; - + // --- 加载状态 --- if (loading) { return (
@@ -388,17 +126,17 @@ export default function PatientDetail() { ); } + // --- 主题卡片样式 --- + const cardStyle = { + borderRadius: 12, + background: isDark ? '#111827' : '#FFFFFF', + border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`, + }; + return (
{/* 顶部导航 */} -
+
{/* 患者基本信息卡片 */} - +
- {patient.created_at - ? new Date(patient.created_at).toLocaleString('zh-CN') - : '-'} + {patient.created_at ? new Date(patient.created_at).toLocaleString('zh-CN') : '-'} {/* 标签页 */} - + - - - {patient.name} - - - {GENDER_LABEL[patient.gender || ''] || - patient.gender || - '-'} - - - {patient.birth_date || '-'} - - - {patient.blood_type || '-'} - - - {patient.id_number || '-'} - - - - - - - - - {patient.source || '-'} - - - {patient.allergy_history || '-'} - - - {patient.medical_history_summary || '-'} - - - {patient.emergency_contact_name || '-'} - - - {patient.emergency_contact_phone || '-'} - - - {patient.notes || '-'} - - -
+ + {patient.name} + + {GENDER_LABEL[patient.gender || ''] || patient.gender || '-'} + + + {patient.birth_date || '-'} + + + {patient.blood_type || '-'} + + + {patient.id_number || '-'} + + + + + + + + + {patient.source || '-'} + + + {patient.allergy_history || '-'} + + + {patient.medical_history_summary || '-'} + + + {patient.emergency_contact_name || '-'} + + + {patient.emergency_contact_phone || '-'} + + + {patient.notes || '-'} + + ), }, { key: 'health', label: '健康数据', - children: ( + children: id ? ( - {id && ( -
- -
- )} - setVitalSignsPage(p), - showTotal: (t) => `共 ${t} 条`, - style: { margin: 0 }, - }} - /> - - ), - }, - { - key: 'lab', - label: '化验报告', - children: ( -
setLabReportsPage(p), - showTotal: (t) => `共 ${t} 条`, - style: { margin: 0 }, - }} - /> - ), - }, - { - key: 'records', - label: '健康档案', - children: ( -
setHealthRecordsPage(p), - showTotal: (t) => `共 ${t} 条`, - style: { margin: 0 }, - }} - /> - ), - }, + { key: 'vital', label: '体征数据', children: }, + { key: 'lab', label: '化验报告', children: }, + { key: 'records', label: '健康档案', children: }, ]} /> - ), + ) : null, }, { key: 'followup', label: '随访记录', - children: ( -
setFollowUpPage(p), - showTotal: (t) => `共 ${t} 条`, - style: { margin: 0 }, - }} - /> - ), + children: id ? : null, }, ]} /> @@ -659,12 +288,7 @@ export default function PatientDetail() { onOk={() => form.submit()} width={600} > -
+ - @@ -697,18 +317,10 @@ export default function PatientDetail() {
- + - +
diff --git a/apps/web/src/pages/health/components/ChatBubble.tsx b/apps/web/src/pages/health/components/ChatBubble.tsx deleted file mode 100644 index 8078a8e..0000000 --- a/apps/web/src/pages/health/components/ChatBubble.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { Avatar, Typography } from 'antd'; -import { UserOutlined } from '@ant-design/icons'; - -interface Props { - senderRole: 'patient' | 'doctor' | 'system'; - senderName?: string; - content: string; - contentType?: string; - createdAt: string; -} - -const ROLE_CONFIG = { - patient: { align: 'flex-start' as const, bg: '#f0f0f0', color: '#000' }, - doctor: { align: 'flex-end' as const, bg: '#1890ff', color: '#fff' }, - system: { align: 'center' as const, bg: '#fafafa', color: '#999' }, -}; - -export function ChatBubble({ - senderRole, - senderName, - content, - createdAt, -}: Props) { - const cfg = ROLE_CONFIG[senderRole] ?? ROLE_CONFIG.system; - - if (senderRole === 'system') { - return ( -
- - {content} - -
- ); - } - - return ( -
- {senderRole === 'patient' && ( - } style={{ marginRight: 8, flexShrink: 0 }} /> - )} -
- {senderName && ( - - {senderName} - - )} -
- - {content} - -
- - {createdAt} - -
- {senderRole === 'doctor' && ( - } style={{ marginLeft: 8, flexShrink: 0 }} /> - )} -
- ); -} diff --git a/apps/web/src/pages/health/components/ExportButton.tsx b/apps/web/src/pages/health/components/ExportButton.tsx index 9e2e2f0..e949a67 100644 --- a/apps/web/src/pages/health/components/ExportButton.tsx +++ b/apps/web/src/pages/health/components/ExportButton.tsx @@ -1,5 +1,6 @@ import { Button, message } from 'antd'; import { DownloadOutlined } from '@ant-design/icons'; +import client from '../../../api/client'; interface Props { fetchUrl: string; @@ -19,12 +20,12 @@ export function ExportButton({ const query = params ? '?' + new URLSearchParams(params).toString() : ''; - const token = localStorage.getItem('access_token'); - const resp = await fetch(`/api/v1${fetchUrl}${query}`, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, + const resp = await client.get(fetchUrl + query, { + responseType: 'blob', }); - if (!resp.ok) throw new Error('导出失败'); - const blob = await resp.blob(); + const blob = resp.data instanceof Blob + ? resp.data + : new Blob([resp.data]); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; diff --git a/apps/web/src/pages/health/components/FollowUpTab.tsx b/apps/web/src/pages/health/components/FollowUpTab.tsx new file mode 100644 index 0000000..7d20198 --- /dev/null +++ b/apps/web/src/pages/health/components/FollowUpTab.tsx @@ -0,0 +1,83 @@ +import { useCallback } from 'react'; +import { Table } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { followUpApi } from '../../../api/health/followUp'; +import type { FollowUpRecord } from '../../../api/health/followUp'; +import { usePaginatedData } from '../../../hooks/usePaginatedData'; + +interface Props { + patientId: string; +} + +const columns: ColumnsType = [ + { + title: '执行日期', + dataIndex: 'executed_date', + key: 'executed_date', + width: 120, + }, + { + title: '随访结果', + dataIndex: 'result', + key: 'result', + ellipsis: true, + }, + { + title: '患者状况', + dataIndex: 'patient_condition', + key: 'patient_condition', + ellipsis: true, + }, + { + title: '医嘱', + dataIndex: 'medical_advice', + key: 'medical_advice', + ellipsis: true, + }, + { + title: '下次随访日期', + dataIndex: 'next_follow_up_date', + key: 'next_follow_up_date', + width: 130, + render: (v?: string) => v || '-', + }, +]; + +/** + * 随访记录标签页 — 分页表格 + */ +export function FollowUpTab({ patientId }: Props) { + const fetcher = useCallback( + async (page: number, pageSize: number) => { + return followUpApi.listRecords({ + patient_id: patientId, + page, + page_size: pageSize, + }); + }, + [patientId], + ); + + const { data, total, page, loading, refresh } = usePaginatedData( + fetcher, + 10, + ); + + return ( +
refresh(p), + showTotal: (t) => `共 ${t} 条`, + style: { margin: 0 }, + }} + /> + ); +} diff --git a/apps/web/src/pages/health/components/HealthRecordsTab.tsx b/apps/web/src/pages/health/components/HealthRecordsTab.tsx new file mode 100644 index 0000000..0841338 --- /dev/null +++ b/apps/web/src/pages/health/components/HealthRecordsTab.tsx @@ -0,0 +1,77 @@ +import { useCallback } from 'react'; +import { Table, Tag } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { healthDataApi } from '../../../api/health/healthData'; +import type { HealthRecord } from '../../../api/health/healthData'; +import { usePaginatedData } from '../../../hooks/usePaginatedData'; + +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'), + }, +]; + +/** + * 健康档案标签页 — 分页表格 + */ +export function HealthRecordsTab({ patientId }: Props) { + const fetcher = useCallback( + async (page: number, pageSize: number) => { + return healthDataApi.listHealthRecords(patientId, { + page, + page_size: pageSize, + }); + }, + [patientId], + ); + + const { data, total, page, loading, refresh } = usePaginatedData( + fetcher, + 10, + ); + + return ( +
refresh(p), + showTotal: (t) => `共 ${t} 条`, + style: { margin: 0 }, + }} + /> + ); +} diff --git a/apps/web/src/pages/health/components/LabReportsTab.tsx b/apps/web/src/pages/health/components/LabReportsTab.tsx new file mode 100644 index 0000000..c4dc847 --- /dev/null +++ b/apps/web/src/pages/health/components/LabReportsTab.tsx @@ -0,0 +1,77 @@ +import { useCallback } from 'react'; +import { Table, Tag } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { healthDataApi } from '../../../api/health/healthData'; +import type { LabReport } from '../../../api/health/healthData'; +import { usePaginatedData } from '../../../hooks/usePaginatedData'; + +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'), + }, +]; + +/** + * 化验报告标签页 — 分页表格 + */ +export function LabReportsTab({ patientId }: Props) { + const fetcher = useCallback( + async (page: number, pageSize: number) => { + return healthDataApi.listLabReports(patientId, { + page, + page_size: pageSize, + }); + }, + [patientId], + ); + + const { data, total, page, loading, refresh } = usePaginatedData( + fetcher, + 10, + ); + + return ( +
refresh(p), + showTotal: (t) => `共 ${t} 条`, + style: { margin: 0 }, + }} + /> + ); +} diff --git a/apps/web/src/pages/health/components/VitalSignsTab.tsx b/apps/web/src/pages/health/components/VitalSignsTab.tsx new file mode 100644 index 0000000..9907eab --- /dev/null +++ b/apps/web/src/pages/health/components/VitalSignsTab.tsx @@ -0,0 +1,98 @@ +import { useCallback } from 'react'; +import { Table } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { healthDataApi } from '../../../api/health/healthData'; +import type { VitalSigns } from '../../../api/health/healthData'; +import { VitalSignsChart } from './VitalSignsChart'; +import { usePaginatedData } from '../../../hooks/usePaginatedData'; + +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` : '-'), + }, +]; + +/** + * 体征数据标签页 — 含趋势图 + 分页表格 + */ +export function VitalSignsTab({ patientId }: Props) { + const fetcher = useCallback( + async (page: number, pageSize: number) => { + return healthDataApi.listVitalSigns(patientId, { + page, + page_size: pageSize, + }); + }, + [patientId], + ); + + const { data, total, page, loading, refresh } = usePaginatedData( + fetcher, + 10, + ); + + return ( +
+
+ +
+
refresh(p), + showTotal: (t) => `共 ${t} 条`, + style: { margin: 0 }, + }} + /> + + ); +} diff --git a/crates/erp-health/src/service/trend_service.rs b/crates/erp-health/src/service/trend_service.rs index c69fb02..7ab78c7 100644 --- a/crates/erp-health/src/service/trend_service.rs +++ b/crates/erp-health/src/service/trend_service.rs @@ -252,11 +252,98 @@ fn parse_range_days(range: &Option) -> i64 { match range.as_deref() { Some("30d") => 30, Some("90d") => 90, - // 默认 7 天(包括 "7d" 和 None) _ => 7, } } +/// 根据参考范围计算指标状态 +fn compute_status(value: f64, low: f64, high: f64) -> &'static str { + if value < low { + "low" + } else if value > high { + "high" + } else { + "normal" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- parse_range_days --- + #[test] + fn range_7d() { + assert_eq!(7, parse_range_days(&Some("7d".to_string()))); + } + + #[test] + fn range_30d() { + assert_eq!(30, parse_range_days(&Some("30d".to_string()))); + } + + #[test] + fn range_90d() { + assert_eq!(90, parse_range_days(&Some("90d".to_string()))); + } + + #[test] + fn range_none_defaults_7() { + assert_eq!(7, parse_range_days(&None)); + } + + #[test] + fn range_invalid_defaults_7() { + assert_eq!(7, parse_range_days(&Some("1y".to_string()))); + } + + // --- compute_status --- + #[test] + fn status_normal() { + assert_eq!("normal", compute_status(75.0, 60.0, 100.0)); + } + + #[test] + fn status_low() { + assert_eq!("low", compute_status(50.0, 60.0, 100.0)); + } + + #[test] + fn status_high() { + assert_eq!("high", compute_status(120.0, 60.0, 100.0)); + } + + #[test] + fn status_at_low_boundary() { + assert_eq!("normal", compute_status(60.0, 60.0, 100.0)); + } + + #[test] + fn status_at_high_boundary() { + assert_eq!("normal", compute_status(100.0, 60.0, 100.0)); + } + + #[test] + fn status_just_below_low() { + assert_eq!("low", compute_status(59.9, 60.0, 100.0)); + } + + #[test] + fn status_just_above_high() { + assert_eq!("high", compute_status(100.1, 60.0, 100.0)); + } + + #[test] + fn status_blood_sugar_normal() { + assert_eq!("normal", compute_status(5.0, 3.9, 6.1)); + } + + #[test] + fn status_blood_sugar_high() { + assert_eq!("high", compute_status(7.0, 3.9, 6.1)); + } +} + /// 小程序趋势查询:通过当前用户的 user_id 关联 patient,查询指定指标的时间序列。 /// /// 逻辑流程: @@ -319,17 +406,6 @@ pub async fn get_mini_trend( // 小程序今日体征摘要 // --------------------------------------------------------------------------- -/// 根据参考范围计算指标状态 -fn compute_status(value: f64, low: f64, high: f64) -> &'static str { - if value < low { - "low" - } else if value > high { - "high" - } else { - "normal" - } -} - /// 查询今日最新体征记录并生成摘要 pub async fn get_mini_today( state: &HealthState, diff --git a/crates/erp-plugin/src/dynamic_table.rs b/crates/erp-plugin/src/dynamic_table.rs index 395f85b..ddcec2d 100644 --- a/crates/erp-plugin/src/dynamic_table.rs +++ b/crates/erp-plugin/src/dynamic_table.rs @@ -1678,4 +1678,91 @@ mod tests { ); assert!(result.is_err(), "不支持的 grain 应报错"); } + + // ===== sanitize_identifier SQL 注入防护测试 ===== + + #[test] + fn test_sanitize_removes_special_chars() { + let result = sanitize_identifier("table;name'here\"with`special"); + assert!( + !result.contains(';'), + "分号应被替换: {}", + result + ); + assert!( + !result.contains('\''), + "单引号应被替换: {}", + result + ); + assert!( + !result.contains('"'), + "双引号应被替换: {}", + result + ); + assert!( + !result.contains('`'), + "反引号应被替换: {}", + result + ); + } + + #[test] + fn test_sanitize_allows_alphanumeric_underscore() { + let result = sanitize_identifier("my_table_123"); + assert_eq!( + result, "my_table_123", + "合法标识符应原样保留" + ); + } + + #[test] + fn test_sanitize_handles_drop_table() { + let result = sanitize_identifier("users; DROP TABLE users;"); + assert_eq!( + result, "users__DROP_TABLE_users_", + "DROP TABLE 注入应被清理为下划线: {}", + result + ); + assert!( + !result.contains(';'), + "不应包含分号: {}", + result + ); + } + + #[test] + fn test_sanitize_handles_sql_comment() { + let result = sanitize_identifier("users--"); + assert_eq!( + result, "users__", + "SQL 注释应被替换为下划线: {}", + result + ); + assert!( + !result.contains('-'), + "不应包含连字符: {}", + result + ); + } + + #[test] + fn test_sanitize_handles_union_injection() { + let result = sanitize_identifier("users UNION SELECT"); + assert_eq!( + result, "users_UNION_SELECT", + "UNION 注入中空格应被替换为下划线: {}", + result + ); + assert!( + !result.contains(' '), + "不应包含空格: {}", + result + ); + } + + #[test] + fn test_sanitize_empty_string() { + let result = sanitize_identifier(""); + assert_eq!(result, "", "空字符串应保持为空"); + } } diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index 6674699..318531c 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -48,6 +48,7 @@ mod m20260424_000045_health_indexes; mod m20260424_000046_health_constraints_fix; mod m20260424_000047_health_index_fix; mod m20260425_000048_add_patient_id_number_hash; +mod m20260425_000049_widen_patient_id_number; pub struct Migrator; @@ -103,6 +104,7 @@ impl MigratorTrait for Migrator { Box::new(m20260424_000046_health_constraints_fix::Migration), Box::new(m20260424_000047_health_index_fix::Migration), Box::new(m20260425_000048_add_patient_id_number_hash::Migration), + Box::new(m20260425_000049_widen_patient_id_number::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260425_000049_widen_patient_id_number.rs b/crates/erp-server/migration/src/m20260425_000049_widen_patient_id_number.rs new file mode 100644 index 0000000..bf12ec6 --- /dev/null +++ b/crates/erp-server/migration/src/m20260425_000049_widen_patient_id_number.rs @@ -0,0 +1,51 @@ +use sea_orm_migration::prelude::*; + +pub struct Migration; + +impl MigrationName for Migration { + fn name(&self) -> &str { + "m20260425_000049_widen_patient_id_number" + } +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let conn = manager.get_connection(); + + // 先删除依赖 id_number 列的唯一索引 + conn.execute_unprepared("DROP INDEX IF EXISTS idx_patient_tenant_id_number") + .await?; + + // 加宽 id_number 列:varchar(20) → varchar(255),容纳 AES-256-GCM 加密值(~88 字符) + conn.execute_unprepared( + "ALTER TABLE patient ALTER COLUMN id_number TYPE varchar(255)", + ) + .await?; + + // 重建唯一索引(partial,排除软删除和空值) + conn.execute_unprepared( + "CREATE UNIQUE INDEX idx_patient_tenant_id_number ON patient (tenant_id, id_number) WHERE deleted_at IS NULL AND id_number IS NOT NULL", + ).await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let conn = manager.get_connection(); + + conn.execute_unprepared("DROP INDEX IF EXISTS idx_patient_tenant_id_number") + .await?; + + conn.execute_unprepared( + "ALTER TABLE patient ALTER COLUMN id_number TYPE varchar(20)", + ) + .await?; + + conn.execute_unprepared( + "CREATE UNIQUE INDEX idx_patient_tenant_id_number ON patient (tenant_id, id_number) WHERE deleted_at IS NULL AND id_number IS NOT NULL", + ).await?; + + Ok(()) + } +} diff --git a/crates/erp-server/tests/integration.rs b/crates/erp-server/tests/integration.rs index c626a53..d4bfb3f 100644 --- a/crates/erp-server/tests/integration.rs +++ b/crates/erp-server/tests/integration.rs @@ -8,3 +8,5 @@ mod plugin_tests; mod workflow_tests; #[path = "integration/health_patient_tests.rs"] mod health_patient_tests; +#[path = "integration/health_appointment_tests.rs"] +mod health_appointment_tests; diff --git a/crates/erp-server/tests/integration/health_appointment_tests.rs b/crates/erp-server/tests/integration/health_appointment_tests.rs new file mode 100644 index 0000000..93a1f19 --- /dev/null +++ b/crates/erp-server/tests/integration/health_appointment_tests.rs @@ -0,0 +1,248 @@ +//! erp-health 预约排班集成测试 +//! +//! 验证预约 CRUD、租户隔离、CAS 排班名额等核心行为。 +//! 使用 TestDb 创建隔离 PostgreSQL 数据库,直接调用 service 层函数。 +//! 预约创建依赖患者 + 医护档案 + 排班三条前置数据。 + +use erp_core::events::EventBus; +use erp_health::dto::appointment_dto::CreateAppointmentReq; +use erp_health::dto::doctor_dto::CreateDoctorReq; +use erp_health::dto::patient_dto::CreatePatientReq; +use erp_health::service::{ + appointment_service, doctor_service, patient_service, +}; +use erp_health::state::HealthState; +use erp_health::HealthCrypto; + +use super::test_db::TestDb; + +/// 构建测试用 HealthState +fn make_state(db: &sea_orm::DatabaseConnection) -> HealthState { + HealthState { + db: db.clone(), + event_bus: EventBus::new(100), + crypto: HealthCrypto::dev_default(), + } +} + +/// 创建患者并返回其 ID +async fn seed_patient( + state: &HealthState, + tenant_id: uuid::Uuid, + name: &str, +) -> uuid::Uuid { + let req = CreatePatientReq { + name: name.to_string(), + gender: Some("male".to_string()), + birth_date: None, + blood_type: None, + id_number: None, + allergy_history: None, + medical_history_summary: None, + emergency_contact_name: None, + emergency_contact_phone: None, + source: None, + notes: None, + }; + let patient = patient_service::create_patient(state, tenant_id, None, req) + .await + .expect("创建患者应成功"); + patient.id +} + +/// 创建医护档案并返回其 ID +async fn seed_doctor( + state: &HealthState, + tenant_id: uuid::Uuid, + name: &str, +) -> uuid::Uuid { + let req = CreateDoctorReq { + user_id: None, + name: name.to_string(), + department: Some("内科".to_string()), + title: Some("主治医师".to_string()), + specialty: Some("心血管内科".to_string()), + license_number: None, + bio: None, + }; + let doctor = doctor_service::create_doctor(state, tenant_id, None, req) + .await + .expect("创建医护档案应成功"); + doctor.id +} + +/// 创建排班并返回其 ID +async fn seed_schedule( + state: &HealthState, + tenant_id: uuid::Uuid, + doctor_id: uuid::Uuid, + date: chrono::NaiveDate, +) -> uuid::Uuid { + let req = erp_health::dto::appointment_dto::CreateScheduleReq { + doctor_id, + schedule_date: date, + period_type: Some("am".to_string()), + start_time: chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap(), + end_time: chrono::NaiveTime::from_hms_opt(12, 0, 0).unwrap(), + max_appointments: 10, + }; + let schedule = appointment_service::create_schedule(state, tenant_id, None, req) + .await + .expect("创建排班应成功"); + schedule.id +} + +// --------------------------------------------------------------------------- +// 测试 1: 创建预约 +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_create_appointment() { + let test_db = TestDb::new().await; + let state = make_state(test_db.db()); + let tenant_id = uuid::Uuid::new_v4(); + let operator_id = uuid::Uuid::new_v4(); + + // 前置:患者 + 医护 + 排班 + let patient_id = seed_patient(&state, tenant_id, "预约测试患者").await; + let doctor_id = seed_doctor(&state, tenant_id, "预约测试医生").await; + let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 10).unwrap(); + seed_schedule(&state, tenant_id, doctor_id, date).await; + + let req = CreateAppointmentReq { + patient_id, + doctor_id: Some(doctor_id), + appointment_type: Some("outpatient".to_string()), + appointment_date: date, + start_time: chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap(), + end_time: chrono::NaiveTime::from_hms_opt(9, 30, 0).unwrap(), + notes: Some("首次就诊".to_string()), + }; + + let appointment = + appointment_service::create_appointment(&state, tenant_id, Some(operator_id), req) + .await + .expect("创建预约应成功"); + + assert_eq!(appointment.patient_id, patient_id); + assert_eq!(appointment.doctor_id, Some(doctor_id)); + assert_eq!(appointment.appointment_type, "outpatient"); + assert_eq!(appointment.status, "pending"); + assert_eq!(appointment.version, 1); + assert_eq!( + appointment.notes, + Some("首次就诊".to_string()) + ); + + // 通过 get_appointment 验证存储正确 + let found = appointment_service::get_appointment(&state, tenant_id, appointment.id) + .await + .expect("查询预约应成功"); + assert_eq!(found.id, appointment.id); + assert_eq!(found.status, "pending"); + assert_eq!(found.version, 1); +} + +// --------------------------------------------------------------------------- +// 测试 2: 列表查询 — 创建 2 条预约后验证分页计数 +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_list_appointments() { + let test_db = TestDb::new().await; + let state = make_state(test_db.db()); + let tenant_id = uuid::Uuid::new_v4(); + + let patient_id = seed_patient(&state, tenant_id, "列表测试患者").await; + let doctor_id = seed_doctor(&state, tenant_id, "列表测试医生").await; + let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 12).unwrap(); + seed_schedule(&state, tenant_id, doctor_id, date).await; + + // 创建 2 条预约(同一个排班时段,CAS 按排班 start_time 匹配) + for _i in 0..2 { + let req = CreateAppointmentReq { + patient_id, + doctor_id: Some(doctor_id), + appointment_type: None, + appointment_date: date, + start_time: chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap(), + end_time: chrono::NaiveTime::from_hms_opt(9, 30, 0).unwrap(), + notes: None, + }; + appointment_service::create_appointment(&state, tenant_id, None, req) + .await + .expect("创建预约应成功"); + } + + let result = appointment_service::list_appointments( + &state, + tenant_id, + 1, + 10, + None, + None, + None, + None, + ) + .await + .expect("列表查询应成功"); + + assert_eq!(result.total, 2, "应有 2 条预约记录"); + assert_eq!(result.data.len(), 2, "当前页应返回 2 条"); +} + +// --------------------------------------------------------------------------- +// 测试 3: 租户隔离 — 租户 A 的预约对租户 B 不可见 +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_appointment_tenant_isolation() { + let test_db = TestDb::new().await; + let state = make_state(test_db.db()); + let tenant_a = uuid::Uuid::new_v4(); + let tenant_b = uuid::Uuid::new_v4(); + + // 租户 A 创建完整链路:患者 + 医护 + 排班 + 预约 + let patient_a = seed_patient(&state, tenant_a, "租户A患者").await; + let doctor_a = seed_doctor(&state, tenant_a, "租户A医生").await; + let date = chrono::NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(); + seed_schedule(&state, tenant_a, doctor_a, date).await; + + let req = CreateAppointmentReq { + patient_id: patient_a, + doctor_id: Some(doctor_a), + appointment_type: None, + appointment_date: date, + start_time: chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap(), + end_time: chrono::NaiveTime::from_hms_opt(9, 30, 0).unwrap(), + notes: None, + }; + let appointment_a = + appointment_service::create_appointment(&state, tenant_a, None, req) + .await + .expect("租户 A 创建预约应成功"); + + // 租户 B 列表查询应看不到租户 A 的预约 + let result_b = appointment_service::list_appointments( + &state, + tenant_b, + 1, + 10, + None, + None, + None, + None, + ) + .await + .expect("租户 B 列表查询应成功"); + assert_eq!(result_b.total, 0, "租户 B 不应看到租户 A 的预约"); + assert!(result_b.data.is_empty()); + + // 租户 B 通过 ID 查询租户 A 的预约应返回错误 + let lookup_result = + appointment_service::get_appointment(&state, tenant_b, appointment_a.id).await; + assert!( + lookup_result.is_err(), + "跨租户查询预约应返回错误" + ); +} diff --git a/wiki/frontend.md b/wiki/frontend.md index 010e435..f5c666f 100644 --- a/wiki/frontend.md +++ b/wiki/frontend.md @@ -35,6 +35,8 @@ React 19.2.4 / Ant Design 6.3.5 / React Router 7.14.0 / Zustand 5.0.12 / Vite 8. | `apps/web/src/api/` | 21 个 API 服务文件 | | `apps/web/vite.config.ts` | Vite 配置 + API 代理 | +> 微信小程序(患者端)是独立前端项目,详见 [[miniprogram]] + ### 路由结构 **公开**: `/login` @@ -100,4 +102,5 @@ ws://localhost:5174/ws/* → ws://localhost:3000/* (WebSocket) | 日期 | 变更 | |------|------| +| 2026-04-24 | 添加小程序交叉引用 | | 2026-04-23 | 重构为 5 节结构,更新为当前完整前端状态 | diff --git a/wiki/index.md b/wiki/index.md index 2bb946d..926b511 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -10,6 +10,7 @@ | 数据库表 | 30+ 基础表 + 16 健康业务表(规划中) | | 核心模块 | 5 基础 (auth/config/workflow/message/plugin) + 1 业务 (health) | | 健康模块页面 | 13 个(规划中) | +| 微信小程序 | Taro 4.2 + React 18 + TypeScript | | API 文档 | `http://localhost:3000/api/docs/openapi.json` | ## 症状导航 @@ -24,6 +25,9 @@ | 端口被占用 | [[infrastructure]] dev.ps1 | 端口 5174-5189 进程 | 残留 Vite 进程 | | 预约超额 | [[erp-health]] 排班并发 | appointment CAS 操作 | 并发控制未走原子 CAS | | 跨租户数据泄漏 | [[architecture]] 多租户策略 | [[database]] tenant_id | 查询缺少 tenant_id 过滤 | +| 小程序页面空白 | [[miniprogram]] defineConstants | `process.env` 未替换 | 编译时未注入环境变量 | +| 小程序登录失败 `btoa is not defined` | [[miniprogram]] secure-storage | Web API 不可用 | 使用 `Taro.arrayBufferToBase64` 替代 | +| 微信登录 500 | [[database]] wechat_users 表结构 | Entity 字段与表不匹配 | 补 `created_by/updated_by/version` 列 | ## 模块导航 @@ -44,6 +48,9 @@ ### 组装层 - [[erp-server]] — Axum 入口 · AppState · 模块注册 · 后台任务 · 优雅关闭 +### 患者端 +- [[miniprogram]] — **微信小程序** · Taro 4.2 · 微信登录 · 手机绑定 · 健康数据查看 + ### 基础设施 - [[infrastructure]] — 连接信息 · 环境变量 · 一键启动 (**单一真相源**) - [[database]] — SeaORM 迁移 · 多租户表结构 diff --git a/wiki/testing.md b/wiki/testing.md index bedb6ed..7e67187 100644 --- a/wiki/testing.md +++ b/wiki/testing.md @@ -58,6 +58,16 @@ curl -s http://localhost:3000/api/v1/auth/login \ Accessibility / SEO / Best Practices 均 100,LCP 840ms,CLS 0.02 +### 微信小程序验证 + +```bash +cd apps/miniprogram && pnpm build:weapp # 构建 +# 在微信开发者工具中打开 apps/miniprogram 项目 +# 点击"编译" → 勾选协议 → 点击"微信一键登录" +``` + +验证点:登录成功 → 首页加载 → 各 Tab 页面可访问 + ## 3. 代码逻辑 ### 集成契约 @@ -104,5 +114,6 @@ SELECT code, name FROM permissions WHERE deleted_at IS NULL ORDER BY code; -- | 日期 | 变更 | |------|------| +| 2026-04-24 | 添加微信小程序验证步骤 | | 2026-04-23 | 重构为 5 节结构,去除与 infrastructure.md 重复 | | 2026-04-18 | Lighthouse 审计 + 性能优化 |