测试覆盖: secure-storage/request/auth/DataSyncScheduler 测试扩展 UX 合规: ARIA 角色标注 + 表单可访问性 + aria-live + 焦点管理 安全: 后端 consent 拦截器
1579 lines
50 KiB
Markdown
1579 lines
50 KiB
Markdown
# 小程序 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 的症状条目
|