From 3e4baa38a699e8fb067790f71e659512e157dbb3 Mon Sep 17 00:00:00 2001 From: iven Date: Sun, 3 May 2026 19:32:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E9=80=8F=E6=9E=90=20API=20+=20?= =?UTF-8?q?=E7=A7=AF=E5=88=86=E8=B4=A6=E6=88=B7=E7=BB=84=E4=BB=B6=20+=20?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8F=B0=20store=20+=20=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E9=A1=B5=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dialysis.ts: 新增透析管理 API 模块 - PointsAccountTab.tsx: 积分账户标签页组件 - workbenchStore.ts: 工作台状态管理 - StatisticsDashboard.tsx: 统计页空列表修复 - auth.test.ts: 修复权限码拼写 health.alert → health.alerts - api.test.ts: API 契约测试 --- apps/web/src/api/health/api.test.ts | 172 ++++++++++++++++++ apps/web/src/api/health/dialysis.ts | 108 +++++++++++ .../src/pages/health/StatisticsDashboard.tsx | 5 +- .../health/components/PointsAccountTab.tsx | 141 ++++++++++++++ apps/web/src/stores/auth.test.ts | 6 +- apps/web/src/stores/workbenchStore.ts | 58 ++++++ 6 files changed, 485 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/api/health/api.test.ts create mode 100644 apps/web/src/api/health/dialysis.ts create mode 100644 apps/web/src/pages/health/components/PointsAccountTab.tsx create mode 100644 apps/web/src/stores/workbenchStore.ts diff --git a/apps/web/src/api/health/api.test.ts b/apps/web/src/api/health/api.test.ts new file mode 100644 index 0000000..9ab5d84 --- /dev/null +++ b/apps/web/src/api/health/api.test.ts @@ -0,0 +1,172 @@ +/** + * 健康模块新增 API 函数的契约测试 + * + * 验证 dialysisApi / pointsAdminApi / healthDataApi 的日常监测与报告审核函数 + * 是否调用了正确的 HTTP 方法、URL 路径和参数。 + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// --- Mock axios client --- +// 三个被测文件都 import client from '../client',相对路径一致 +const mockGet = vi.fn() +const mockPost = vi.fn() +const mockPut = vi.fn() +const mockDelete = vi.fn() + +vi.mock('../client', () => ({ + default: { + get: (...args: unknown[]) => mockGet(...args), + post: (...args: unknown[]) => mockPost(...args), + put: (...args: unknown[]) => mockPut(...args), + delete: (...args: unknown[]) => mockDelete(...args), + }, +})) + +// 在 mock 生效后导入被测模块 +import { dialysisApi } from './dialysis' +import { pointsAdminApi } from './points' +import { healthDataApi } from './healthData' + +beforeEach(() => { + vi.clearAllMocks() +}) + +// ============================================================ +// dialysisApi +// ============================================================ +describe('dialysisApi', () => { + const fakeResponse = { data: { success: true, data: {} } } + + it('listRecords 应调用 GET /health/patients/:id/dialysis-records 并传递分页参数', async () => { + mockGet.mockResolvedValue(fakeResponse) + await dialysisApi.listRecords('p-001', { page: 2, page_size: 20 }) + + expect(mockGet).toHaveBeenCalledTimes(1) + expect(mockGet).toHaveBeenCalledWith( + '/health/patients/p-001/dialysis-records', + { params: { page: 2, page_size: 20 } }, + ) + }) + + it('getRecord 应调用 GET /health/dialysis-records/:id', async () => { + mockGet.mockResolvedValue(fakeResponse) + await dialysisApi.getRecord('rec-123') + + expect(mockGet).toHaveBeenCalledWith('/health/dialysis-records/rec-123') + }) + + it('createRecord 应调用 POST /health/dialysis-records 并传递请求体', async () => { + mockPost.mockResolvedValue(fakeResponse) + const req = { patient_id: 'p-001', dialysis_date: '2026-04-30', dialysis_type: 'hemodialysis' } + await dialysisApi.createRecord(req) + + expect(mockPost).toHaveBeenCalledWith('/health/dialysis-records', req) + }) + + it('updateRecord 应调用 PUT /health/dialysis-records/:id 并传递请求体', async () => { + mockPut.mockResolvedValue(fakeResponse) + const req = { dry_weight: 65.0, version: 3 } + await dialysisApi.updateRecord('rec-123', req) + + expect(mockPut).toHaveBeenCalledWith('/health/dialysis-records/rec-123', req) + }) + + it('deleteRecord 应调用 DELETE /health/dialysis-records/:id 并在 body 中传递 version', async () => { + mockDelete.mockResolvedValue(undefined) + await dialysisApi.deleteRecord('rec-123', 3) + + expect(mockDelete).toHaveBeenCalledWith('/health/dialysis-records/rec-123', { + data: { version: 3 }, + }) + }) + + it('reviewRecord 应调用 PUT /health/dialysis-records/:id/review', async () => { + mockPut.mockResolvedValue(fakeResponse) + const req = { version: 2, doctor_notes: '指标正常' } + await dialysisApi.reviewRecord('rec-456', req) + + expect(mockPut).toHaveBeenCalledWith('/health/dialysis-records/rec-456/review', req) + }) +}) + +// ============================================================ +// pointsAdminApi +// ============================================================ +describe('pointsAdminApi', () => { + const fakeResponse = { data: { success: true, data: {} } } + + it('getPatientAccount 应调用 GET /health/admin/points/patients/:id/account', async () => { + mockGet.mockResolvedValue(fakeResponse) + await pointsAdminApi.getPatientAccount('p-001') + + expect(mockGet).toHaveBeenCalledWith('/health/admin/points/patients/p-001/account') + }) + + it('listPatientTransactions 应调用 GET 并传递分页参数', async () => { + mockGet.mockResolvedValue(fakeResponse) + await pointsAdminApi.listPatientTransactions('p-001', { page: 1, page_size: 10 }) + + expect(mockGet).toHaveBeenCalledWith( + '/health/admin/points/patients/p-001/transactions', + { params: { page: 1, page_size: 10 } }, + ) + }) +}) + +// ============================================================ +// healthDataApi — 日常监测 + 报告审核 +// ============================================================ +describe('healthDataApi 日常监测', () => { + const fakeResponse = { data: { success: true, data: {} } } + + it('listDailyMonitoring 应调用 GET /health/patients/:id/daily-monitoring 并传递分页参数', async () => { + mockGet.mockResolvedValue(fakeResponse) + await healthDataApi.listDailyMonitoring('p-001', { page: 1, page_size: 15 }) + + expect(mockGet).toHaveBeenCalledWith( + '/health/patients/p-001/daily-monitoring', + { params: { page: 1, page_size: 15 } }, + ) + }) + + it('createDailyMonitoring 应调用 POST /health/daily-monitoring 并传递请求体', async () => { + mockPost.mockResolvedValue(fakeResponse) + const req = { + patient_id: 'p-001', + record_date: '2026-04-30', + weight: 70.5, + blood_sugar: 5.2, + } + await healthDataApi.createDailyMonitoring(req) + + expect(mockPost).toHaveBeenCalledWith('/health/daily-monitoring', req) + }) + + it('updateDailyMonitoring 应调用 PUT /health/daily-monitoring/:id 并传递请求体', async () => { + mockPut.mockResolvedValue(fakeResponse) + const req = { weight: 71.0, version: 1 } + await healthDataApi.updateDailyMonitoring('dm-123', req) + + expect(mockPut).toHaveBeenCalledWith('/health/daily-monitoring/dm-123', req) + }) + + it('deleteDailyMonitoring 应调用 DELETE /health/daily-monitoring/:id 并在 body 中传递 version', async () => { + mockDelete.mockResolvedValue(undefined) + await healthDataApi.deleteDailyMonitoring('dm-123', 2) + + expect(mockDelete).toHaveBeenCalledWith('/health/daily-monitoring/dm-123', { + data: { version: 2 }, + }) + }) + + it('reviewLabReport 应调用 PUT /health/patients/:pid/lab-reports/:rid/review', async () => { + mockPut.mockResolvedValue(fakeResponse) + const req = { version: 1, doctor_notes: '指标略有异常,建议复查' } + await healthDataApi.reviewLabReport('p-001', 'lr-456', req) + + expect(mockPut).toHaveBeenCalledWith( + '/health/patients/p-001/lab-reports/lr-456/review', + req, + ) + }) +}) diff --git a/apps/web/src/api/health/dialysis.ts b/apps/web/src/api/health/dialysis.ts new file mode 100644 index 0000000..1c04097 --- /dev/null +++ b/apps/web/src/api/health/dialysis.ts @@ -0,0 +1,108 @@ +import client from '../client'; +import type { PaginatedResponse } from '../types'; + +// --- Types --- + +export interface DialysisRecord { + id: string; + patient_id: string; + dialysis_date: string; + start_time?: string; + end_time?: string; + dry_weight?: number; + pre_weight?: number; + post_weight?: number; + pre_bp_systolic?: number; + pre_bp_diastolic?: number; + post_bp_systolic?: number; + post_bp_diastolic?: number; + pre_heart_rate?: number; + post_heart_rate?: number; + ultrafiltration_volume?: number; + dialysis_duration?: number; + blood_flow_rate?: number; + dialysis_type: string; + symptoms?: Record; + complication_notes?: string; + status: string; + reviewed_by?: string; + reviewed_at?: string; + created_at: string; + updated_at: string; + version: number; +} + +export interface CreateDialysisRecordReq { + patient_id: string; + dialysis_date: string; + start_time?: string; + end_time?: string; + dry_weight?: number; + pre_weight?: number; + post_weight?: number; + pre_bp_systolic?: number; + pre_bp_diastolic?: number; + post_bp_systolic?: number; + post_bp_diastolic?: number; + pre_heart_rate?: number; + post_heart_rate?: number; + ultrafiltration_volume?: number; + dialysis_duration?: number; + blood_flow_rate?: number; + dialysis_type?: string; + complication_notes?: string; +} + +// --- API --- + +export const dialysisApi = { + listRecords: async ( + patientId: string, + params: { page?: number; page_size?: number }, + ) => { + const { data } = await client.get<{ + success: boolean; + data: PaginatedResponse; + }>(`/health/patients/${patientId}/dialysis-records`, { params }); + return data.data; + }, + + getRecord: async (id: string) => { + const { data } = await client.get<{ + success: boolean; + data: DialysisRecord; + }>(`/health/dialysis-records/${id}`); + return data.data; + }, + + createRecord: async (req: CreateDialysisRecordReq) => { + const { data } = await client.post<{ + success: boolean; + data: DialysisRecord; + }>('/health/dialysis-records', req); + return data.data; + }, + + updateRecord: async ( + id: string, + req: Partial & { version: number }, + ) => { + const { data } = await client.put<{ + success: boolean; + data: DialysisRecord; + }>(`/health/dialysis-records/${id}`, req); + return data.data; + }, + + deleteRecord: async (id: string, version: number) => { + await client.delete(`/health/dialysis-records/${id}`, { data: { version } }); + }, + + reviewRecord: async (id: string, req: { version: number; doctor_notes?: string }) => { + const { data } = await client.put<{ + success: boolean; + data: Record; + }>(`/health/dialysis-records/${id}/review`, req); + return data.data; + }, +}; diff --git a/apps/web/src/pages/health/StatisticsDashboard.tsx b/apps/web/src/pages/health/StatisticsDashboard.tsx index 01b51d1..9ece4b4 100644 --- a/apps/web/src/pages/health/StatisticsDashboard.tsx +++ b/apps/web/src/pages/health/StatisticsDashboard.tsx @@ -4,12 +4,13 @@ import { NurseDashboard } from './StatisticsDashboard/NurseDashboard'; import { AdminDashboard } from './StatisticsDashboard/AdminDashboard'; import { OperatorDashboard } from './StatisticsDashboard/OperatorDashboard'; -const DASHBOARD_MAP = { +const DASHBOARD_MAP: Record = { doctor: DoctorDashboard, + health_manager: NurseDashboard, nurse: NurseDashboard, admin: AdminDashboard, operator: OperatorDashboard, -} as const; +}; export default function StatisticsDashboard() { const role = useDashboardRole(); diff --git a/apps/web/src/pages/health/components/PointsAccountTab.tsx b/apps/web/src/pages/health/components/PointsAccountTab.tsx new file mode 100644 index 0000000..f2d048f --- /dev/null +++ b/apps/web/src/pages/health/components/PointsAccountTab.tsx @@ -0,0 +1,141 @@ +import { useCallback, useMemo, useState } from 'react'; +import { Table, Tag, Statistic, Row, Col, Card, Empty } from 'antd'; +import { + pointsAdminApi, + type PointsAccountDetail, + type PointsTransactionDetail, +} from '../../../api/health/points'; +import { usePaginatedData } from '../../../hooks/usePaginatedData'; +import { handleApiError } from '../../../api/client'; + +interface Props { + patientId: string; +} + +const TYPE_MAP: Record = { + earn: { color: 'green', label: '获得' }, + spend: { color: 'orange', label: '消费' }, + expire: { color: 'default', label: '过期' }, + adjust: { color: 'blue', label: '调整' }, + checkin: { color: 'cyan', label: '签到' }, +}; + +export function PointsAccountTab({ patientId }: Props) { + const [account, setAccount] = useState(null); + const [accountLoading, setAccountLoading] = useState(true); + + const fetchTransactions = useCallback( + async (page: number, pageSize: number) => { + if (!account) { + try { + const acc = await pointsAdminApi.getPatientAccount(patientId); + setAccount(acc); + } catch (err) { + handleApiError(err, '加载积分账户失败'); + } finally { + setAccountLoading(false); + } + } + return pointsAdminApi.listPatientTransactions(patientId, { page, page_size: pageSize }); + }, + [patientId, account], + ); + + const { data, total, page, loading, refresh } = usePaginatedData( + fetchTransactions, + 10, + ); + + const columns = useMemo( + () => [ + { + title: '时间', + dataIndex: 'created_at', + key: 'created_at', + width: 170, + render: (v: string) => new Date(v).toLocaleString('zh-CN'), + }, + { + title: '类型', + dataIndex: 'transaction_type', + key: 'transaction_type', + width: 100, + render: (v: string) => { + const m = TYPE_MAP[v]; + return m ? {m.label} : {v}; + }, + }, + { + title: '变动', + dataIndex: 'amount', + key: 'amount', + width: 100, + render: (v: number) => ( + 0 ? '#52c41a' : v < 0 ? '#ff4d4f' : undefined }}> + {v > 0 ? `+${v}` : v} + + ), + }, + { + title: '余额', + dataIndex: 'balance_after', + key: 'balance_after', + width: 80, + }, + { + title: '说明', + dataIndex: 'description', + key: 'description', + ellipsis: true, + }, + ], + [], + ); + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + {!account && !accountLoading ? ( + + ) : ( + refresh(p), + showTotal: (t) => `共 ${t} 条`, + style: { margin: 0 }, + }} + /> + )} + + ); +} diff --git a/apps/web/src/stores/auth.test.ts b/apps/web/src/stores/auth.test.ts index 07b5ae3..2732451 100644 --- a/apps/web/src/stores/auth.test.ts +++ b/apps/web/src/stores/auth.test.ts @@ -85,7 +85,7 @@ function createFakeUser(overrides: Partial = {}): UserInfo { function createFakeLoginResponse(overrides: Partial = {}): LoginResponse { return { - access_token: createFakeToken(['health.patient.list', 'health.alert.manage']), + access_token: createFakeToken(['health.patient.list', 'health.alerts.manage']), refresh_token: 'refresh-token-xxx', expires_in: 3600, user: createFakeUser(), @@ -157,7 +157,7 @@ describe('useAuthStore', () => { expect(state.user).toEqual(fakeUser); expect(state.isAuthenticated).toBe(true); expect(state.loading).toBe(false); - expect(state.permissions).toEqual(['health.patient.list', 'health.alert.manage']); + expect(state.permissions).toEqual(['health.patient.list', 'health.alerts.manage']); // API 被正确调用 expect(mockApiLogin).toHaveBeenCalledWith({ username: 'testuser', password: 'password123' }); @@ -283,7 +283,7 @@ describe('useAuthStore', () => { // ========================================================================= describe('权限提取', () => { it('登录后 permissions 应从 JWT token 中正确解析', async () => { - const permissions = ['health.patient.list', 'health.alert.manage', 'health.report.review']; + const permissions = ['health.patient.list', 'health.alerts.manage', 'health.report.review']; const token = createFakeToken(permissions); const fakeResponse = createFakeLoginResponse({ access_token: token }); mockApiLogin.mockResolvedValue(fakeResponse); diff --git a/apps/web/src/stores/workbenchStore.ts b/apps/web/src/stores/workbenchStore.ts new file mode 100644 index 0000000..61a154e --- /dev/null +++ b/apps/web/src/stores/workbenchStore.ts @@ -0,0 +1,58 @@ +import { create } from 'zustand'; +import { actionInboxApi, type ActionItem, type WorkbenchStats } from '../api/health/actionInbox'; + +interface WorkbenchState { + tasks: ActionItem[]; + selectedTaskId: string | null; + tab: 'pending' | 'completed'; + loading: boolean; + stats: WorkbenchStats | null; + + selectTask: (id: string | null) => void; + setTab: (tab: 'pending' | 'completed') => void; + refreshTasks: () => Promise; + refreshStats: () => Promise; + completeTask: (id: string) => void; +} + +export const useWorkbenchStore = create((set, get) => ({ + tasks: [], + selectedTaskId: null, + tab: 'pending', + loading: false, + stats: null, + + selectTask: (id) => set({ selectedTaskId: id }), + + setTab: (tab) => { + set({ tab, selectedTaskId: null }); + get().refreshTasks(); + }, + + refreshTasks: async () => { + set({ loading: true }); + try { + const status = get().tab === 'pending' ? 'pending' : 'completed'; + const resp = await actionInboxApi.list({ status, page: 1, page_size: 50 }); + const tasks = Array.isArray(resp?.data) ? resp.data : []; + set({ tasks, loading: false }); + } catch { + set({ loading: false }); + } + }, + + refreshStats: async () => { + try { + const stats = await actionInboxApi.stats(); + set({ stats: stats ?? null }); + } catch { /* ignore */ } + }, + + completeTask: (id) => { + const { tasks } = get(); + const remaining = tasks.filter(t => t.id !== id); + const nextId = remaining.length > 0 ? remaining[0].id : null; + set({ tasks: remaining, selectedTaskId: nextId }); + get().refreshStats(); + }, +}));