Phase 2: Token 常量生成 + Analytics PII 清理 + 阈值加密 + DTO 最小化 + Canvas 适老 + 表单跳焦 Phase 3: API 签名 + any 清零 + 大文件拆分 + 构建优化 + CI + 导航状态 + 微交互
68 KiB
小程序 Phase 2-3 实施计划
日期: 2026-05-21 | 设计规格:
docs/superpowers/specs/2026-05-21-miniprogram-comprehensive-improvement-design.md§6-7 前置: Phase 0 + Phase 1 已完成(AES-GCM 加密、TypeScript strict、ESLint、secure-storage 测试)
目录
Phase 2:加密替换 + Canvas 适老(11d)
文件结构表
| 新增/修改 | 文件路径 | 类型 |
|---|---|---|
| 新增 | apps/miniprogram/scripts/generate-tokens.ts |
Token 生成脚本 |
| 新增 | apps/miniprogram/src/styles/token-values.ts |
自动生成,勿手动编辑 |
| 新增 | apps/miniprogram/src/hooks/useCanvasTokens.ts |
Canvas Token hook |
| 新增 | apps/miniprogram/src/hooks/useAutoFocus.ts |
表单跳焦 hook |
| 修改 | apps/miniprogram/src/services/analytics.ts |
PII 清理 |
| 修改 | apps/miniprogram/src/services/health.ts |
阈值缓存加密 |
| 修改 | apps/miniprogram/src/services/auth.ts |
PatientDTO 最小化 |
| 修改 | apps/miniprogram/src/services/patient.ts |
PatientSummary 类型 |
| 修改 | apps/miniprogram/src/stores/auth.ts |
summary 端点调用 |
| 修改 | apps/miniprogram/src/components/TrendChart/index.tsx |
Canvas 适老 |
| 修改 | apps/miniprogram/src/components/TrendChart/index.scss |
适老样式 |
| 修改 | apps/miniprogram/src/pages/pkg-health/input/index.tsx |
跳焦 |
| 修改 | apps/miniprogram/src/pages/pkg-health/daily-monitoring/index.tsx |
跳焦 |
| 修改 | apps/miniprogram/package.json |
脚本入口 |
E2-1: Token 常量生成脚本(1d)
目标: 从 tokens.scss 构建时生成 token-values.ts,供 Canvas 和 JS 运行时使用。微信小程序无 getComputedStyle/document,必须用构建时生成方案。
TDD 步骤
-
Step 1: 编写脚本骨架 + 单元测试(RED)
文件:
apps/miniprogram/scripts/generate-tokens.ts// scripts/generate-tokens.ts // 从 src/styles/tokens.scss + variables.scss 解析 CSS 变量, // 输出 src/styles/token-values.ts 供 JS 运行时使用 import { readFileSync, writeFileSync } from 'fs'; import { resolve, dirname } from 'path'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); interface TokenMap { [key: string]: string; } // 解析 SCSS 文件中的 CSS 变量声明 function parseTokensFromScss( scssContent: string, selector: string = 'page', ): TokenMap { const tokens: TokenMap = {}; // 匹配选择器块内的 --tk-* 变量 const blockRegex = new RegExp( `${selector.replace('.', '\\.')}\\s*\\{([^}]+)}`, 's', ); const blockMatch = scssContent.match(blockRegex); if (!blockMatch) return tokens; const varRegex = /--tk-([\w-]+)\s*:\s*([^;]+);/g; let match: RegExpExecArray | null; while ((match = varRegex.exec(blockMatch[1])) !== null) { // 跳过 SCSS 变量引用(如 #{$pri}),仅保留已解析的值 const value = match[2].trim(); if (!value.includes('#{')) { tokens[match[1]] = value; } } return tokens; } // 解析 SCSS 变量(如 $pri: #C4623A)用于替换 #{...} 引用 function parseScssVariables(scssContent: string): Record<string, string> { const vars: Record<string, string> = {}; const regex = /\$([\w-]+)\s*:\s*([^;]+);/g; let match: RegExpExecArray | null; while ((match = regex.exec(scssContent)) !== null) { vars[match[1]] = match[2].trim(); } return vars; } // 解析 tokens.scss,替换 SCSS 变量引用 function resolveTokenValues( tokensContent: string, variablesContent: string, selector: string, ): TokenMap { const scssVars = parseScssVariables(variablesContent); const rawTokens = parseTokensFromScss(tokensContent, selector); const resolved: TokenMap = {}; for (const [key, value] of Object.entries(rawTokens)) { // 替换 #{...} 引用 let resolvedValue = value; resolvedValue = resolvedValue.replace( /#\{\$([\w-]+)\}/g, (_, varName) => scssVars[varName] || '', ); resolved[key] = resolvedValue; } return resolved; } function generateTokenFile( tokensPath: string, variablesPath: string, outputPath: string, ): void { const tokensContent = readFileSync(tokensPath, 'utf-8'); const variablesContent = readFileSync(variablesPath, 'utf-8'); const normalTokens = resolveTokenValues( tokensContent, variablesContent, 'page', ); const elderTokens = resolveTokenValues( tokensContent, variablesContent, '.elder-mode', ); const doctorTokens = resolveTokenValues( tokensContent, variablesContent, '.doctor-mode', ); const output = `// Auto-generated by scripts/generate-tokens.ts — DO NOT EDIT
// Generated at: ${new Date().toISOString()}
export const TOKEN_VALUES = ${JSON.stringify(normalTokens, null, 2)} as const;
export const ELDER_TOKEN_OVERRIDES = ${JSON.stringify(elderTokens, null, 2)} as const;
export const DOCTOR_TOKEN_OVERRIDES = ${JSON.stringify(doctorTokens, null, 2)} as const;
// Canvas 专用:字号(数字,单位 px) export const CANVAS_FONT_NORMAL = { yLabel: 10, xLabel: 10, tooltip: 12, pointNormal: 3, pointAbnormal: 5, } as const;
export const CANVAS_FONT_ELDER = { yLabel: 14, xLabel: 14, tooltip: 16, pointNormal: 5, pointAbnormal: 8, } as const; `;
writeFileSync(outputPath, output, 'utf-8');
console.log(`[generate-tokens] Generated ${outputPath}`);
console.log(
` Normal: ${Object.keys(normalTokens).length} tokens`,
);
console.log(
` Elder: ${Object.keys(elderTokens).length} overrides`,
);
console.log(
` Doctor: ${Object.keys(doctorTokens).length} overrides`,
);
}
// CLI 入口 if (process.argv[1]?.endsWith('generate-tokens.ts')) { const root = resolve(__dirname, '..'); generateTokenFile( resolve(root, 'src/styles/tokens.scss'), resolve(root, 'src/styles/variables.scss'), resolve(root, 'src/styles/token-values.ts'), ); }
export { parseTokensFromScss, parseScssVariables, resolveTokenValues, generateTokenFile };
测试文件: `apps/miniprogram/scripts/__tests__/generate-tokens.test.ts`
```typescript
import { describe, it, expect } from 'vitest';
import {
parseTokensFromScss,
parseScssVariables,
resolveTokenValues,
} from '../generate-tokens';
describe('parseScssVariables', () => {
it('extracts SCSS variable declarations', () => {
const scss = '$pri: #C4623A;\n$bg: #F5F0EB;';
const vars = parseScssVariables(scss);
expect(vars).toEqual({ pri: '#C4623A', bg: '#F5F0EB' });
});
it('ignores comments and non-variable lines', () => {
const scss = '// comment\n$pri: #C4623A;';
const vars = parseScssVariables(scss);
expect(vars).toEqual({ pri: '#C4623A' });
});
});
describe('parseTokensFromScss', () => {
it('extracts --tk-* CSS variables from page selector', () => {
const scss = `page {
--tk-font-h1: 28px;
--tk-font-body: 16px;
}`;
const tokens = parseTokensFromScss(scss, 'page');
expect(tokens).toEqual({ 'font-h1': '28px', 'font-body': '16px' });
});
it('extracts from class selector', () => {
const scss = `.elder-mode {
--tk-font-h1: 32px;
}`;
const tokens = parseTokensFromScss(scss, '.elder-mode');
expect(tokens).toEqual({ 'font-h1': '32px' });
});
it('skips SCSS variable references', () => {
const scss = `page {
--tk-pri: #{$pri};
--tk-font-body: 16px;
}`;
const tokens = parseTokensFromScss(scss, 'page');
expect(tokens).toEqual({ 'font-body': '16px' });
expect(tokens).not.toHaveProperty('pri');
});
});
describe('resolveTokenValues', () => {
it('replaces SCSS variable references with actual values', () => {
const tokensScss = `page {
--tk-pri: #{$pri};
}`;
const varsScss = '$pri: #C4623A;';
const result = resolveTokenValues(tokensScss, varsScss, 'page');
expect(result).toEqual({ pri: '#C4623A' });
});
it('handles multi-level variable references', () => {
const tokensScss = `page {
--tk-card-radius: 16px;
--tk-font-body: 16px;
}`;
const varsScss = '$r: 16px;';
const result = resolveTokenValues(tokensScss, varsScss, 'page');
expect(result['card-radius']).toBe('16px');
expect(result['font-body']).toBe('16px');
});
});
运行命令:
cd apps/miniprogram && npx vitest run scripts/__tests__/generate-tokens.test.ts
预期: 测试失败(文件不存在)
-
Step 2: 创建脚本文件(GREEN)
创建
apps/miniprogram/scripts/generate-tokens.ts,内容见 Step 1。运行命令:
cd apps/miniprogram && npx vitest run scripts/__tests__/generate-tokens.test.ts预期: 测试通过
-
Step 3: 执行脚本生成 token-values.ts
运行命令:
cd apps/miniprogram && npx tsx scripts/generate-tokens.ts预期: 生成
src/styles/token-values.ts,包含约 40 个 token + elder 覆盖 + doctor 覆盖验证:
# 确认生成的文件存在且非空 test -s apps/miniprogram/src/styles/token-values.ts && echo "OK" -
Step 4: 添加 npm script 到 package.json
文件:
apps/miniprogram/package.json在
scripts中新增:{ "scripts": { "generate-tokens": "tsx scripts/generate-tokens.ts", "prebuild:weapp": "npm run generate-tokens", "predev:weapp": "npm run generate-tokens" } }运行命令:
cd apps/miniprogram && npm run generate-tokens -
Step 5: 创建 useCanvasTokens hook
文件:
apps/miniprogram/src/hooks/useCanvasTokens.tsimport { useMemo } from 'react'; import { TOKEN_VALUES, ELDER_TOKEN_OVERRIDES, DOCTOR_TOKEN_OVERRIDES, CANVAS_FONT_NORMAL, CANVAS_FONT_ELDER, } from '@/styles/token-values'; import { useUIStore } from '@/stores/ui'; import { useAuthStore } from '@/stores/auth'; /** Canvas 绘制用的合并 Token(字号为数字 px,颜色为字符串) */ export interface CanvasTokens { // 色彩 pri: string; tx: string; tx2: string; gridColor: string; // 字号(px 数字) fontH1: number; fontBody: number; fontBodySm: number; fontCap: number; // Canvas 专用 yLabelFontSize: number; xLabelFontSize: number; tooltipFontSize: number; pointNormalRadius: number; pointAbnormalRadius: number; // 参考 referenceBandColor: string; areaGradientStart: string; areaGradientEnd: string; lineColor: string; abnormalColor: string; } /** 为 Canvas 组件提供适老化/医生端的 Token 值 */ export function useCanvasTokens(): CanvasTokens { const mode = useUIStore((s) => s.mode); const isDoctor = useAuthStore((s) => s.isDoctor); return useMemo(() => { // 基础色彩 let pri = TOKEN_VALUES['pri'] || '#C4623A'; let tx = TOKEN_VALUES['tx'] || '#2D2A26'; if (isDoctor) { pri = DOCTOR_TOKEN_OVERRIDES['pri'] || pri; tx = DOCTOR_TOKEN_OVERRIDES['tx'] || tx; } const isElder = mode === 'elder'; const fontSet = isElder ? CANVAS_FONT_ELDER : CANVAS_FONT_NORMAL; return { pri, tx, tx2: isElder ? '#5A554F' : (TOKEN_VALUES['text-secondary'] || '#78716C'), gridColor: isElder ? '#E0DBD5' : '#F3F4F6', fontH1: parseInt(TOKEN_VALUES['font-h1'] || '28', 10), fontBody: parseInt( isElder ? (ELDER_TOKEN_OVERRIDES['font-body'] || '22') : (TOKEN_VALUES['font-body'] || '16'), 10, ), fontBodySm: parseInt( isElder ? (ELDER_TOKEN_OVERRIDES['font-body-sm'] || '19') : (TOKEN_VALUES['font-body-sm'] || '14'), 10, ), fontCap: parseInt( isElder ? (ELDER_TOKEN_OVERRIDES['font-cap'] || '18') : (TOKEN_VALUES['font-cap'] || '13'), 10, ), yLabelFontSize: fontSet.yLabel, xLabelFontSize: fontSet.xLabel, tooltipFontSize: fontSet.tooltip, pointNormalRadius: fontSet.pointNormal, pointAbnormalRadius: fontSet.pointAbnormal, referenceBandColor: isElder ? 'rgba(5,150,105,0.15)' : 'rgba(5,150,105,0.08)', areaGradientStart: isElder ? 'rgba(8,145,178,0.20)' : 'rgba(8,145,178,0.15)', areaGradientEnd: 'rgba(8,145,178,0.01)', lineColor: '#0891B2', abnormalColor: '#DC2626', }; }, [mode, isDoctor]); }验证:
cd apps/miniprogram && npx tsc --noEmit src/hooks/useCanvasTokens.ts
S2-1: Analytics 队列 PII 清理(2d)
目标: 移除 userId/patientId 字段,运行时过滤 properties 中的 PII 标识。
TDD 步骤
-
Step 1: 编写 PII 清理测试(RED)
文件:
apps/miniprogram/src/services/__tests__/analytics-pii.test.tsimport { describe, it, expect, beforeEach, vi } from 'vitest'; // Mock Taro vi.mock('@tarojs/taro', () => ({ default: { getStorageSync: vi.fn(() => '[]'), setStorage: vi.fn(), removeStorageSync: vi.fn(), }, })); // Mock request vi.mock('../request', () => ({ api: { post: vi.fn().mockResolvedValue({ success: true }) }, })); describe('Analytics PII 清理', () => { it('trackEvent 不包含 userId/patientId 字段', async () => { const { trackEvent, flushEvents } = await import('../analytics'); trackEvent('test_event'); // 获取 setStorage 的调用参数来验证队列内容 const Taro = await import('@tarojs/taro'); // flushEvents 清空内存队列,我们需要在 flush 前捕获 // 这里验证数据模型不含 PII 字段 }); it('properties 中过滤 PII 标识字段', async () => { // 验证 sanitizeProperties 函数 }); it('flushEvents 发送的 batch 不含 PII', async () => { const { trackEvent, flushEvents } = await import('../analytics'); const { api } = await import('../request'); trackEvent('page_view', { page: 'health', userId: 'should-be-removed', patientId: 'should-be-removed', user_name: 'should-be-removed', phone: 'should-be-removed', id_card: 'should-be-removed', }); await flushEvents(); const postCall = vi.mocked(api.post).mock.calls[0]; const body = postCall[1] as { events: Array<Record<string, unknown>> }; const evt = body.events[0]; // 事件级别不应有 userId/patientId expect(evt).not.toHaveProperty('userId'); expect(evt).not.toHaveProperty('patientId'); // properties 中不应有 PII 字段 const props = evt.properties as Record<string, unknown>; expect(props).not.toHaveProperty('userId'); expect(props).not.toHaveProperty('patientId'); expect(props).not.toHaveProperty('user_name'); expect(props).not.toHaveProperty('phone'); expect(props).not.toHaveProperty('id_card'); // 正常字段保留 expect(props.page).toBe('health'); }); });运行命令:
cd apps/miniprogram && npx vitest run src/services/__tests__/analytics-pii.test.ts预期: 测试失败(analytics.ts 尚未改造)
-
Step 2: 修改 AnalyticsEvent 接口和 trackEvent(GREEN)
文件:
apps/miniprogram/src/services/analytics.ts改动点:
- 移除
AnalyticsEvent接口中的userId和patientId字段 - 添加
sanitizeProperties运行时过滤函数 trackEvent调用时自动清理 properties
// PII 字段黑名单 — 运行时过滤 const PII_KEYS = new Set([ 'userId', 'user_id', 'patientId', 'patient_id', 'user_name', 'username', 'phone', 'mobile', 'id_card', 'id_number', 'email', 'address', 'openid', 'access_token', 'refresh_token', ]); /** 运行时过滤 properties 中的 PII 标识 */ function sanitizeProperties( properties?: Record<string, unknown>, ): Record<string, unknown> | undefined { if (!properties) return undefined; const cleaned: Record<string, unknown> = {}; for (const [key, value] of Object.entries(properties)) { if (!PII_KEYS.has(key)) { cleaned[key] = value; } } return Object.keys(cleaned).length > 0 ? cleaned : undefined; } interface AnalyticsEvent { event: string; properties?: Record<string, unknown>; timestamp: number; // 注意:不再包含 userId 和 patientId } export function trackEvent( event: EventName | string, properties?: Record<string, unknown>, ): void { loadQueue(); const evt: AnalyticsEvent = { event, properties: sanitizeProperties(properties), timestamp: Date.now(), }; // ... 其余不变 }运行命令:
cd apps/miniprogram && npx vitest run src/services/__tests__/analytics-pii.test.ts预期: 测试通过
- 移除
-
Step 3: 清理 Storage 中已有的旧格式队列
在
migrateLegacyStorage调用点(app.tsxonLaunch)添加队列清理:// apps/miniprogram/src/app.tsx — onLaunch 中 // 清理旧格式 analytics 队列(含 PII 的旧事件) try { const raw = Taro.getStorageSync('analytics_queue'); if (raw && Array.isArray(raw)) { const cleaned = raw .filter((evt: Record<string, unknown>) => evt.event) // 保留有效事件 .map((evt: Record<string, unknown>) => { delete evt.userId; delete evt.patientId; return evt; }); Taro.setStorageSync('analytics_queue', cleaned); } } catch { /* best-effort */ } -
Step 4: 编译验证
运行命令:
cd apps/miniprogram && npx tsc --noEmit cd G:/hms && cargo check
S2-2: 健康阈值缓存加密(1d)
目标: 复用 Phase 0 的 secureSet/secureGet(AES-GCM),替换 getHealthThresholds 中的明文 Storage 调用。
TDD 步骤
-
Step 1: 编写加密缓存测试(RED)
文件:
apps/miniprogram/src/services/__tests__/health-threshold-encrypt.test.tsimport { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('@tarojs/taro', () => ({ default: { getStorageSync: vi.fn(), setStorageSync: vi.fn(), }, })); vi.mock('@/utils/secure-storage', () => ({ secureGet: vi.fn(), secureSet: vi.fn(), secureRemove: vi.fn(), })); vi.mock('../request', () => ({ api: { get: vi.fn().mockResolvedValue([ { id: '1', indicator: 'systolic_bp', direction: 'high', threshold_value: 140, level: 'warning', is_active: true }, ]), }, })); describe('getHealthThresholds 缓存加密', () => { beforeEach(() => { vi.clearAllMocks(); }); it('写入缓存时使用 secureSet', async () => { const { getHealthThresholds } = await import('../health'); const { secureSet } = await import('@/utils/secure-storage'); await getHealthThresholds(); expect(secureSet).toHaveBeenCalledWith( 'health_thresholds', expect.any(String), // JSON 字符串 ); }); it('读取缓存时使用 secureGet', async () => { const { getHealthThresholds } = await import('../health'); const { secureGet } = await import('@/utils/secure-storage'); // 模拟缓存命中(24h TTL 内) vi.mocked(secureGet).mockReturnValue( JSON.stringify({ data: [{ id: '1', indicator: 'systolic_bp', direction: 'high', threshold_value: 140, level: 'warning', is_active: true }], ts: Date.now(), }), ); const result = await getHealthThresholds(); expect(secureGet).toHaveBeenCalledWith('health_thresholds'); expect(result).toHaveLength(1); }); it('缓存过期时重新请求 API', async () => { const { getHealthThresholds } = await import('../health'); const { secureGet } = await import('@/utils/secure-storage'); // 模拟过期缓存 vi.mocked(secureGet).mockReturnValue( JSON.stringify({ data: [], ts: Date.now() - 25 * 60 * 60 * 1000 }), ); const result = await getHealthThresholds(); // 应该调用 API 获取新数据 expect(result).toHaveLength(1); }); }); -
Step 2: 修改 getHealthThresholds 使用加密存储(GREEN)
文件:
apps/miniprogram/src/services/health.ts改动点: 将
Taro.getStorageSync/Taro.setStorageSync替换为secureGet/secureSetimport { secureGet, secureSet } from '@/utils/secure-storage'; // ... 在 getHealthThresholds 函数中: export async function getHealthThresholds(): Promise<HealthThreshold[]> { // 尝试从加密缓存读取 try { const cachedRaw = secureGet(THRESHOLD_CACHE_KEY); if (cachedRaw) { const cached = JSON.parse(cachedRaw) as { data: HealthThreshold[]; ts: number }; if (cached && Date.now() - cached.ts < THRESHOLD_TTL) { return cached.data; } } } catch { /* cache miss */ } try { const data = await api.get<HealthThreshold[]>('/health/critical-value-thresholds/public'); // 使用加密存储写入 secureSet(THRESHOLD_CACHE_KEY, JSON.stringify({ data, ts: Date.now() })); return data; } catch (err) { console.warn('[health] 数据加载失败:', err); return []; } }运行命令:
cd apps/miniprogram && npx vitest run src/services/__tests__/health-threshold-encrypt.test.ts -
Step 3: 修改 THRESHOLD_CACHE_KEY 前缀
因为
secureSet会添加_es_前缀,旧明文缓存的 key 是health_thresholds,新加密缓存的 Storage key 是_es_health_thresholds。旧数据会在自然过期后被忽略,无需手动清理。编译验证:
cd apps/miniprogram && npx tsc --noEmit
S2-3: Patient DTO 字段最小化(2d)
目标: 后端新增 PatientSummary DTO(列表用),前端调用 summary 端点,减少敏感字段传输和存储。
TDD 步骤
-
Step 1: 后端新增 PatientSummary DTO 和端点(RED)
先检查后端已有的 patient handler 和 DTO 结构。
新增文件:
crates/erp-health/src/dto/patient_summary.rsuse serde::{Deserialize, Serialize}; use utoipa::ToSchema; use validator::Validate; /// 患者摘要 — 列表/切换用,不含敏感字段 #[derive(Debug, Serialize, ToSchema)] pub struct PatientSummary { pub id: String, pub name: String, pub gender: Option<String>, pub birth_date: Option<String>, pub relation: Option<String>, pub status: Option<String>, // 注意:不包含 id_number, phone, phone_hash, id_card_number 等敏感字段 }在
crates/erp-health/src/handler/patient_handler.rs新增:/// GET /health/patients/summary — 列表用摘要(字段最小化) pub async fn list_patient_summaries( State(state): State<AppState>, Extension(claims): Extension<Claims>, Query(params): Query<Pagination>, ) -> Result<Json<ApiResponse<PaginatedResponse<PatientSummary>>>, AppError> { let tenant_id = &claims.tenant_id; let user_id = &claims.user_id; let page = params.page.unwrap_or(1).max(1).min(100); let page_size = params.page_size.unwrap_or(20).max(1).min(100); let (patients, total) = PatientService::list_summaries( &state.db, tenant_id, user_id, page, page_size, ).await?; Ok(Json(ApiResponse::success(PaginatedResponse { data: patients, total, page, page_size, }))) }在
crates/erp-health/src/service/patient_service.rs新增:pub async fn list_summaries( db: &DatabaseConnection, tenant_id: &str, user_id: &str, page: u64, page_size: u64, ) -> Result<(Vec<PatientSummary>, i64), AppError> { // 查询关联患者的摘要信息 // SELECT id, name, gender, birth_date, relation, status // FROM patients WHERE tenant_id = ? AND deleted_at IS NULL // ... }路由注册(
crates/erp-health/src/module.rs):// 在 patient 路由组中新增 .route("/patients/summary", get(patient_handler::list_patient_summaries))后端测试:
cd G:/hms && cargo test -p erp-health -- patient_summary -
Step 2: 前端新增 PatientSummary 类型和 service(GREEN)
文件:
apps/miniprogram/src/services/auth.ts// PatientInfo 保持不变(详情用) export interface PatientInfo { id: string; name: string; gender?: string; birth_date?: string; relation: string; } /** 患者摘要 — 列表用,字段最小化,不含敏感信息 */ export interface PatientSummary { id: string; name: string; gender?: string; birth_date?: string; relation?: string; status?: string; } /** 获取患者摘要列表(字段最小化,替代 getPatients) */ export async function getPatientSummaries() { const res = await api.get<PaginatedData<PatientSummary>>('/health/patients/summary'); return Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : []); } -
Step 3: 修改 auth store 使用 summary 端点
文件:
apps/miniprogram/src/stores/auth.ts// loadPatients 改用 summary 端点 loadPatients: async () => { try { const patients = await authApi.getPatientSummaries(); // PatientSummary → PatientInfo 兼容映射 const mapped: authApi.PatientInfo[] = patients.map((p) => ({ id: p.id, name: p.name, gender: p.gender, birth_date: p.birth_date, relation: p.relation || 'self', })); set({ patients: mapped }); if (mapped.length > 0 && !get().currentPatient) { get().setCurrentPatient(mapped[0]); } } catch (err) { console.warn('[auth] 患者列表加载失败:', err); } }, -
Step 4: 过滤 current_patient 存储字段
在
setCurrentPatient中过滤敏感字段:setCurrentPatient: (patient) => { // 仅保留非敏感字段到 Storage const safePatient: authApi.PatientInfo = { id: patient.id, name: patient.name, gender: patient.gender, birth_date: patient.birth_date, relation: patient.relation, }; secureSet('current_patient_id', safePatient.id); secureSet('current_patient', JSON.stringify(safePatient)); setCachedPatientId(safePatient.id); clearRequestCache(); set({ currentPatient: safePatient }); }, -
Step 5: 编译验证
cd G:/hms && cargo check && cargo test -p erp-health cd apps/miniprogram && npx tsc --noEmit
U2-1: TrendChart Canvas 适老化(2d)
目标: 替换所有硬编码字号/颜色,关怀模式放大字号至 14-16px,异常点放大至 7-8px,参考区间带增加纹理,tooltip 改常驻。
TDD 步骤
-
Step 1: 重构 TrendChart 使用 useCanvasTokens(GREEN)
文件:
apps/miniprogram/src/components/TrendChart/index.tsx核心改动:
- 导入
useCanvasTokens - 替换所有硬编码字号
'10px sans-serif'→tokens.yLabelFontSize + 'px sans-serif' - 替换硬编码颜色 → token 颜色
- 关怀模式异常点半径放大到
tokens.pointAbnormalRadius - 参考区间带增加斜线纹理(hatch pattern)
- tooltip 改为常驻显示(最后 N 个数据点)
改动要点(非完整文件,仅关键 diff):
import { useCanvasTokens, type CanvasTokens } from '@/hooks/useCanvasTokens'; export default React.memo(function TrendChart({ data, referenceMin, referenceMax, unit = '', height = 500, }: TrendChartProps) { const tokens = useCanvasTokens(); // ... const draw = useCallback(() => { // ... 获取 ctx 后 // Reference band with hatch pattern for elder mode if (referenceMin != null && referenceMax != null) { const ry1 = toY(referenceMax); const ry2 = toY(referenceMin); // 底色填充 ctx.fillStyle = tokens.referenceBandColor; ctx.fillRect(pad.left, ry1, cw, ry2 - ry1); // 关怀模式增加斜线纹理增强区分度 if (tokens.yLabelFontSize >= 14) { ctx.save(); ctx.strokeStyle = 'rgba(5,150,105,0.18)'; ctx.lineWidth = 1; const step = 8; ctx.beginPath(); for (let x = pad.left; x < pad.left + cw; x += step) { const x1 = Math.min(x, pad.left + cw); const x2 = Math.min(x + (ry2 - ry1), pad.left + cw); ctx.moveTo(x1, ry2); ctx.lineTo(x2, ry1); } ctx.stroke(); ctx.restore(); } } // Y-axis labels — 使用 token 字号 ctx.fillStyle = tokens.tx2; ctx.font = `${tokens.yLabelFontSize}px sans-serif`; // ... // X-axis labels ctx.font = `${tokens.xLabelFontSize}px sans-serif`; // ... // Data points — 使用 token 半径 for (let i = 0; i < data.length; i++) { const d = data[i]; const isAbnormal = /* ... */; ctx.beginPath(); ctx.arc( chartPoints[i].x, chartPoints[i].y, isAbnormal ? tokens.pointAbnormalRadius : tokens.pointNormalRadius, 0, Math.PI * 2, ); ctx.fillStyle = isAbnormal ? tokens.abnormalColor : tokens.lineColor; ctx.fill(); } }, [data, referenceMin, referenceMax, tokens]); // ... }); - 导入
-
Step 2: Tooltip 常驻显示(关怀模式)
当
tokens.yLabelFontSize >= 14(关怀模式)时,默认显示最后一个数据点的 tooltip,无需用户触摸。触摸时切换到对应数据点。// 初始化时显示最后一个数据点 useEffect(() => { if (tokens.yLabelFontSize >= 14 && data && data.length > 0) { const lastIdx = data.length - 1; const w = /* canvas width */; const pad = { left: 45, right: 15 }; const cw = w - pad.left - pad.right; setTooltip({ date: data[lastIdx].date, value: data[lastIdx].value, x: pad.left + (lastIdx / Math.max(data.length - 1, 1)) * cw, }); } else { setTooltip(null); } }, [data, tokens]); -
Step 3: 修改 TrendChart SCSS 适老样式
文件:
apps/miniprogram/src/components/TrendChart/index.scss.trend-tooltip { // 关怀模式增大 tooltip .elder-mode & { padding: 12px 16px; font-size: 16px; min-height: 44px; // 触摸目标 } } -
Step 4: 验证
cd apps/miniprogram && npx tsc --noEmit # 在微信开发者工具中预览 TrendChart,分别检查: # 1. 正常模式:字号 10px,数据点半径 3/5 # 2. 关怀模式:字号 14px,数据点半径 5/8,斜线纹理,tooltip 常驻
U2-2: 表单自动跳焦 + 历史参考(1.5d)
目标: 血压录入页收缩压输入完自动跳到舒张压;日常监测页字段链式跳焦;显示上次测量值参考。
TDD 步骤
-
Step 1: 创建 useAutoFocus hook
文件:
apps/miniprogram/src/hooks/useAutoFocus.tsimport { useCallback, useRef } from 'react'; interface AutoFocusConfig { /** 字段 ID 列表,按跳焦顺序 */ fieldIds: string[]; } /** * 表单自动跳焦 hook。 * 配合 Input 组件的 returnKeyType="next" + onConfirm 实现链式跳焦。 */ export function useAutoFocus({ fieldIds }: AutoFocusConfig) { const currentIndexRef = useRef(0); /** 跳到下一个字段 */ const focusNext = useCallback( (currentId: string) => { const idx = fieldIds.indexOf(currentId); if (idx >= 0 && idx < fieldIds.length - 1) { const nextId = fieldIds[idx + 1]; // Taro 小程序中使用 createSelectorQuery 聚焦 // 注意:小程序 Input 不支持 imperative focus // 替代方案:通过 ref 设置 focus 属性 currentIndexRef.current = idx + 1; return nextId; } currentIndexRef.current = fieldIds.length - 1; return null; }, [fieldIds], ); return { focusNext, currentIndexRef }; }注意:微信小程序
<Input>不支持 imperative focus。替代方案是在<Input>上设置focus={focusedField === 'xxx'}prop,通过状态控制。 -
Step 2: 修改体征录入页 — 血压字段跳焦
文件:
apps/miniprogram/src/pages/pkg-health/input/index.tsx改动点:
- 新增
focusedFieldstate(默认'systolic') - 收缩压
<Input>添加returnKeyType="next"+onConfirm={() => setFocusedField('diastolic')} - 舒张压
<Input>添加focus={focusedField === 'diastolic'}+returnKeyType="done"+onConfirm={() => setFocusedField(null)} - 收缩压
<Input>显示上次测量参考值
const [focusedField, setFocusedField] = useState<string | null>(null); const [lastBp, setLastBp] = useState<{ systolic: number; diastolic: number } | null>(null); // 加载上次血压记录 usePageData(async () => { // ... 已有阈值加载逻辑 try { const pid = currentPatient?.id; if (pid) { const res = await api.get<{ data: DailyMonitoring[] }>( `/health/patients/${pid}/daily-monitoring`, { page: 1, page_size: 1 }, ); const latest = res?.data?.[0]; if (latest?.morning_bp_systolic && latest?.morning_bp_diastolic) { setLastBp({ systolic: latest.morning_bp_systolic, diastolic: latest.morning_bp_diastolic }); } } } catch { /* 不影响主流程 */ } }, { throttleMs: 10000 }); // 血压输入区域修改 <View className='input-bp-field'> <Text className='input-field-label'> 收缩压 {lastBp && <Text className='input-field-ref'>上次 {lastBp.systolic}</Text>} </Text> <Input type='digit' className='input-field-box' placeholder='如 120' value={systolic} focus={focusedField === 'systolic'} returnKeyType='next' onInput={(e) => setSystolic(e.detail.value)} onConfirm={() => setFocusedField('diastolic')} /> </View> // ... 中间分隔线 <View className='input-bp-field'> <Text className='input-field-label'> 舒张压 {lastBp && <Text className='input-field-ref'>上次 {lastBp.diastolic}</Text>} </Text> <Input type='digit' className='input-field-box' placeholder='如 80' value={diastolic} focus={focusedField === 'diastolic'} returnKeyType='done' onInput={(e) => setDiastolic(e.detail.value)} onConfirm={() => setFocusedField(null)} /> </View> - 新增
-
Step 3: 日常监测页链式跳焦
文件:
apps/miniprogram/src/pages/pkg-health/daily-monitoring/index.tsx类似改造:字段顺序为 晨间收缩压 → 晨间舒张压 → 晚间收缩压 → 晚间舒张压 → 体重 → 血糖 → 入量 → 出量 → 备注 → 提交
每个字段设置
returnKeyType="next"和onConfirm跳转到下一个。const FIELD_ORDER = [ 'morningSystolic', 'morningDiastolic', 'eveningSystolic', 'eveningDiastolic', 'weight', 'bloodSugar', 'fluidIntake', 'urineOutput', 'notes', ]; const [focusedField, setFocusedField] = useState<string | null>(null); // 每个 Input: <Input focus={focusedField === 'morningSystolic'} returnKeyType='next' onConfirm={() => setFocusedField('morningDiastolic')} // ... /> -
Step 4: 添加历史参考样式
.input-field-ref { color: var(--tk-text-secondary); font-size: var(--tk-font-cap); margin-left: 8px; } -
Step 5: 编译验证
cd apps/miniprogram && npx tsc --noEmit
U2-3: Loading/骨架屏统一(0.5d)
目标: 统一全项目 Loading 规范。
TDD 步骤
-
Step 1: 审计现有 Loading 用法
运行命令:
cd G:/hms && grep -rn "Loading\|loading" apps/miniprogram/src/pages/ --include="*.tsx" | grep -i "import.*Loading\|<Loading" | head -30确认当前 Loading 组件使用情况,识别不一致的地方。
-
Step 2: 建立 Loading 规范文档并统一
规范:
场景 组件 layout 参数 列表页初始加载 <LoadingCard layout="card" count={3} />'card'列表页(紧凑列表) <LoadingCard layout="list" count={5} />'list'详情页加载 <LoadingCard layout="detail" count={1} />'detail'操作反馈(提交中) <Loading />(旋转器)默认 修改所有页面中直接使用
loading && <Loading />但场景为列表/详情的地方,改为LoadingCard。修改文件清单(典型):
src/pages/health/index.tsx— 列表页 →LoadingCard layout="card"src/pages/pkg-profile/reports/index.tsx— 列表 →LoadingCard layout="card"src/pages/pkg-doctor-core/patients/index.tsx— 列表 →LoadingCard layout="list"
-
Step 3: 验证
cd apps/miniprogram && npx tsc --noEmit
Phase 2 验收清单
grep -rn 'userId\|patientId' apps/miniprogram/src/services/analytics.ts返回 0 匹配secureGet('health_thresholds')返回加密值(非明文 JSON)/health/patients/summary端点返回 5-8 字段,不含id_number/phone- TrendChart 关怀模式 Y 轴字号 >= 14px
useCanvasTokens()hook 存在且被 TrendChart 引用- 血压录入页收缩压 onConfirm 跳转到舒张压
npm run generate-tokens成功执行,生成token-values.ts
Phase 3:全面提升 + CI(7d)
文件结构表
| 新增/修改 | 文件路径 | 类型 |
|---|---|---|
| 新增 | apps/miniprogram/src/utils/request-signer.ts |
请求签名工具 |
| 新增 | apps/miniprogram/src/hooks/useNavigationState.ts |
导航状态保持 |
| 新增 | apps/miniprogram/.gitea/workflows/ci.yml |
CI 配置 |
| 修改 | apps/miniprogram/src/services/request.ts |
签名注入 |
| 修改 | apps/miniprogram/src/services/ble/BLEManager.ts |
消灭 any |
| 修改 | apps/miniprogram/src/services/ble/types.ts |
BLE 回调类型 |
| 修改 | 多个页面文件 | 大文件拆分 |
| 修改 | apps/miniprogram/config/index.ts |
构建优化 |
| 修改 | apps/miniprogram/package.json |
sideEffects |
S3-1: API 请求签名(1.5d)
目标: 每个请求携带 HMAC-SHA256 签名,防止请求篡改和重放。
TDD 步骤
-
Step 1: 编写请求签名工具 + 测试(RED)
文件:
apps/miniprogram/src/utils/__tests__/request-signer.test.tsimport { describe, it, expect } from 'vitest'; import { signRequest, generateNonce } from '../request-signer'; describe('signRequest', () => { const signingKey = 'test-signing-key-256-bit!!!!!!!!!!!'; it('生成正确的签名头', () => { const headers = signRequest( 'GET', '/health/patients', undefined, signingKey, ); expect(headers).toHaveProperty('X-Signature'); expect(headers).toHaveProperty('X-Timestamp'); expect(headers).toHaveProperty('X-Nonce'); expect(headers['X-Nonce']).toHaveLength(16); }); it('POST 请求包含 body hash', () => { const headers = signRequest( 'POST', '/health/vital-signs', { value: 120 }, signingKey, ); expect(headers).toHaveProperty('X-Signature'); // 相同参数相同 key 应产生相同签名 const headers2 = signRequest('POST', '/health/vital-signs', { value: 120 }, signingKey); // 由于 timestamp 不同,签名不同 — 但结构一致 expect(headers2).toHaveProperty('X-Signature'); }); it('无 body 时 body hash 为空字符串', () => { const headers = signRequest('GET', '/test', undefined, signingKey); expect(headers['X-Signature']).toBeTruthy(); }); }); describe('generateNonce', () => { it('生成 16 字符十六进制字符串', () => { const nonce = generateNonce(); expect(nonce).toHaveLength(16); expect(nonce).toMatch(/^[0-9a-f]{16}$/); }); it('连续调用生成不同值', () => { const n1 = generateNonce(); const n2 = generateNonce(); expect(n1).not.toBe(n2); }); });运行命令:
cd apps/miniprogram && npx vitest run src/utils/__tests__/request-signer.test.ts预期: 失败
-
Step 2: 实现请求签名工具(GREEN)
文件:
apps/miniprogram/src/utils/request-signer.tsimport { secureGet } from './secure-storage'; // 微信小程序 HMAC-SHA256 使用 wx 某些原生能力或纯 JS 实现 // 使用简单的 SHA256 实现(从 @noble/hashes 复用,Phase 0 已安装) /** * 生成 16 字符随机 nonce(十六进制) */ export function generateNonce(): string { const arr = new Uint8Array(8); // 使用 wx.getRandomValuesSync 或 polyfill(Phase 0 已配置) if (typeof crypto !== 'undefined' && crypto.getRandomValues) { crypto.getRandomValues(arr); } else { for (let i = 0; i < arr.length; i++) { arr[i] = Math.floor(Math.random() * 256); } } return Array.from(arr) .map((b) => b.toString(16).padStart(2, '0')) .join(''); } /** * 简单的 SHA-256 HMAC 实现(用于请求签名) * 依赖 Phase 0 安装的 @noble/hashes */ async function hmacSha256(key: string, message: string): Promise<string> { // 使用 SubtleCrypto(微信小程序基础库 2.17.3+ 支持) // 或 fallback 到纯 JS 实现 const encoder = new TextEncoder(); const keyData = encoder.encode(key); const msgData = encoder.encode(message); const cryptoKey = await crypto.subtle.importKey( 'raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], ); const signature = await crypto.subtle.sign('HMAC', cryptoKey, msgData); return Array.from(new Uint8Array(signature)) .map((b) => b.toString(16).padStart(2, '0')) .join(''); } /** * 为请求生成签名头 * @returns X-Signature, X-Timestamp, X-Nonce */ export async function signRequest( method: string, path: string, body: unknown, signingKey: string, ): Promise<Record<string, string>> { const timestamp = String(Math.floor(Date.now() / 1000)); const nonce = generateNonce(); // body hash:JSON stringify 后取 SHA256 const bodyStr = body !== undefined ? JSON.stringify(body) : ''; const bodyHash = bodyStr ? await hmacSha256(signingKey, bodyStr) : ''; // 签名消息:method + path + bodyHash + timestamp + nonce const message = `${method.toUpperCase()}${path}${bodyHash}${timestamp}${nonce}`; const signature = await hmacSha256(signingKey, message); return { 'X-Signature': signature, 'X-Timestamp': timestamp, 'X-Nonce': nonce, }; }运行命令:
cd apps/miniprogram && npx vitest run src/utils/__tests__/request-signer.test.ts预期: 通过
注意: 小程序环境可能不支持
crypto.subtle。如不支持,改用@noble/hashes/hmac+@noble/hashes/sha256(Phase 0 已安装@noble/ciphers,同生态)。备选纯 JS 实现:
import { hmac } from '@noble/hashes/hmac'; import { sha256 } from '@noble/hashes/sha256'; function hmacSha256Sync(key: string, message: string): string { const encoder = new TextEncoder(); const mac = hmac(sha256, encoder.encode(key), encoder.encode(message)); return Array.from(mac) .map((b) => b.toString(16).padStart(2, '0')) .join(''); } -
Step 3: 集成到 request.ts
文件:
apps/miniprogram/src/services/request.ts改动点:
- 在
getHeaders()中加入签名头 signingKey从登录响应获取,仅存内存
import { signRequest } from '@/utils/request-signer'; // 新增:签名密钥(仅内存,不持久化) let signingKey = ''; export function setSigningKey(key: string): void { signingKey = key; } export function clearSigningKey(): void { signingKey = ''; } // 修改 getHeaders 为 async(已是 async) async function getHeaders(): Promise<Record<string, string>> { if (Date.now() - headersCacheTs > HEADERS_CACHE_TTL) { refreshHeadersCache(); } const headers: Record<string, string> = { 'Content-Type': 'application/json' }; if (cachedToken) headers['Authorization'] = `Bearer ${cachedToken}`; if (responseCache.getPatientId()) headers['X-Patient-Id'] = responseCache.getPatientId(); if (cachedTenantId) headers['X-Tenant-Id'] = cachedTenantId; // 签名头 if (signingKey) { // 签名在 request() 函数中计算(需要 method + path + body) } return headers; } // 在 request() 函数中,headers 之后、Taro.request 之前添加签名 async function request<T>(method: string, path: string, data?: unknown, timeout?: number, signal?: AbortSignal, bypassLimiter = false): Promise<T> { // ... acquire limiter, get headers ... const headers = await getHeaders(); // 添加请求签名 if (signingKey) { try { const signHeaders = await signRequest(method, path, data, signingKey); Object.assign(headers, signHeaders); } catch { // 签名失败不阻断请求(降级策略) } } // ... Taro.request ... } - 在
-
Step 4: 后端配合 — signing_key 下发 + 校验中间件
后端改动(简述,具体实现由后端工程师负责):
login/refresh响应新增signing_key字段(256-bit 随机,与 access_token 同生命周期)- 新增
SignatureMiddleware:- 从
X-Signature/X-Timestamp/X-Nonce头获取签名参数 - 用该用户的
signing_key重新计算 HMAC-SHA256 - 验证签名匹配 + timestamp 偏移 <= 300s
- 验证通过则放行,否则返回 401
- 从
文件:
crates/erp-server/src/middleware/signature.rs(新增)前端在
auth.ts登录成功后保存signingKey:// auth.ts credentialLogin / wechatLogin 成功后 if (resp.signing_key) { setSigningKey(resp.signing_key); }logout中清理:clearSigningKey(); -
Step 5: 编译验证
cd G:/hms && cargo check && cargo test cd apps/miniprogram && npx tsc --noEmit
E3-1: 消灭所有 any 类型(1d)
目标: 将所有 : any 替换为具体类型或 unknown。
TDD 步骤
-
Step 1: 审计所有 any 使用
运行命令:
cd G:/hms && grep -rn ": any\|as any" apps/miniprogram/src/ --include="*.ts" --include="*.tsx" > /tmp/any-audit.txt wc -l /tmp/any-audit.txt当前约 38 处。分类处理:
类别 数量 处理方式 catch (err: any)~12 改为 catch (err: unknown)BLE 回调 (res: any)~6 提取 BLECallbackResult接口method as any1 使用 Taro RequestMethod类型Taro.requestSubscribeMessage as any2 类型声明文件 __wxConfig as any1 类型声明文件 其他 as any~16 逐个分析替换 -
Step 2: 修复 catch (err: any) → catch (err: unknown)
全局替换:
# 列出所有 catch (err: any) 或 catch (e: any) grep -rn "catch.*(e\|err): any" apps/miniprogram/src/ --include="*.ts" --include="*.tsx" -l每个文件修改:
// BEFORE } catch (err: any) { console.warn('xxx:', err); } // AFTER } catch (err: unknown) { const message = err instanceof Error ? err.message : '未知错误'; console.warn('xxx:', message); }涉及文件:
src/services/ble/BLEManager.ts— 5 处src/services/ble/DataSyncScheduler.ts— 1 处src/services/request.ts— 1 处src/stores/auth.ts— 1 处src/pages/login/index.tsx— 3 处src/pages/pkg-health/device-sync/index.tsx— 3 处src/pages/appointment/create/index.tsx— 1 处src/pages/pkg-profile/events/index.tsx— 1 处
-
Step 3: BLE 回调类型提取
文件:
apps/miniprogram/src/services/ble/types.ts新增:
/** 微信 BLE 扫描回调结果 */ export interface BLEScanResult { devices: Array<{ deviceId: string; name?: string; RSSI?: number; localName?: string; }>; } /** 微信 BLE 连接状态变更回调结果 */ export interface BLEConnectionChangeResult { deviceId: string; connected: boolean; } /** 微信 BLE 特征值变更回调结果 */ export interface BLECharacteristicChangeResult { deviceId: string; serviceId: string; characteristicId: string; value: ArrayBuffer; } /** 微信 BLE 服务发现结果 */ export interface BLEServiceResult { services: Array<{ uuid: string; isPrimary: boolean; }>; }修改
BLEManager.ts:import type { BLEScanResult, BLEConnectionChangeResult, BLECharacteristicChangeResult, BLEServiceResult, } from './types'; // BEFORE: private connChangeHandler: ((res: any) => void) | null = null; // AFTER: private connChangeHandler: ((res: BLEConnectionChangeResult) => void) | null = null; private charChangeHandler: ((res: BLECharacteristicChangeResult) => void) | null = null; // BEFORE: const onFound = (res: any) => { // AFTER: const onFound = (res: BLEScanResult) => { // BEFORE: const svc = services.find((s: any) => s.uuid... // AFTER: const svc = services.find((s: { uuid: string }) => s.uuid... -
Step 4: 修复 method as any
文件:
apps/miniprogram/src/services/request.ts// BEFORE res = await Taro.request({ url, method: method as any, ... }); // AFTER — 使用 Taro 导出的 method 类型 import type { RequestMethod } from '@tarojs/taro'; // 或者直接使用字符串字面量类型 type ValidMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'TRACE' | 'CONNECT'; res = await Taro.request({ url, method: method as ValidMethod, ... });如果 Taro 没有导出
RequestMethod,在src/types/taro.d.ts中声明:declare module '@tarojs/taro' { type RequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS'; } -
Step 5: 处理其他 as any
创建
apps/miniprogram/src/types/wx.d.ts:declare const __wxConfig: { envVersion: 'develop' | 'trial' | 'release'; }; declare module '@tarojs/taro' { interface TaroStatic { requestSubscribeMessage(options: { tmplIds: string[]; success?: (res: { [key: string]: 'accept' | 'reject' | 'ban' }) => void; fail?: (err: { errMsg: string }) => void; }): void; } }修改引用:
// login.tsx // BEFORE: const IS_SIMULATOR = typeof __wxConfig !== 'undefined' && (__wxConfig as any).envVersion === 'develop'; // AFTER: const IS_SIMULATOR = typeof __wxConfig !== 'undefined' && __wxConfig.envVersion === 'develop'; // appointment/create/index.tsx // BEFORE: await (Taro.requestSubscribeMessage as any)({ tmplIds: [tmplId] }); // AFTER: await Taro.requestSubscribeMessage({ tmplIds: [tmplId] }); -
Step 6: 全量验证
cd apps/miniprogram && npx tsc --noEmit grep -rn ": any" src/ --include="*.ts" --include="*.tsx" | wc -l # 预期: 0 或极少数(通过 ts-expect-error 标注的已知限制)
E3-2: 大文件拆分(1d)
目标: 将 8 个 >300 行文件拆分至 <=250 行。
目标文件清单
| 文件 | 当前行数 | 拆分策略 |
|---|---|---|
pages/pkg-health/daily-monitoring/index.tsx |
449 | 提取 useDailyMonitoring hook |
services/request.ts |
376 | 提取 ResponseCache/ConcurrencyLimiter 到独立文件 |
pages/health/index.tsx |
376 | 提取 useHealthData hook(已部分提取) |
pages/index/index.tsx |
371 | 提取 useHomeData hook(已存在但未完全拆分) |
services/ble/BLEManager.ts |
356 | 提取连接管理逻辑到 BLEConnection.ts |
pages/pkg-health/device-sync/index.tsx |
324 | 提取设备扫描 UI 为子组件 |
pages/appointment/create/index.tsx |
304 | 提取时间段选择为子组件 |
pages/pkg-health/input/index.tsx |
294 | 提取血压输入组为子组件 |
TDD 步骤
-
Step 1: 拆分 daily-monitoring — 提取 hook
新增文件:
apps/miniprogram/src/pages/pkg-health/daily-monitoring/useDailyMonitoring.tsimport { useState, useCallback } from 'react'; import Taro from '@tarojs/taro'; import { createDailyMonitoring } from '@/services/health'; import { useAuthStore } from '@/stores/auth'; import { useHealthStore } from '@/stores/health'; import { usePointsStore } from '@/stores/points'; import { clearRequestCache } from '@/services/request'; import { trackEvent } from '@/services/analytics'; import { useSafeTimeout } from '@/hooks/useSafeTimeout'; import { BP_RANGE, WEIGHT_RANGE, SUGAR_RANGE, VOLUME_RANGE, checkAbnormal, formatDate, FIELD_LABELS, type SectionKey, type AbnormalResult, } from './constants'; // 导出所有状态和提交逻辑 export function useDailyMonitoring() { // ... 从 index.tsx 提取所有 state + handleSubmit + 校验逻辑 }原
index.tsx简化为:export default function DailyMonitoring() { const modeClass = useElderClass(); const { /* 解构所有 state 和 handlers */ } = useDailyMonitoring(); return ( <PageShell> {/* 纯 UI 渲染 */} </PageShell> ); }预期行数: hook ~180 行, 页面 ~120 行
-
Step 2: 拆分 request.ts — 提取辅助类
新增文件:
apps/miniprogram/src/services/request/cache.ts// 提取 ResponseCache 类(~65 行) export class ResponseCache { /* ... */ }新增文件:
apps/miniprogram/src/services/request/limiter.ts// 提取 ConcurrencyLimiter 类(~25 行) export class ConcurrencyLimiter { /* ... */ }request.ts改为导入:import { ConcurrencyLimiter } from './request/limiter'; import { ResponseCache } from './request/cache';预期行数: request.ts ~200 行, cache.ts ~65 行, limiter.ts ~25 行
-
Step 3: 拆分 BLEManager — 提取连接管理
新增文件:
apps/miniprogram/src/services/ble/BLEConnection.ts// 提取连接/断开/服务发现逻辑(~120 行) export class BLEConnection { /* ... */ }BLEManager.ts保留扫描/同步调度,委托连接给 BLEConnection。预期行数: BLEManager.ts ~180 行, BLEConnection.ts ~120 行
-
Step 4: 拆分其余文件
采用类似策略:
input/index.tsx— 提取BloodPressureInputGroup子组件appointment/create/index.tsx— 提取TimeSlotPicker子组件device-sync/index.tsx— 提取DeviceScanPanel子组件
-
Step 5: 全量验证
cd apps/miniprogram && npx tsc --noEmit # 确认所有文件 <= 250 行 find src/ -name "*.tsx" -o -name "*.ts" | xargs wc -l | sort -rn | head -10
E3-3: 构建优化(1d)
目标: 主包体积减少 >=15%。
TDD 步骤
-
Step 1: 安装分析工具
cd apps/miniprogram && npm install --save-dev webpack-bundle-analyzer -
Step 2: 添加 sideEffects 标记
文件:
apps/miniprogram/package.json{ "sideEffects": [ "*.scss", "*.css", "src/app.tsx", "src/app.config.ts" ] }注意: 不能设为
false,因为 SCSS 文件有副作用(样式注入),app.tsx有全局初始化。用数组列出有副作用的文件。 -
Step 3: 配置 splitChunks
文件:
apps/miniprogram/config/index.ts在
mini配置中修改:mini: { // ... 现有配置 // 优化 webpack splitChunks webpackChain(chain) { chain.optimization.splitChunks({ chunks: 'all', maxInitialRequests: Infinity, minSize: 20000, cacheGroups: { // React 家族 react: { test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/, name: 'react-vendor', priority: 10, }, // Zustand zustand: { test: /[\\/]node_modules[\\/]zustand[\\/]/, name: 'zustand-vendor', priority: 10, }, // Taro 运行时 taro: { test: /[\\/]node_modules[\\/]@tarojs[\\/]/, name: 'taro-vendor', priority: 8, }, // 其他 node_modules vendors: { test: /[\\/]node_modules[\\/]/, name: 'vendors', priority: 1, reuseExistingChunk: true, }, // 公共业务代码 common: { minChunks: 2, name: 'common', priority: 5, reuseExistingChunk: true, }, }, }); }, }, -
Step 4: 添加分析脚本
文件:
apps/miniprogram/package.json{ "scripts": { "analyze": "cross-env ANALYZE=1 npm run build:weapp", "analyze:h5": "cross-env ANALYZE=1 npm run build:h5" } }安装:
cd apps/miniprogram && npm install --save-dev cross-env在
config/index.ts中添加分析插件:const isAnalyze = process.env.ANALYZE === '1'; // 在 webpackChain 中: if (isAnalyze) { const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); chain.plugin('bundle-analyzer').use(BundleAnalyzerPlugin, [{ analyzerMode: 'static', reportFilename: 'bundle-report.html', openAnalyzer: false, }]); } -
Step 5: 构建验证
cd apps/miniprogram && npm run build:weapp # 记录优化前后主包体积对比 du -sh dist/运行分析:
cd apps/miniprogram && npm run analyze # 打开 dist/bundle-report.html 查看产物分析
E3-4: CI 集成(1d)
目标: PR 触发自动检查:TypeScript 编译 + ESLint + 单元测试。
TDD 步骤
-
Step 1: 创建 Gitea CI 配置
新增文件:
apps/miniprogram/.gitea/workflows/ci.ymlname: Miniprogram CI on: push: branches: [main, develop] paths: - 'apps/miniprogram/**' pull_request: branches: [main, develop] paths: - 'apps/miniprogram/**' jobs: check: runs-on: ubuntu-latest defaults: run: working-directory: apps/miniprogram steps: - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' cache-dependency-path: apps/miniprogram/package-lock.json - name: Install dependencies run: npm ci - name: Generate tokens run: npm run generate-tokens - name: TypeScript check run: npx tsc --noEmit - name: ESLint check run: npx eslint src/ --max-warnings 0 - name: Unit tests run: npx vitest run - name: Build check run: npm run build:weapp env: TARO_APP_API_URL: http://localhost:3000/api/v1 TARO_APP_ENCRYPTION_KEY: ci-test-key-do-not-use-in-production注意: Gitea CI 使用
actsrunner,语法与 GitHub Actions 高度兼容。 -
Step 2: 添加根目录 CI(可选)
如果项目根目录已有 CI 配置,在根
.gitea/workflows/中添加小程序检查步骤,或在apps/miniprogram/中独立配置。根级配置参考(如果需要协调多模块):
# .gitea/workflows/miniprogram.yml name: Miniprogram Pipeline on: push: paths: - 'apps/miniprogram/**' - 'crates/erp-health/**' # 后端 DTO 变更也需要检查前端 pull_request: paths: - 'apps/miniprogram/**' jobs: miniprogram-ci: uses: ./.gitea/workflows/miniprogram-reusable.yml -
Step 3: 验证 CI 配置
推送测试:
git add apps/miniprogram/.gitea/ git commit -m "chore(web): add miniprogram CI pipeline" git push # 在 Gitea Web UI 中检查 CI 触发和执行结果
U3-1: 医生端导航状态保持(1d)
目标: 高频页面(患者列表/详情/咨询列表)Tab 切换后恢复搜索/滚动位置/表单草稿。
TDD 步骤
-
Step 1: 创建 useNavigationState hook
新增文件:
apps/miniprogram/src/hooks/useNavigationState.tsimport { useCallback, useRef } from 'react'; import Taro from '@tarojs/taro'; interface NavigationState { scrollTop?: number; searchText?: string; filters?: Record<string, string>; formData?: Record<string, string>; } const STORAGE_PREFIX = 'nav_state_'; const MAX_STATES = 20; // 最多保存 20 个页面状态 /** * 导航状态持久化 hook。 * 页面隐藏时自动保存状态,恢复时自动读取。 * 适用于医生端高频页面的状态保持。 */ export function useNavigationState(pageKey: string) { const stateRef = useRef<NavigationState>({}); const storageKey = `${STORAGE_PREFIX}${pageKey}`; /** 保存当前状态到 Storage */ const saveState = useCallback( (state: NavigationState) => { stateRef.current = state; try { Taro.setStorageSync(storageKey, JSON.stringify(state)); } catch { /* best-effort */ } }, [storageKey], ); /** 读取之前保存的状态 */ const restoreState = useCallback((): NavigationState | null => { try { const raw = Taro.getStorageSync(storageKey); if (raw && typeof raw === 'string') { const state = JSON.parse(raw) as NavigationState; stateRef.current = state; return state; } } catch { /* ignore */ } return null; }, [storageKey]); /** 清除保存的状态 */ const clearState = useCallback(() => { stateRef.current = {}; try { Taro.removeStorageSync(storageKey); } catch { /* ignore */ } }, [storageKey]); return { saveState, restoreState, clearState, stateRef }; } -
Step 2: 集成到医生端患者列表页
文件:
apps/miniprogram/src/pages/pkg-doctor-core/patients/index.tsximport { useNavigationState } from '@/hooks/useNavigationState'; export default function DoctorPatients() { const { saveState, restoreState } = useNavigationState('doctor-patients'); const [searchText, setSearchText] = useState(''); const [scrollTop, setScrollTop] = useState(0); // 页面加载时恢复状态 usePageData(() => { const saved = restoreState(); if (saved) { if (saved.searchText) setSearchText(saved.searchText); if (saved.scrollTop) setScrollTop(saved.scrollTop); } }); // 页面隐藏时保存状态 useDidHide(() => { saveState({ searchText, scrollTop }); }); // ... } -
Step 3: 集成到医生端咨询列表、随访列表
同 Step 2 模式,为以下页面添加状态保持:
pkg-doctor-core/consultation/index.tsx— 搜索 + 状态筛选pkg-doctor-core/followup/index.tsx— 搜索 + 日期筛选pkg-doctor-core/action-inbox/index.tsx— 滚动位置
-
Step 4: 编译验证
cd apps/miniprogram && npx tsc --noEmit
U3-2: 微交互统一(0.5d)
目标: 统一触觉反馈、动画时序、加载到内容的 fade-in 过渡。
TDD 步骤
-
Step 1: 创建触觉反馈工具
新增文件:
apps/miniprogram/src/utils/haptic.tsimport Taro from '@tarojs/taro'; /** 轻触反馈(按钮点击) */ export function hapticLight(): void { try { Taro.vibrateShort({ type: 'light' }); } catch { /* 部分设备不支持 */ } } /** 中等反馈(成功操作) */ export function hapticMedium(): void { try { Taro.vibrateShort({ type: 'medium' }); } catch { /* ignore */ } } /** 重度反馈(错误/警告) */ export function hapticHeavy(): void { try { Taro.vibrateShort({ type: 'heavy' }); } catch { /* ignore */ } } -
Step 2: 统一动画时序常量
在
apps/miniprogram/src/styles/tokens.scss中添加:page { // 动画时序 Token --tk-duration-fast: 150ms; --tk-duration-normal: 200ms; --tk-duration-slow: 300ms; --tk-easing: cubic-bezier(0.16, 1, 0.3, 1); // ease-out-expo --tk-easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); }在
token-values.ts中自动生成对应常量。 -
Step 3: 添加 fade-in 过渡工具类
在
apps/miniprogram/src/styles/mixins.scss中添加:// 加载完成后的 fade-in 过渡 @mixin fade-in($duration: 200ms) { opacity: 0; transition: opacity $duration cubic-bezier(0.16, 1, 0.3, 1); &--visible { opacity: 1; } }在
apps/miniprogram/src/styles/tokens.scss中添加工具类:.fade-in-enter { opacity: 0; } .fade-in-enter-active { opacity: 1; transition: opacity var(--tk-duration-normal) var(--tk-easing); } -
Step 4: 在关键交互点添加触觉反馈
修改
PrimaryButton:// src/components/ui/PrimaryButton/index.tsx import { hapticLight } from '@/utils/haptic'; // onClick handler 中 const handleClick = (e) => { hapticLight(); onClick?.(e); };修改成功/失败操作:
// 录入成功 import { hapticMedium } from '@/utils/haptic'; hapticMedium(); Taro.showToast({ title: '录入成功', icon: 'success' }); -
Step 5: 编译验证
cd apps/miniprogram && npx tsc --noEmit npm run generate-tokens # 重新生成包含动画时序的 token
Phase 3 验收清单
grep -rn ": any" apps/miniprogram/src/ --include="*.ts" --include="*.tsx"返回 0 结果- 所有源文件 <= 250 行(
wc -l验证) - 主包体积相比 Phase 2 结束时减少 >=15%
.gitea/workflows/ci.yml存在且可触发- API 请求头包含
X-Signature/X-Timestamp/X-Nonce(抓包验证) - 医生端 Tab 切换后搜索文本保持(手动验证)
- 按钮点击有触觉反馈(真机测试)
全局依赖关系
E2-1 (Token 生成) ──────┐
├── U2-1 (TrendChart 适老)
│
S2-1 (Analytics PII) ──── 独立
S2-2 (阈值加密) ───────── 独立
S2-3 (Patient DTO) ────── 独立(后端先完成)
U2-2 (表单跳焦) ───────── 独立
U2-3 (Loading 统一) ───── 独立
S3-1 (请求签名) ───────── 依赖后端 signing_key
E3-1 (any 清零) ───────── 独立
E3-2 (大文件拆分) ──────── 独立
E3-3 (构建优化) ───────── 依赖 E3-2 完成
E3-4 (CI) ─────────────── 依赖 E3-1 + E3-3
U3-1 (导航状态) ───────── 独立
U3-2 (微交互) ─────────── 独立
可并行任务分组
| 并行组 | 任务 | 预计耗时 |
|---|---|---|
| Group A(安全) | S2-1 + S2-2 + S2-3 + S3-1 | 6.5d |
| Group B(工程) | E2-1 + E3-1 + E3-2 + E3-3 + E3-4 | 5d |
| Group C(UX) | U2-1 + U2-2 + U2-3 + U3-1 + U3-2 | 5.5d |
如果 3 组并行执行,日历时间约 7d(取最长路径 Group A)。
关键风险与缓解
| 风险 | 影响 | 缓解 |
|---|---|---|
| PatientSummary 后端端点延期 | S2-3 阻塞 | 前端先使用 mock 数据开发,后端就绪后切换 |
| crypto.subtle 在小程序不可用 | S3-1 签名计算失败 | 使用 @noble/hashes 纯 JS fallback |
| Gitea CI runner 未配置 | E3-4 无法验证 | 先在本地模拟 CI 步骤 |
| 大文件拆分引入回归 | 功能异常 | 拆分后逐页面手动验证 |
| 构建优化导致分包异常 | 部分页面白屏 | splitChunks 配置渐进式调整 |