测试覆盖: secure-storage/request/auth/DataSyncScheduler 测试扩展 UX 合规: ARIA 角色标注 + 表单可访问性 + aria-live + 焦点管理 安全: 后端 consent 拦截器
50 KiB
小程序 Phase 1 实施计划:测试覆盖 + UX 合规
日期: 2026-05-21 | 分支: feat/media-library-banner | 预计工时: 13d | 负责人: Dev Agent
目录
- 1. 文件结构总览
- 2. Task T1-1: secure-storage 单元测试
- 3. Task T1-2: request.ts 核心路径测试
- 4. Task T1-3: auth store 测试
- 5. Task T1-4: DataSyncScheduler + BLEManager 测试
- 6. Task U1-1: 核心 ARIA 角色标注
- 7. Task U1-2: 表单可访问性增强
- 8. Task U1-3: 动态内容 aria-live
- 9. Task U1-4: 焦点管理基础
- 10. Task S1-1: handler 层 consent 状态检查
- 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
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: 运行测试确认通过
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
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)。
需新增覆盖:
ConcurrencyLimiter— 并发上限 8,队列 FIFO- 401 重试 +
tryRefreshToken去重(多个并发 401 只刷新一次) ResponseCache— 命中/淘汰/inflight 去重/patientId 隔离safeReLaunch— 去重(两次调用只执行一次)AbortSignal取消
RED: 扩展失败测试
文件: apps/miniprogram/__tests__/services/request.test.ts
在现有文件末尾追加以下测试套件:
// ... 现有测试保持不变 ...
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: 运行测试
cd apps/miniprogram && npx vitest run __tests__/services/request.test.ts
注意:部分 401 重试测试可能因 resetForTesting() 在 beforeEach 中未完全重置 token 刷新状态而失败。若失败,在 resetForTesting() 中补充 isLoggingOut = false 重置(当前已包含)。
COMMIT
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
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: 运行测试
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
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。
需新增:
startPeriodicCheck并发互斥 — 多次调用只创建一个 timerstartPeriodicCheck实际触发同步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(追加)
// ... 现有测试保持不变 ...
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(扩展现有)
// 在现有 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: 运行测试
cd apps/miniprogram && npx vitest run __tests__/services/ble/
COMMIT
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
- <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
- <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
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
- <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
- <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
- <View className={`loading-card-group loading-card-group--${layout}`}>
+ <View
+ className={`loading-card-group loading-card-group--${layout}`}
+ role="status"
+ aria-label="正在加载"
+ >
验证步骤
# 编译检查(ARIA 属性是字符串透传,不影响编译)
cd apps/miniprogram && npx tsc --noEmit
# 构建检查
cd apps/miniprogram && pnpm build
在微信开发者工具中验证:
- 打开任意含 SegmentTabs 的页面(如健康页)
- 使用开发者工具 Audits 面板检查 ARIA 属性是否生效
COMMIT
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
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 元素并添加属性:
<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 等)。
验证
cd apps/miniprogram && npx tsc --noEmit
cd apps/miniprogram && pnpm build
在微信开发者工具中验证:
- 打开体征录入页,检查 Input 是否有 aria-label/aria-valuemin 等
- 输入错误值后检查 aria-invalid 和 error 提示
COMMIT
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
- <View className='empty-state'>
+ <View className='empty-state' role='status' aria-live='polite'>
U1-3.2 ErrorState
文件: apps/miniprogram/src/components/ErrorState/index.tsx
- <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
{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>
)}
验证
cd apps/miniprogram && npx tsc --noEmit
COMMIT
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
在文件末尾追加:
// ─── 焦点环(可访问性)───
$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
@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
+ @import '../../../styles/focus-ring';
.primary-btn {
// ... existing styles ...
+ @include focus-ring;
}
U1-4.4 SecondaryButton 焦点环
文件: apps/miniprogram/src/components/ui/SecondaryButton/index.scss
+ @import '../../../styles/focus-ring';
.secondary-btn {
// ... existing styles ...
+ @include focus-ring;
}
U1-4.5 SegmentTabs 焦点环
文件: apps/miniprogram/src/components/SegmentTabs/index.scss
+ @import '../../styles/focus-ring';
.seg-tab {
// ... existing styles ...
+ @include interactive-focus;
}
U1-4.6 DoctorTabBar 焦点环
文件: apps/miniprogram/src/components/ui/DoctorTabBar/index.scss
+ @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
+ @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;
+ }
}
验证
cd apps/miniprogram && pnpm build
在微信开发者工具中验证:
- 使用键盘 Tab 键在按钮/TabBar 间导航,观察焦点环是否可见
- FormInput 聚焦时边框+阴影是否正确
COMMIT
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(新建)
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
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
+ 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?;
+ }
// ... 原有逻辑 ...
}
验证
cargo check
cargo test --workspace -p erp-health
COMMIT
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 / AbortSignalauth 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.scssmixin 文件存在且被 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 的症状条目