diff --git a/docs/superpowers/plans/2026-05-21-miniprogram-phase1-plan.md b/docs/superpowers/plans/2026-05-21-miniprogram-phase1-plan.md new file mode 100644 index 0000000..58f7664 --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-miniprogram-phase1-plan.md @@ -0,0 +1,1578 @@ +# 小程序 Phase 1 实施计划:测试覆盖 + UX 合规 + +> 日期: 2026-05-21 | 分支: feat/media-library-banner | 预计工时: 13d | 负责人: Dev Agent + +## 目录 + +- [1. 文件结构总览](#1-文件结构总览) +- [2. Task T1-1: secure-storage 单元测试](#2-task-t1-1-secure-storage-单元测试) +- [3. Task T1-2: request.ts 核心路径测试](#3-task-t1-2-requestts-核心路径测试) +- [4. Task T1-3: auth store 测试](#4-task-t1-3-auth-store-测试) +- [5. Task T1-4: DataSyncScheduler + BLEManager 测试](#5-task-t1-4-datasyncscheduler--blemanager-测试) +- [6. Task U1-1: 核心 ARIA 角色标注](#6-task-u1-1-核心-aria-角色标注) +- [7. Task U1-2: 表单可访问性增强](#7-task-u1-2-表单可访问性增强) +- [8. Task U1-3: 动态内容 aria-live](#8-task-u1-3-动态内容-aria-live) +- [9. Task U1-4: 焦点管理基础](#9-task-u1-4-焦点管理基础) +- [10. Task S1-1: handler 层 consent 状态检查](#10-task-s1-1-handler-层-consent-状态检查) +- [11. 验收标准](#11-验收标准) + +--- + +## 1. 文件结构总览 + +### 新建文件 + +| 文件路径 | 类型 | Task | +|----------|------|------| +| `apps/miniprogram/__tests__/utils/secure-storage.test.ts` | 测试 | T1-1 | +| `apps/miniprogram/__tests__/stores/auth.test.ts` | 测试 | T1-3 | +| `apps/miniprogram/src/styles/_focus-ring.scss` | 样式 | U1-4 | +| `docs/superpowers/plans/2026-05-21-miniprogram-phase1-plan.md` | 文档 | -- | + +### 修改文件 + +| 文件路径 | 改动摘要 | Task | +|----------|----------|------| +| `apps/miniprogram/__tests__/services/request.test.ts` | 扩展:ConcurrencyLimiter / 401 重试 / ResponseCache / AbortSignal | T1-2 | +| `apps/miniprogram/__tests__/services/ble/DataSyncScheduler.test.ts` | 扩展:并发互斥 / startPeriodicCheck 完整覆盖 | T1-4 | +| `apps/miniprogram/src/components/SegmentTabs/index.tsx` | 添加 `role="tablist"` / `role="tab"` / `aria-selected` | U1-1 | +| `apps/miniprogram/src/components/ui/DoctorTabBar/index.tsx` | 添加 `role="tablist"` / `role="tab"` / `aria-selected` | U1-1 | +| `apps/miniprogram/src/components/ui/PrimaryButton/index.tsx` | 添加 `role="button"` / `aria-disabled` / `aria-busy` | U1-1 | +| `apps/miniprogram/src/components/ui/SecondaryButton/index.tsx` | 添加 `role="button"` / `aria-disabled` | U1-1 | +| `apps/miniprogram/src/components/Loading/index.tsx` | 添加 `role="status"` / `aria-live="polite"` | U1-1 | +| `apps/miniprogram/src/components/ui/LoadingCard/index.tsx` | 添加 `role="status"` / `aria-label` | U1-1 | +| `apps/miniprogram/src/components/EmptyState/index.tsx` | 添加 `role="status"` / `aria-live="polite"` | U1-3 | +| `apps/miniprogram/src/components/ErrorState/index.tsx` | 添加 `role="alert"` / `aria-live="assertive"` | U1-3 | +| `apps/miniprogram/src/components/TrendChart/index.tsx` | tooltip 添加 `role="tooltip"` / `aria-live="polite"` | U1-3 | +| `apps/miniprogram/src/components/ui/FormInput/index.tsx` | 添加 `aria-label` / `aria-describedby` / `aria-invalid` | U1-2 | +| `apps/miniprogram/src/components/ui/FormInput/index.scss` | 添加 `:focus-within` 焦点环样式 | U1-2+U1-4 | +| `apps/miniprogram/src/components/ui/PrimaryButton/index.scss` | 添加 focus 焦点环样式 | U1-4 | +| `apps/miniprogram/src/components/ui/SecondaryButton/index.scss` | 添加 focus 焦点环样式 | U1-4 | +| `apps/miniprogram/src/components/SegmentTabs/index.scss` | 添加 `:focus` 焦点环样式 | U1-4 | +| `apps/miniprogram/src/components/ui/DoctorTabBar/index.scss` | 添加 `:focus` 焦点环样式 | U1-4 | +| `apps/miniprogram/src/styles/variables.scss` | 新增 `$focus-ring` 变量 | U1-4 | + +--- + +## 2. Task T1-1: secure-storage 单元测试 + +> 工时: 2d | 文件: `apps/miniprogram/__tests__/utils/secure-storage.test.ts` + +### 前置知识 + +源文件 `apps/miniprogram/src/utils/secure-storage.ts` 当前使用 XOR 编码(Phase 0 将替换为 AES-256-GCM)。本测试需同时覆盖: +- Phase 0 完成后的 AES-GCM 加解密对称性 +- 当前 XOR 路径的兼容性(明文 fallback / 迁移逻辑) + +测试应在 Phase 0 S0-1 完成后编写,以 AES-GCM 为主要路径。若 Phase 0 未完成,先测 XOR 路径,Phase 0 完成后补充 AES 测试用例。 + +### RED: 编写失败测试 + +**文件:** `apps/miniprogram/__tests__/utils/secure-storage.test.ts` + +```typescript +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock Taro Storage API +const storage = new Map(); +vi.mock('@tarojs/taro', () => ({ + default: { + setStorageSync: vi.fn((key: string, value: string) => { storage.set(key, value); }), + getStorageSync: vi.fn((key: string) => storage.get(key) || ''), + removeStorageSync: vi.fn((key: string) => { storage.delete(key); }), + arrayBufferToBase64: vi.fn((buf: ArrayBuffer) => { + const bytes = new Uint8Array(buf); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); + }), + base64ToArrayBuffer: vi.fn((b64: string) => { + const binary = atob(b64); + const buf = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + buf[i] = binary.charCodeAt(i); + } + return buf.buffer; + }), + }, +})); + +import { secureSet, secureGet, secureRemove, migrateLegacyStorage } from '@/utils/secure-storage'; + +describe('secure-storage', () => { + beforeEach(() => { + storage.clear(); + vi.clearAllMocks(); + }); + + describe('secureSet + secureGet 对称性', () => { + it('应正确加解密普通英文字符串', () => { + secureSet('token', 'hello-world-123'); + expect(secureGet('token')).toBe('hello-world-123'); + }); + + it('应正确加解密中文', () => { + secureSet('name', '张三'); + expect(secureGet('name')).toBe('张三'); + }); + + it('应正确加解密 emoji', () => { + secureSet('mood', '🏥💊❤️'); + expect(secureGet('mood')).toBe('🏥💊❤️'); + }); + + it('应正确加解密空格和特殊字符', () => { + secureSet('data', ' a+b=c&d?e '); + expect(secureGet('data')).toBe(' a+b=c&d?e '); + }); + + it('应正确加解密 JSON 字符串', () => { + const json = JSON.stringify({ id: 'abc-123', roles: ['doctor', 'admin'] }); + secureSet('user_data', json); + expect(secureGet('user_data')).toBe(json); + }); + + it('应正确处理超长字符串(5000 字符)', () => { + const long = 'A'.repeat(5000); + secureSet('long', long); + expect(secureGet('long')).toBe(long); + }); + }); + + describe('空 value 触发 remove', () => { + it('secureSet 空字符串应删除 key', () => { + secureSet('token', 'some-value'); + secureSet('token', ''); + expect(secureGet('token')).toBe(''); + }); + + it('secureSet 空字符串不应在 storage 中留下数据', () => { + secureSet('token', 'value'); + secureSet('token', ''); + // _es_ 前缀的 key 应被 removeStorageSync 移除 + const { default: Taro } = require('@tarojs/taro'); + expect(Taro.removeStorageSync).toHaveBeenCalledWith('_es_token'); + }); + }); + + describe('明文 fallback 读取兼容性', () => { + it('应能读取无前缀的明文值', () => { + // 模拟 MCP 注入的明文 token + const { default: Taro } = require('@tarojs/taro'); + vi.mocked(Taro.getStorageSync).mockImplementation((key: string) => { + if (key === '_es_access_token') return ''; // 无加密存储 + if (key === 'access_token') return 'plain-text-token'; // 明文 + return ''; + }); + expect(secureGet('access_token')).toBe('plain-text-token'); + }); + + it('加密值优先于明文值', () => { + secureSet('token', 'encrypted-value'); + const { default: Taro } = require('@tarojs/taro'); + vi.mocked(Taro.getStorageSync).mockImplementation((key: string) => { + if (key === '_es_token') return storage.get('_es_token') || ''; + if (key === 'token') return 'plain-value'; + return ''; + }); + expect(secureGet('token')).toBe('encrypted-value'); + }); + }); + + describe('migrateLegacyStorage 迁移逻辑', () => { + it('应将明文数据迁移到加密存储', () => { + const { default: Taro } = require('@tarojs/taro'); + // 模拟明文遗留数据 + const legacyData: Record = { + 'access_token': 'legacy-token', + 'refresh_token': 'legacy-refresh', + 'user_data': '{"id":"u1"}', + }; + vi.mocked(Taro.getStorageSync).mockImplementation((key: string) => { + if (key.startsWith('_es_')) return ''; // 无加密存储 + return legacyData[key] || ''; + }); + + migrateLegacyStorage(); + + // 应调用 removeStorageSync 清理明文 key + expect(Taro.removeStorageSync).toHaveBeenCalledWith('access_token'); + expect(Taro.removeStorageSync).toHaveBeenCalledWith('refresh_token'); + expect(Taro.removeStorageSync).toHaveBeenCalledWith('user_data'); + }); + + it('已迁移的 key 不应重复迁移', () => { + const { default: Taro } = require('@tarojs/taro'); + // 已存在加密存储 + vi.mocked(Taro.getStorageSync).mockImplementation((key: string) => { + if (key === '_es_access_token') return 'already-encrypted'; + if (key === 'access_token') return 'legacy-token'; + return ''; + }); + + migrateLegacyStorage(); + + // access_token 已有加密版本,不应再 remove 明文 + expect(Taro.removeStorageSync).not.toHaveBeenCalledWith('access_token'); + }); + + it('MIGRATION_KEYS 以外的 key 不受影响', () => { + const { default: Taro } = require('@tarojs/taro'); + const legacyData = { 'unknown_key': 'value' }; + vi.mocked(Taro.getStorageSync).mockImplementation((key: string) => { + if (key.startsWith('_es_')) return ''; + return legacyData[key] || ''; + }); + + migrateLegacyStorage(); + + expect(Taro.removeStorageSync).not.toHaveBeenCalledWith('unknown_key'); + }); + }); + + describe('secureRemove', () => { + it('应移除加密存储的 key', () => { + secureSet('token', 'value'); + secureRemove('token'); + expect(secureGet('token')).toBe(''); + }); + + it('移除不存在的 key 不报错', () => { + expect(() => secureRemove('nonexistent')).not.toThrow(); + }); + }); + + describe('Base64 边界', () => { + it('空字符串输入应正确处理', () => { + secureSet('empty', ''); + expect(secureGet('empty')).toBe(''); + }); + + it('单字符输入应正确处理', () => { + secureSet('single', 'a'); + expect(secureGet('single')).toBe('a'); + }); + + it('null byte 字符串应正确处理', () => { + const withNull = 'before\0after'; + secureSet('null', withNull); + expect(secureGet('null')).toBe(withNull); + }); + }); +}); +``` + +### GREEN: 运行测试确认通过 + +```bash +cd apps/miniprogram && npx vitest run __tests__/utils/secure-storage.test.ts +``` + +预期:AES-GCM 路径(Phase 0 后)或 XOR 路径全部通过。若 Phase 0 未完成,注释掉 AES 相关用例,标记 `@skip("待 Phase 0 S0-1 完成")`。 + +### COMMIT + +```bash +git add apps/miniprogram/__tests__/utils/secure-storage.test.ts +git commit -m "test(mp): secure-storage 单元测试 — 加解密对称性/空值/明文fallback/迁移" +``` + +--- + +## 3. Task T1-2: request.ts 核心路径测试 + +> 工时: 2d | 文件: `apps/miniprogram/__tests__/services/request.test.ts`(扩展现有) + +### 前置知识 + +现有测试覆盖:`api.get`/`api.post`/`api.put`/`api.delete` 基本行为 + 错误处理(403/500/网络错误/API failure)。 + +需新增覆盖: +1. `ConcurrencyLimiter` — 并发上限 8,队列 FIFO +2. 401 重试 + `tryRefreshToken` 去重(多个并发 401 只刷新一次) +3. `ResponseCache` — 命中/淘汰/inflight 去重/patientId 隔离 +4. `safeReLaunch` — 去重(两次调用只执行一次) +5. `AbortSignal` 取消 + +### RED: 扩展失败测试 + +**文件:** `apps/miniprogram/__tests__/services/request.test.ts` + +在现有文件末尾追加以下测试套件: + +```typescript +// ... 现有测试保持不变 ... + +describe('ConcurrencyLimiter', () => { + it('应限制并发为 8', async () => { + const callOrder: number[] = []; + let resolveCount = 0; + const resolvers: Array<() => void> = []; + + vi.mocked(Taro.request).mockImplementation(() => { + const idx = callOrder.length; + callOrder.push(idx); + return new Promise((resolve) => { + resolvers.push(() => { + resolveCount++; + resolve({ statusCode: 200, data: { success: true, data: idx } } as any); + }); + }); + }); + + // 发起 10 个并发请求 + const promises = []; + for (let i = 0; i < 10; i++) { + promises.push(api.get(`/concurrent/${i}`)); + } + + // 只有 8 个应该开始(前 8 个 request 调用) + expect(resolvers.length).toBe(8); + + // 逐个释放 + resolvers.slice(0, 2).forEach(r => r()); + await Promise.all(promises.slice(0, 2)); + + // 释放后第 9、10 个应开始 + // 最终全部完成 + resolvers.forEach(r => r()); + await Promise.all(promises); + expect(resolveCount).toBe(10); + }); +}); + +describe('401 重试 + token 刷新去重', () => { + it('401 后应尝试刷新 token 并重试', async () => { + let callCount = 0; + vi.mocked(Taro.request).mockImplementation((opts: any) => { + callCount++; + // 第一次请求返回 401 + if (opts.url.includes('/health/test')) { + if (callCount === 1) { + return Promise.resolve({ statusCode: 401, data: {} } as any); + } + return Promise.resolve({ statusCode: 200, data: { success: true, data: 'ok' } } as any); + } + // refresh token 请求 + if (opts.url.includes('/auth/refresh')) { + return Promise.resolve({ + statusCode: 200, + data: { + success: true, + data: { + access_token: 'new-access', + refresh_token: 'new-refresh', + expires_in: 3600, + }, + }, + } as any); + } + return Promise.resolve({ statusCode: 200, data: { success: true, data: null } } as any); + }); + + // mock 有 access_token 和 refresh_token + vi.mocked(secureGet).mockImplementation((key: string) => { + if (key === 'access_token') return 'old-token'; + if (key === 'refresh_token') return 'valid-refresh'; + return ''; + }); + + const result = await api.get('/health/test'); + expect(result).toBe('ok'); + expect(callCount).toBeGreaterThanOrEqual(2); // 至少 1 次 401 + 1 次重试 + }); + + it('刷新失败应跳转到登录页', async () => { + vi.mocked(Taro.request).mockImplementation((opts: any) => { + if (opts.url.includes('/auth/refresh')) { + return Promise.resolve({ statusCode: 401, data: {} } as any); + } + return Promise.resolve({ statusCode: 401, data: {} } as any); + }); + + vi.mocked(secureGet).mockImplementation((key: string) => { + if (key === 'access_token') return 'old-token'; + if (key === 'refresh_token') return 'expired-refresh'; + return ''; + }); + + await expect(api.get('/health/protected')).rejects.toThrow('登录已过期'); + }); +}); + +describe('ResponseCache', () => { + it('相同 URL 应命中缓存', async () => { + vi.mocked(Taro.request).mockResolvedValue({ + statusCode: 200, + data: { success: true, data: { id: 'cached' } }, + } as any); + + const r1 = await api.get('/cache-test'); + const r2 = await api.get('/cache-test'); + + expect(Taro.request).toHaveBeenCalledTimes(1); + expect(r1).toEqual(r2); + }); + + it('不同 URL 不命中缓存', async () => { + vi.mocked(Taro.request).mockResolvedValue({ + statusCode: 200, + data: { success: true, data: {} }, + } as any); + + await api.get('/url-a'); + await api.get('/url-b'); + + expect(Taro.request).toHaveBeenCalledTimes(2); + }); + + it('并发相同 URL 应去重(inflight)', async () => { + let resolveReq: (v: any) => void; + vi.mocked(Taro.request).mockImplementation(() => { + return new Promise((resolve) => { resolveReq = resolve; }); + }); + + const p1 = api.get('/dedup'); + const p2 = api.get('/dedup'); + + // 只有一次 request 调用 + expect(Taro.request).toHaveBeenCalledTimes(1); + + resolveReq!({ statusCode: 200, data: { success: true, data: 'shared' } }); + const [r1, r2] = await Promise.all([p1, p2]); + expect(r1).toEqual(r2); + }); +}); + +describe('safeReLaunch', () => { + it('连续调用应去重', async () => { + vi.mocked(Taro.request).mockResolvedValue({ statusCode: 401, data: {} } as any); + vi.mocked(secureGet).mockImplementation((key: string) => { + if (key === 'access_token') return 'old-token'; + if (key === 'refresh_token') return 'expired'; + return ''; + }); + // 刷新失败,触发 safeReLaunch + vi.mocked(Taro.request).mockImplementation((opts: any) => { + if (opts.url.includes('/auth/refresh')) { + return Promise.resolve({ statusCode: 401, data: {} } as any); + } + return Promise.resolve({ statusCode: 401, data: {} } as any); + }); + vi.mocked(Taro.getCurrentPages).mockReturnValue([{ path: 'pages/health/index' }]); + + // 发起两个并发 401 请求 + await Promise.allSettled([ + api.get('/health/a'), + api.get('/health/b'), + ]); + + // reLaunch 应只调用一次(去重) + expect(Taro.reLaunch).toHaveBeenCalledTimes(1); + }); +}); + +describe('AbortSignal', () => { + it('请求前取消应抛出 "请求已取消"', async () => { + const controller = new AbortController(); + controller.abort(); + + await expect(api.get('/health/abort-test', undefined, undefined, controller.signal)) + .rejects.toThrow('请求已取消'); + }); +}); +``` + +### GREEN: 运行测试 + +```bash +cd apps/miniprogram && npx vitest run __tests__/services/request.test.ts +``` + +注意:部分 401 重试测试可能因 `resetForTesting()` 在 `beforeEach` 中未完全重置 token 刷新状态而失败。若失败,在 `resetForTesting()` 中补充 `isLoggingOut = false` 重置(当前已包含)。 + +### COMMIT + +```bash +git add apps/miniprogram/__tests__/services/request.test.ts +git commit -m "test(mp): request.ts 扩展测试 — ConcurrencyLimiter/401重试/ResponseCache/AbortSignal" +``` + +--- + +## 4. Task T1-3: auth store 测试 + +> 工时: 2d | 文件: `apps/miniprogram/__tests__/stores/auth.test.ts` + +### 前置知识 + +auth store (`stores/auth.ts`) 是 Zustand store,依赖: +- `@/services/auth` — API 调用(需 mock) +- `@/utils/secure-storage` — 加密存储(需 mock) +- `@/services/request` — `clearRequestCache`/`markLoggingOut`/`clearLoggingOut`/`setCachedPatientId`(需 mock) +- `@tarojs/taro` — `reLaunch`/`getStorageSync`/`removeStorageSync`/`getStorageInfoSync`(已在 setup.ts mock) + +关键测试点: +- `restore()` — 从 storage 恢复用户/角色/患者状态 +- `login(code)` — 微信登录流程(已绑定 vs 未绑定) +- `credentialLogin(username, password)` — 账号密码登录 +- `logout()` — 清理完整性(所有 secureRemove + Taro.removeStorageSync + reLaunch) +- `bindPhone(encryptedData, iv)` — 手机绑定 +- 角色判断 — `isMedicalStaff`/`isDoctor`/`isNurse`/`isHealthManager`/`hasRole` + +### RED: 编写失败测试 + +**文件:** `apps/miniprogram/__tests__/stores/auth.test.ts` + +```typescript +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock secure-storage +const secureStore = new Map(); +vi.mock('@/utils/secure-storage', () => ({ + secureGet: vi.fn((key: string) => secureStore.get(key) || ''), + secureSet: vi.fn((key: string, value: string) => { secureStore.set(key, value); }), + secureRemove: vi.fn((key: string) => { secureStore.delete(key); }), +})); + +// Mock auth API +vi.mock('@/services/auth', () => ({ + wechatLogin: vi.fn(), + credentialLogin: vi.fn(), + wechatBindPhone: vi.fn(), + getPatients: vi.fn(), +})); + +// Mock request module +vi.mock('@/services/request', () => ({ + clearRequestCache: vi.fn(), + markLoggingOut: vi.fn(), + clearLoggingOut: vi.fn(), + setCachedPatientId: vi.fn(), + getCachedPatientId: vi.fn(() => ''), +})); + +// Mock stores/index resetAllStores +vi.mock('@/stores', () => ({ + resetAllStores: vi.fn(), +})); + +import { useAuthStore } from '@/stores/auth'; +import * as authApi from '@/services/auth'; +import { secureGet, secureSet, secureRemove } from '@/utils/secure-storage'; +import { clearRequestCache, markLoggingOut, setCachedPatientId } from '@/services/request'; + +describe('auth store', () => { + beforeEach(() => { + secureStore.clear(); + vi.clearAllMocks(); + // 重置 store 状态 + useAuthStore.setState({ + user: null, + roles: [], + currentPatient: null, + patients: [], + loading: false, + }); + }); + + describe('restore()', () => { + it('应从 secure storage 恢复用户信息', () => { + secureStore.set('user_data', JSON.stringify({ id: 'u1', username: 'test', tenant_id: 't1' })); + secureStore.set('user_roles', JSON.stringify(['doctor'])); + secureStore.set('current_patient', JSON.stringify({ id: 'p1', name: '张三', relation: 'self' })); + + useAuthStore.getState().restore(); + + const state = useAuthStore.getState(); + expect(state.user).toEqual({ id: 'u1', username: 'test', tenant_id: 't1' }); + expect(state.roles).toEqual(['doctor']); + expect(state.currentPatient).toEqual({ id: 'p1', name: '张三', relation: 'self' }); + }); + + it('storage 为空时应保持默认值', () => { + useAuthStore.getState().restore(); + + const state = useAuthStore.getState(); + expect(state.user).toBeNull(); + expect(state.roles).toEqual([]); + expect(state.currentPatient).toBeNull(); + }); + + it('恢复患者后应同步 setCachedPatientId', () => { + secureStore.set('user_data', JSON.stringify({ id: 'u1' })); + secureStore.set('user_roles', JSON.stringify([])); + secureStore.set('current_patient', JSON.stringify({ id: 'p1', name: '张三', relation: 'self' })); + + useAuthStore.getState().restore(); + + expect(setCachedPatientId).toHaveBeenCalledWith('p1'); + }); + + it('JSON 解析失败不应崩溃', () => { + secureStore.set('user_data', '{invalid json}'); + secureStore.set('user_roles', JSON.stringify([])); + + expect(() => useAuthStore.getState().restore()).not.toThrow(); + }); + }); + + describe('login(code)', () => { + it('微信登录成功(已绑定用户)', async () => { + vi.mocked(authApi.wechatLogin).mockResolvedValue({ + bound: true, + openid: 'wx-openid', + token: { + access_token: 'at', + refresh_token: 'rt', + expires_in: 3600, + user: { id: 'u1', username: 'test', roles: [{ code: 'doctor', name: '医生' }] }, + }, + }); + vi.mocked(authApi.getPatients).mockResolvedValue([]); + + const result = await useAuthStore.getState().login('wx-code'); + + expect(result).toBe(true); + expect(secureSet).toHaveBeenCalledWith('access_token', 'at'); + expect(secureSet).toHaveBeenCalledWith('refresh_token', 'rt'); + expect(useAuthStore.getState().roles).toEqual(['doctor']); + expect(useAuthStore.getState().loading).toBe(false); + }); + + it('微信登录未绑定用户', async () => { + vi.mocked(authApi.wechatLogin).mockResolvedValue({ + bound: false, + openid: 'wx-openid-123', + }); + + const result = await useAuthStore.getState().login('wx-code'); + + expect(result).toBe(false); + expect(secureSet).toHaveBeenCalledWith('wechat_openid', 'wx-openid-123'); + }); + + it('登录 API 失败应返回 false', async () => { + vi.mocked(authApi.wechatLogin).mockRejectedValue(new Error('network')); + + const result = await useAuthStore.getState().login('wx-code'); + + expect(result).toBe(false); + expect(useAuthStore.getState().loading).toBe(false); + }); + + it('loading 时应拒绝重复登录', async () => { + useAuthStore.setState({ loading: true }); + const result = await useAuthStore.getState().login('code'); + expect(result).toBe(false); + expect(authApi.wechatLogin).not.toHaveBeenCalled(); + }); + }); + + describe('credentialLogin(username, password)', () => { + it('账号密码登录成功', async () => { + vi.mocked(authApi.credentialLogin).mockResolvedValue({ + access_token: 'at', + refresh_token: 'rt', + expires_in: 3600, + user: { id: 'u1', username: 'admin', roles: [{ code: 'admin', name: '管理员' }], tenant_id: 't1' }, + }); + vi.mocked(authApi.getPatients).mockResolvedValue([]); + + const result = await useAuthStore.getState().credentialLogin('admin', 'password'); + + expect(result).toBe(true); + expect(secureSet).toHaveBeenCalledWith('access_token', 'at'); + expect(useAuthStore.getState().user).toBeTruthy(); + }); + + it('登录失败应返回 false', async () => { + vi.mocked(authApi.credentialLogin).mockRejectedValue(new Error('invalid')); + + const result = await useAuthStore.getState().credentialLogin('admin', 'wrong'); + + expect(result).toBe(false); + }); + }); + + describe('logout()', () => { + it('应清理所有 secure storage key', () => { + useAuthStore.setState({ + user: { id: 'u1', username: 'test' }, + roles: ['doctor'], + currentPatient: { id: 'p1', name: '张三', relation: 'self' }, + }); + + useAuthStore.getState().logout(); + + // 验证所有 key 被 remove + const removedKeys = vi.mocked(secureRemove).mock.calls.map(c => c[0]); + expect(removedKeys).toContain('access_token'); + expect(removedKeys).toContain('refresh_token'); + expect(removedKeys).toContain('token_expires_at'); + expect(removedKeys).toContain('user_data'); + expect(removedKeys).toContain('user_roles'); + expect(removedKeys).toContain('tenant_id'); + expect(removedKeys).toContain('wechat_openid'); + expect(removedKeys).toContain('current_patient'); + expect(removedKeys).toContain('current_patient_id'); + }); + + it('应重置 store 状态', () => { + useAuthStore.setState({ user: { id: 'u1', username: 'test' }, roles: ['doctor'] }); + + useAuthStore.getState().logout(); + + const state = useAuthStore.getState(); + expect(state.user).toBeNull(); + expect(state.roles).toEqual([]); + expect(state.currentPatient).toBeNull(); + expect(state.patients).toEqual([]); + }); + + it('应调用 markLoggingOut', () => { + useAuthStore.getState().logout(); + expect(markLoggingOut).toHaveBeenCalled(); + }); + + it('应跳转到首页', () => { + const Taro = require('@tarojs/taro').default; + useAuthStore.getState().logout(); + expect(Taro.reLaunch).toHaveBeenCalledWith({ url: '/pages/index/index' }); + }); + }); + + describe('bindPhone(encryptedData, iv)', () => { + it('绑定成功应存储 token', async () => { + secureStore.set('wechat_openid', 'wx-openid'); + vi.mocked(authApi.wechatBindPhone).mockResolvedValue({ + access_token: 'new-at', + refresh_token: 'new-rt', + expires_in: 3600, + user: { id: 'u1', username: 'bound-user', roles: [] }, + }); + vi.mocked(authApi.getPatients).mockResolvedValue([]); + + const result = await useAuthStore.getState().bindPhone('enc-data', 'iv-data'); + + expect(result).toBe(true); + expect(secureSet).toHaveBeenCalledWith('access_token', 'new-at'); + expect(secureRemove).toHaveBeenCalledWith('wechat_openid'); + }); + + it('无 openid 应抛出错误', async () => { + secureStore.delete('wechat_openid'); + + await expect( + useAuthStore.getState().bindPhone('enc-data', 'iv-data'), + ).rejects.toThrow('登录态丢失'); + }); + }); + + describe('角色判断', () => { + it.each([ + { roles: ['doctor'], fn: 'isDoctor', expected: true }, + { roles: ['nurse'], fn: 'isDoctor', expected: false }, + { roles: ['admin'], fn: 'isDoctor', expected: true }, + { roles: ['nurse'], fn: 'isNurse', expected: true }, + { roles: ['health_manager'], fn: 'isHealthManager', expected: true }, + { roles: ['doctor'], fn: 'isMedicalStaff', expected: true }, + { roles: ['patient'], fn: 'isMedicalStaff', expected: false }, + { roles: ['admin'], fn: 'isMedicalStaff', expected: true }, + ])('roles=$roles → $fn() = $expected', ({ roles, fn, expected }) => { + useAuthStore.setState({ roles }); + expect((useAuthStore.getState() as any)[fn]()).toBe(expected); + }); + + it('hasRole 应精确匹配', () => { + useAuthStore.setState({ roles: ['doctor'] }); + expect(useAuthStore.getState().hasRole('doctor')).toBe(true); + expect(useAuthStore.getState().hasRole('nurse')).toBe(false); + }); + + it('admin 角色应通过所有 hasRole 检查', () => { + useAuthStore.setState({ roles: ['admin'] }); + expect(useAuthStore.getState().hasRole('doctor')).toBe(true); + expect(useAuthStore.getState().hasRole('nurse')).toBe(true); + }); + }); +}); +``` + +### GREEN: 运行测试 + +```bash +cd apps/miniprogram && npx vitest run __tests__/stores/auth.test.ts +``` + +注意:`stores/index.ts` 中的 `resetAllStores` 需要在 mock 中正确处理。若 `auth.ts` 顶部 `import { resetAllStores } from './index'` 导致循环引用问题,在 mock 中提供 stub。 + +### COMMIT + +```bash +git add apps/miniprogram/__tests__/stores/auth.test.ts +git commit -m "test(mp): auth store 单元测试 — restore/login/logout/bindPhone/角色判断" +``` + +--- + +## 5. Task T1-4: DataSyncScheduler + BLEManager 测试 + +> 工时: 1.5d | 文件: `apps/miniprogram/__tests__/services/ble/DataSyncScheduler.test.ts`(扩展) + +### 前置知识 + +现有 DataSyncScheduler 测试已覆盖 8 个用例:needsSync/recordSync/tryAutoSync/destroy/getLastSyncAt。 + +需新增: +1. `startPeriodicCheck` 并发互斥 — 多次调用只创建一个 timer +2. `startPeriodicCheck` 实际触发同步 +3. `destroy` 后 `startPeriodicCheck` 可重新启动 + +BLEManager 测试需要 mock `Taro.openBluetoothAdapter`/`Taro.startBluetoothDevicesDiscovery`/`Taro.createBLEConnection` 等。因 BLEManager 构造函数中 `new DataBuffer().restore()` 调用了 `Taro.getStorageSync`,需确保 mock 在 import 之前。 + +### RED: 扩展失败测试 + +**文件:** `apps/miniprogram/__tests__/services/ble/DataSyncScheduler.test.ts`(追加) + +```typescript +// ... 现有测试保持不变 ... + +describe('startPeriodicCheck', () => { + it('应启动定时器并周期触发同步', () => { + vi.useFakeTimers(); + const syncFn = vi.fn().mockResolvedValue({ success: true, uploadedCount: 0 }); + + // 先同步一次以记录时间戳,否则 needsSync=true 会立即触发 + storage.set('last_ble_sync', JSON.stringify({ lastSyncAt: Date.now() })); + const scheduler = new DataSyncScheduler({ + intervalMs: 60 * 60 * 1000, + storageKey: 'last_ble_sync', + }); + + scheduler.startPeriodicCheck(syncFn, 5000); + + // 不应立即触发(needsSync=false 因为刚同步过) + expect(syncFn).not.toHaveBeenCalled(); + + // 模拟 1 小时后,needsSync=true + const oneHourAgo = Date.now() - 60 * 60 * 1000; + storage.set('last_ble_sync', JSON.stringify({ lastSyncAt: oneHourAgo })); + + // 推进定时器 + vi.advanceTimersByTime(5000); + + expect(syncFn).toHaveBeenCalledTimes(1); + + scheduler.destroy(); + vi.useRealTimers(); + }); + + it('destroy 后不应再触发', () => { + vi.useFakeTimers(); + const syncFn = vi.fn().mockResolvedValue({ success: true, uploadedCount: 0 }); + storage.clear(); // needsSync = true + + const scheduler = new DataSyncScheduler({ + intervalMs: 1000, + storageKey: 'last_ble_sync_periodic', + }); + + scheduler.startPeriodicCheck(syncFn, 1000); + scheduler.destroy(); + + vi.advanceTimersByTime(5000); + expect(syncFn).not.toHaveBeenCalled(); + + vi.useRealTimers(); + }); + + it('多次调用 startPeriodicCheck 应替换旧 timer(不叠加)', () => { + vi.useFakeTimers(); + const syncFn = vi.fn().mockResolvedValue({ success: true, uploadedCount: 0 }); + storage.clear(); + + const scheduler = new DataSyncScheduler({ + intervalMs: 1000, + storageKey: 'last_ble_sync_multi', + }); + + scheduler.startPeriodicCheck(syncFn, 1000); + scheduler.startPeriodicCheck(syncFn, 1000); // 第二次调用 + + // 只有一个 setInterval 在运行 + vi.advanceTimersByTime(1000); + // startPeriodicCheck 内部先 destroy 再 setInterval + // 但首次 syncFn 的调用取决于 needsSync + expect(syncFn.mock.calls.length).toBeLessThanOrEqual(2); + + scheduler.destroy(); + vi.useRealTimers(); + }); +}); +``` + +### BLEManager 测试(新增部分) + +**文件:** `apps/miniprogram/__tests__/services/ble/BLEManager.test.ts`(扩展现有) + +```typescript +// 在现有 BLEManager.test.ts 末尾追加: + +describe('BLEManager 生命周期', () => { + it('registerAdapter 应注册适配器', () => { + const manager = new BLEManager(); + const mockAdapter = { + supportedModels: ['TestDevice'], + serviceUUID: 'test-uuid', + parseData: vi.fn(), + deviceType: 'test' as const, + }; + + manager.registerAdapter(mockAdapter); + + // 间接验证:scanDevices 应使用注册的适配器 + // (完整测试需要 mock Taro BLE API) + }); + + it('destroy 应清理所有状态', () => { + const manager = new BLEManager(); + manager.destroy(); + + // 验证没有内存泄漏(无法直接断言,但不应抛出) + expect(true).toBe(true); + }); +}); +``` + +### GREEN: 运行测试 + +```bash +cd apps/miniprogram && npx vitest run __tests__/services/ble/ +``` + +### COMMIT + +```bash +git add apps/miniprogram/__tests__/services/ble/DataSyncScheduler.test.ts +git add apps/miniprogram/__tests__/services/ble/BLEManager.test.ts +git commit -m "test(mp): DataSyncScheduler+BLEManager 扩展测试 — 周期同步/并发互斥/生命周期" +``` + +--- + +## 6. Task U1-1: 核心 ARIA 角色标注 + +> 工时: 2d | 涉及约 15 个核心组件 + +### 改动清单 + +#### U1-1.1 SegmentTabs + +**文件:** `apps/miniprogram/src/components/SegmentTabs/index.tsx` + +```diff +- ++ + {tabs.map((tab) => ( + onChange(tab.key)} + > +``` + +#### U1-1.2 DoctorTabBar + +**文件:** `apps/miniprogram/src/components/ui/DoctorTabBar/index.tsx` + +```diff +- ++ + {DOCTOR_TABS.map((tab) => ( + ++ +``` + +#### U1-1.4 SecondaryButton + +**文件:** `apps/miniprogram/src/components/ui/SecondaryButton/index.tsx` + +```diff +- ++ +``` + +#### U1-1.5 Loading + +**文件:** `apps/miniprogram/src/components/Loading/index.tsx` + +```diff +- ++ +``` + +#### U1-1.6 LoadingCard + +**文件:** `apps/miniprogram/src/components/ui/LoadingCard/index.tsx` + +```diff +- ++ +``` + +### 验证步骤 + +```bash +# 编译检查(ARIA 属性是字符串透传,不影响编译) +cd apps/miniprogram && npx tsc --noEmit + +# 构建检查 +cd apps/miniprogram && pnpm build +``` + +在微信开发者工具中验证: +- 打开任意含 SegmentTabs 的页面(如健康页) +- 使用开发者工具 Audits 面板检查 ARIA 属性是否生效 + +### COMMIT + +```bash +git add apps/miniprogram/src/components/SegmentTabs/index.tsx +git add apps/miniprogram/src/components/ui/DoctorTabBar/index.tsx +git add apps/miniprogram/src/components/ui/PrimaryButton/index.tsx +git add apps/miniprogram/src/components/ui/SecondaryButton/index.tsx +git add apps/miniprogram/src/components/Loading/index.tsx +git add apps/miniprogram/src/components/ui/LoadingCard/index.tsx +git commit -m "feat(mp): ARIA 角色标注 — tablist/tab/button/status 核心组件" +``` + +--- + +## 7. Task U1-2: 表单可访问性增强 + +> 工时: 1d | 文件: FormInput 组件 + 体征录入页 + +### U1-2.1 FormInput 组件改造 + +**文件:** `apps/miniprogram/src/components/ui/FormInput/index.tsx` + +```diff + interface FormInputProps { + label?: string; + placeholder?: string; + value?: string; + onInput?: (value: string) => void; + type?: 'text' | 'number' | 'idcard' | 'digit'; + maxLength?: number; + disabled?: boolean; + error?: string; + className?: string; ++ ariaLabel?: string; + } + + const FormInput: React.FC = ({ + label, + placeholder, + value, + onInput, + type = 'text', + maxLength, + disabled = false, + error, + className = '', ++ ariaLabel, + }) => { ++ const errorId = error ? `form-error-${label || Math.random().toString(36).slice(2, 8)}` : undefined; + + return ( + + {label && {label}} + + onInput?.(e.detail.value)} + type={type} + maxlength={maxLength} + disabled={disabled} ++ aria-label={ariaLabel || label} ++ aria-describedby={errorId} ++ aria-invalid={!!error} + /> + +- {error && {error}} ++ {error && {error}} + + ); + }; +``` + +### U1-2.2 体征录入页 aria-valuemin/max/now + +**文件:** `apps/miniprogram/src/pages/pkg-health/input/index.tsx`(查找数值输入区域) + +在体征录入页的数值 Input 组件上添加 ARIA 属性。此改动需要找到具体的 Input 元素并添加属性: + +```diff + setValue(e.detail.value)} ++ aria-label={`${indicatorName}数值`} ++ aria-valuemin={min} ++ aria-valuemax={max} ++ aria-valuenow={value ? parseFloat(value) : undefined} + /> +``` + +具体 min/max 值需参考各体征指标的参考范围常量(如收缩压 60-250、舒张压 30-150、心率 30-250 等)。 + +### 验证 + +```bash +cd apps/miniprogram && npx tsc --noEmit +cd apps/miniprogram && pnpm build +``` + +在微信开发者工具中验证: +- 打开体征录入页,检查 Input 是否有 aria-label/aria-valuemin 等 +- 输入错误值后检查 aria-invalid 和 error 提示 + +### COMMIT + +```bash +git add apps/miniprogram/src/components/ui/FormInput/index.tsx +git add apps/miniprogram/src/pages/pkg-health/input/index.tsx +git commit -m "feat(mp): 表单可访问性 — aria-label/describedby/invalid + 体征录入aria-valuemin/max" +``` + +--- + +## 8. Task U1-3: 动态内容 aria-live + +> 工时: 0.5d | 文件: EmptyState / ErrorState / LoadingCard / TrendChart + +### U1-3.1 EmptyState + +**文件:** `apps/miniprogram/src/components/EmptyState/index.tsx` + +```diff +- ++ +``` + +### U1-3.2 ErrorState + +**文件:** `apps/miniprogram/src/components/ErrorState/index.tsx` + +```diff +- ++ +``` + +### U1-3.3 TrendChart tooltip + +**文件:** `apps/miniprogram/src/components/TrendChart/index.tsx` + +```diff + {tooltip && ( + + + {tooltip.date}: {tooltip.value}{unit ? ` ${unit}` : ''} + + + )} +``` + +### 验证 + +```bash +cd apps/miniprogram && npx tsc --noEmit +``` + +### COMMIT + +```bash +git add apps/miniprogram/src/components/EmptyState/index.tsx +git add apps/miniprogram/src/components/ErrorState/index.tsx +git add apps/miniprogram/src/components/TrendChart/index.tsx +git commit -m "feat(mp): aria-live 动态内容播报 — EmptyState/ErrorState/TrendChart tooltip" +``` + +--- + +## 9. Task U1-4: 焦点管理基础 + +> 工时: 1d | 涉及全局焦点环样式 + 组件级焦点反馈 + +### U1-4.1 全局焦点环变量 + +**文件:** `apps/miniprogram/src/styles/variables.scss` + +在文件末尾追加: + +```scss +// ─── 焦点环(可访问性)─── +$focus-ring-color: rgba(196, 98, 58, 0.5); // 赤土橙 50% 透明 +$focus-ring-width: 2px; +$focus-ring-offset: 2px; + +// 医生端焦点环 +$doc-focus-ring-color: rgba(58, 107, 140, 0.5); // 靛蓝 50% 透明 +``` + +### U1-4.2 新建焦点环 mixin 文件 + +**文件:** `apps/miniprogram/src/styles/_focus-ring.scss` + +```scss +@import './variables.scss'; + +// 焦点环基础 mixin — 可交互元素统一调用 +@mixin focus-ring($color: $focus-ring-color) { + &:focus-visible { + outline: none; + box-shadow: 0 0 0 $focus-ring-offset $color; + border-radius: inherit; + } +} + +// Taro View 的 focusin/focusout 视觉反馈 +@mixin interactive-focus($color: $focus-ring-color) { + transition: box-shadow 0.15s ease; + + &:focus { + outline: none; + box-shadow: 0 0 0 $focus-ring-offset $color; + } +} +``` + +### U1-4.3 PrimaryButton 焦点环 + +**文件:** `apps/miniprogram/src/components/ui/PrimaryButton/index.scss` + +```diff ++ @import '../../../styles/focus-ring'; + + .primary-btn { + // ... existing styles ... ++ @include focus-ring; + } +``` + +### U1-4.4 SecondaryButton 焦点环 + +**文件:** `apps/miniprogram/src/components/ui/SecondaryButton/index.scss` + +```diff ++ @import '../../../styles/focus-ring'; + + .secondary-btn { + // ... existing styles ... ++ @include focus-ring; + } +``` + +### U1-4.5 SegmentTabs 焦点环 + +**文件:** `apps/miniprogram/src/components/SegmentTabs/index.scss` + +```diff ++ @import '../../styles/focus-ring'; + + .seg-tab { + // ... existing styles ... ++ @include interactive-focus; + } +``` + +### U1-4.6 DoctorTabBar 焦点环 + +**文件:** `apps/miniprogram/src/components/ui/DoctorTabBar/index.scss` + +```diff ++ @import '../../../styles/focus-ring'; + + .doctor-tabbar__item { + // ... existing styles ... ++ @include interactive-focus($color: $doc-focus-ring-color); + } +``` + +### U1-4.7 FormInput 焦点环(增强现有 :focus 样式) + +**文件:** `apps/miniprogram/src/components/ui/FormInput/index.scss` + +```diff ++ @import '../../../styles/focus-ring'; + + .form-input { + // ... + + &__field { + // ... existing styles ... ++ transition: border-color 0.2s, box-shadow 0.15s ease; + } + +- &--focus &__field { +- border-color: var(--tk-pri); +- } ++ &--focus &__field, ++ &__field:focus-within { ++ border-color: var(--tk-pri); ++ box-shadow: 0 0 0 $focus-ring-offset $focus-ring-color; ++ } + } +``` + +### 验证 + +```bash +cd apps/miniprogram && pnpm build +``` + +在微信开发者工具中验证: +- 使用键盘 Tab 键在按钮/TabBar 间导航,观察焦点环是否可见 +- FormInput 聚焦时边框+阴影是否正确 + +### COMMIT + +```bash +git add apps/miniprogram/src/styles/variables.scss +git add apps/miniprogram/src/styles/_focus-ring.scss +git add apps/miniprogram/src/components/ui/PrimaryButton/index.scss +git add apps/miniprogram/src/components/ui/SecondaryButton/index.scss +git add apps/miniprogram/src/components/SegmentTabs/index.scss +git add apps/miniprogram/src/components/ui/DoctorTabBar/index.scss +git add apps/miniprogram/src/components/ui/FormInput/index.scss +git commit -m "feat(mp): 焦点管理基础 — 全局焦点环变量/mixin + 5 组件焦点样式" +``` + +--- + +## 10. Task S1-1: handler 层 consent 状态检查 + +> 工时: 1d | 涉及后端 handler 层 + +### 前置分析 + +当前状态: +- **后端** `consent_handler.rs` 已有 CRUD 端点(list / grant / revoke / patient-sign) +- **前端** `pages/pkg-profile/consents/index.tsx` 已有知情同意列表页 +- **缺失**:handler 层在访问患者敏感数据(vital_signs / lab_reports / health_alerts 等)时,未检查该患者是否有有效的 consent 记录 + +设计规格要求:handler 层新增 `check_consent_active` 函数,在涉及患者数据的读取端点中调用。 + +### S1-1.1 新建 consent 检查辅助模块 + +**文件:** `crates/erp-health/src/handler/consent_check.rs`(新建) + +```rust +use erp_core::error::AppError; +use erp_core::types::TenantContext; +use crate::entity::consent::Entity as ConsentEntity; +use crate::entity::consent::Column as ConsentColumn; +use sea_orm::{EntityTrait, QueryFilter, DatabaseConnection}; +use uuid::Uuid; + +/// 检查患者是否有有效的知情同意记录(status = granted) +/// 在 handler 层调用,对患者数据的读取进行 consent 门控 +pub async fn check_consent_active( + db: &DatabaseConnection, + tenant_id: Uuid, + patient_id: Uuid, + ctx: &TenantContext, +) -> Result<(), AppError> { + // admin 和医护角色不需要 consent 检查 + if ctx.roles.iter().any(|r| r == "admin" || r == "doctor" || r == "nurse" || r == "health_manager") { + return Ok(()); + } + + let has_active = ConsentEntity::find() + .filter(ConsentColumn::TenantId.eq(tenant_id)) + .filter(ConsentColumn::PatientId.eq(patient_id)) + .filter(ConsentColumn::Status.eq("granted")) + .filter(ConsentColumn::ConsentType.eq("data_processing")) + .one(db) + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + + if has_active.is_none() { + return Err(AppError::Forbidden("患者未签署知情同意书,无法访问数据".to_string())); + } + + Ok(()) +} +``` + +### S1-1.2 注册模块 + +**文件:** `crates/erp-health/src/handler/mod.rs` + +```diff + pub mod consent_handler; ++ pub mod consent_check; +``` + +### S1-1.3 在关键 handler 中添加 consent 检查 + +在以下 handler 的数据读取端点中添加 `check_consent_active` 调用: + +| handler | 端点 | patient_id 来源 | +|--------|------|----------------| +| `vital_sign_handler` | `list_vital_signs` | Query 参数 | +| `lab_report_handler` | `list_lab_reports` | Query 参数 | +| `alert_handler` | `list_alerts` | Query 参数 | +| `daily_monitoring_handler` | `get_daily_summary` | Query 参数 | +| `health_data_handler` | `list_health_data` | Path 参数 | + +**示例改动:** `crates/erp-health/src/handler/vital_sign_handler.rs` + +```diff ++ use crate::handler::consent_check::check_consent_active; + + pub async fn list_vital_signs( + State(state): State, + Extension(ctx): Extension, + Query(query): Query, + ) -> Result<...> { + require_permission(&ctx, "health.health-data.list")?; + ++ // consent 门控:患者端访问需检查知情同意 ++ if let Some(patient_id) = query.patient_id { ++ check_consent_active(&state.db, ctx.tenant_id, patient_id, &ctx).await?; ++ } + + // ... 原有逻辑 ... + } +``` + +### 验证 + +```bash +cargo check +cargo test --workspace -p erp-health +``` + +### COMMIT + +```bash +git add crates/erp-health/src/handler/consent_check.rs +git add crates/erp-health/src/handler/mod.rs +git add crates/erp-health/src/handler/vital_sign_handler.rs +git add crates/erp-health/src/handler/lab_report_handler.rs +git add crates/erp-health/src/handler/alert_handler.rs +git add crates/erp-health/src/handler/daily_monitoring_handler.rs +git commit -m "feat(health): consent 门控 — handler 层 check_consent_active 患者数据访问拦截" +``` + +--- + +## 11. 验收标准 + +### 测试覆盖 + +- [ ] 单元测试文件 >= 6 个(secure-storage / request / auth / DataSyncScheduler / BLEManager / components) +- [ ] `npx vitest run` 全部通过 +- [ ] `secure-storage` 测试覆盖: 加解密对称性 / 空 value / 明文 fallback / 迁移 / Base64 边界 +- [ ] `request.ts` 测试覆盖: ConcurrencyLimiter / 401 重试 / ResponseCache / safeReLaunch / AbortSignal +- [ ] `auth store` 测试覆盖: restore / login / credentialLogin / logout / bindPhone / 角色判断 +- [ ] `DataSyncScheduler` 测试覆盖: needsSync / recordSync / startPeriodicCheck 并发互斥 / destroy + +### UX 合规 + +- [ ] SegmentTabs 有 `role="tablist"` / `role="tab"` / `aria-selected` +- [ ] DoctorTabBar 有 `role="tablist"` / `role="tab"` / `aria-selected` +- [ ] PrimaryButton 有 `role="button"` / `aria-disabled` / `aria-busy` +- [ ] SecondaryButton 有 `role="button"` / `aria-disabled` +- [ ] Loading 有 `role="status"` / `aria-live="polite"` +- [ ] LoadingCard 有 `role="status"` / `aria-label` +- [ ] EmptyState 有 `role="status"` / `aria-live="polite"` +- [ ] ErrorState 有 `role="alert"` / `aria-live="assertive"` +- [ ] TrendChart tooltip 有 `role="tooltip"` / `aria-live="polite"` +- [ ] FormInput 有 `aria-label` / `aria-describedby` / `aria-invalid` +- [ ] 体征录入页数值输入有 `aria-valuemin` / `aria-valuemax` / `aria-valuenow` + +### 焦点管理 + +- [ ] `$focus-ring-color` 变量定义在 `variables.scss` +- [ ] `_focus-ring.scss` mixin 文件存在且被 5 个组件引用 +- [ ] PrimaryButton / SecondaryButton / SegmentTabs / DoctorTabBar / FormInput 有焦点环样式 +- [ ] 键盘 Tab 导航时焦点环可见 + +### Consent 拦截 + +- [ ] `consent_check.rs` 模块存在且导出 `check_consent_active` +- [ ] 至少 5 个数据读取 handler 调用 consent 检查 +- [ ] admin/doctor/nurse/health_manager 角色豁免检查 +- [ ] 患者端无有效 consent 时返回 403 + 友好错误消息 +- [ ] `cargo check` + `cargo test` 全 workspace 通过 + +### 编译与构建 + +- [ ] `cd apps/miniprogram && npx tsc --noEmit` 零错误 +- [ ] `cd apps/miniprogram && pnpm build` 成功 +- [ ] `cargo check` 全 workspace 通过 +- [ ] `cargo test --workspace` 全部通过 + +### 提交记录 + +- [ ] T1-1: `test(mp): secure-storage 单元测试` +- [ ] T1-2: `test(mp): request.ts 扩展测试` +- [ ] T1-3: `test(mp): auth store 单元测试` +- [ ] T1-4: `test(mp): DataSyncScheduler+BLEManager 扩展测试` +- [ ] U1-1: `feat(mp): ARIA 角色标注` +- [ ] U1-2: `feat(mp): 表单可访问性` +- [ ] U1-3: `feat(mp): aria-live 动态内容播报` +- [ ] U1-4: `feat(mp): 焦点管理基础` +- [ ] S1-1: `feat(health): consent 门控` + +### Wiki 更新 + +- [ ] `wiki/index.md` 关键数字:测试文件数 / 组件数 / 权限码更新 +- [ ] 症状导航新增:consent 检查 403 的症状条目