Files
hms/docs/superpowers/plans/2026-05-21-miniprogram-phase1-plan.md
iven 9c61156ab3 docs(mp): Phase 1 实施计划 — 测试覆盖 + UX 合规(9 Tasks)
测试覆盖: secure-storage/request/auth/DataSyncScheduler 测试扩展
UX 合规: ARIA 角色标注 + 表单可访问性 + aria-live + 焦点管理
安全: 后端 consent 拦截器
2026-05-21 23:46:06 +08:00

1579 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 小程序 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<string, string>();
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<string, string> = {
'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<string, string>();
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
- <View className={`seg-tabs seg-tabs--${variant}`}>
+ <View className={`seg-tabs seg-tabs--${variant}`} role="tablist">
{tabs.map((tab) => (
<View
key={tab.key}
+ role="tab"
+ aria-selected={activeKey === tab.key}
className={`seg-tab ${activeKey === tab.key ? 'seg-tab--active' : ''}`}
onClick={() => onChange(tab.key)}
>
```
#### U1-1.2 DoctorTabBar
**文件:** `apps/miniprogram/src/components/ui/DoctorTabBar/index.tsx`
```diff
- <View className="doctor-tabbar">
+ <View className="doctor-tabbar" role="tablist">
{DOCTOR_TABS.map((tab) => (
<View
key={tab.key}
+ role="tab"
+ aria-selected={tab.key === activeKey}
className={`doctor-tabbar__item ${tab.key === activeKey ? 'doctor-tabbar__item--active' : ''}`}
```
#### U1-1.3 PrimaryButton
**文件:** `apps/miniprogram/src/components/ui/PrimaryButton/index.tsx`
```diff
return (
- <View className={cls} onClick={!disabled && !loading ? onClick : undefined}>
+ <View
+ className={cls}
+ role="button"
+ aria-disabled={disabled || loading}
+ aria-busy={loading}
+ onClick={!disabled && !loading ? onClick : undefined}
+ >
```
#### U1-1.4 SecondaryButton
**文件:** `apps/miniprogram/src/components/ui/SecondaryButton/index.tsx`
```diff
- <View className={cls} onClick={!disabled ? onClick : undefined}>
+ <View
+ className={cls}
+ role="button"
+ aria-disabled={disabled}
+ onClick={!disabled ? onClick : undefined}
+ >
```
#### U1-1.5 Loading
**文件:** `apps/miniprogram/src/components/Loading/index.tsx`
```diff
- <View className={`loading-state ${isListEnd ? 'loading-state--end' : ''}`}>
+ <View
+ className={`loading-state ${isListEnd ? 'loading-state--end' : ''}`}
+ role="status"
+ aria-live="polite"
+ >
```
#### U1-1.6 LoadingCard
**文件:** `apps/miniprogram/src/components/ui/LoadingCard/index.tsx`
```diff
- <View className={`loading-card-group loading-card-group--${layout}`}>
+ <View
+ className={`loading-card-group loading-card-group--${layout}`}
+ role="status"
+ aria-label="正在加载"
+ >
```
### 验证步骤
```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<FormInputProps> = ({
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 (
<View className={cls}>
{label && <Text className='form-input__label' id={errorId ? `label-${label}` : undefined}>{label}</Text>}
<View className='form-input__field'>
<Input
className='form-input__control'
placeholder={placeholder}
placeholderClass='form-input__placeholder'
value={value}
onInput={e => onInput?.(e.detail.value)}
type={type}
maxlength={maxLength}
disabled={disabled}
+ aria-label={ariaLabel || label}
+ aria-describedby={errorId}
+ aria-invalid={!!error}
/>
</View>
- {error && <Text className='form-input__error'>{error}</Text>}
+ {error && <Text className='form-input__error' id={errorId} role='alert'>{error}</Text>}
</View>
);
};
```
### U1-2.2 体征录入页 aria-valuemin/max/now
**文件:** `apps/miniprogram/src/pages/pkg-health/input/index.tsx`(查找数值输入区域)
在体征录入页的数值 Input 组件上添加 ARIA 属性。此改动需要找到具体的 Input 元素并添加属性:
```diff
<Input
type='digit'
value={value}
onInput={e => 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
- <View className='empty-state'>
+ <View className='empty-state' role='status' aria-live='polite'>
```
### U1-3.2 ErrorState
**文件:** `apps/miniprogram/src/components/ErrorState/index.tsx`
```diff
- <View className='error-state'>
+ <View className='error-state' role='alert' aria-live='assertive'>
```
### U1-3.3 TrendChart tooltip
**文件:** `apps/miniprogram/src/components/TrendChart/index.tsx`
```diff
{tooltip && (
<View
className='trend-tooltip'
style={{ left: `${tooltip.x}px`, top: '8px' }}
+ role='tooltip'
+ aria-live='polite'
>
<Text className='trend-tooltip-text'>
{tooltip.date}: {tooltip.value}{unit ? ` ${unit}` : ''}
</Text>
</View>
)}
```
### 验证
```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<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Query(query): Query<VitalSignListQuery>,
) -> 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 的症状条目