# 小程序 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)](#phase-2加密替换--canvas-适老11d) - [Phase 3:全面提升 + CI(7d)](#phase-3全面提升--ci7d) --- ## 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` ```typescript // 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 { const vars: Record = {}; 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'); }); }); ``` 运行命令: ```bash cd apps/miniprogram && npx vitest run scripts/__tests__/generate-tokens.test.ts ``` 预期: 测试失败(文件不存在) - [ ] **Step 2: 创建脚本文件(GREEN)** 创建 `apps/miniprogram/scripts/generate-tokens.ts`,内容见 Step 1。 运行命令: ```bash cd apps/miniprogram && npx vitest run scripts/__tests__/generate-tokens.test.ts ``` 预期: 测试通过 - [ ] **Step 3: 执行脚本生成 token-values.ts** 运行命令: ```bash cd apps/miniprogram && npx tsx scripts/generate-tokens.ts ``` 预期: 生成 `src/styles/token-values.ts`,包含约 40 个 token + elder 覆盖 + doctor 覆盖 验证: ```bash # 确认生成的文件存在且非空 test -s apps/miniprogram/src/styles/token-values.ts && echo "OK" ``` - [ ] **Step 4: 添加 npm script 到 package.json** 文件: `apps/miniprogram/package.json` 在 `scripts` 中新增: ```json { "scripts": { "generate-tokens": "tsx scripts/generate-tokens.ts", "prebuild:weapp": "npm run generate-tokens", "predev:weapp": "npm run generate-tokens" } } ``` 运行命令: ```bash cd apps/miniprogram && npm run generate-tokens ``` - [ ] **Step 5: 创建 useCanvasTokens hook** 文件: `apps/miniprogram/src/hooks/useCanvasTokens.ts` ```typescript import { 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]); } ``` 验证: ```bash 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.ts` ```typescript import { 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> }; 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; 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'); }); }); ``` 运行命令: ```bash 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` 改动点: 1. 移除 `AnalyticsEvent` 接口中的 `userId` 和 `patientId` 字段 2. 添加 `sanitizeProperties` 运行时过滤函数 3. `trackEvent` 调用时自动清理 properties ```typescript // 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, ): Record | undefined { if (!properties) return undefined; const cleaned: Record = {}; 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; timestamp: number; // 注意:不再包含 userId 和 patientId } export function trackEvent( event: EventName | string, properties?: Record, ): void { loadQueue(); const evt: AnalyticsEvent = { event, properties: sanitizeProperties(properties), timestamp: Date.now(), }; // ... 其余不变 } ``` 运行命令: ```bash cd apps/miniprogram && npx vitest run src/services/__tests__/analytics-pii.test.ts ``` 预期: 测试通过 - [ ] **Step 3: 清理 Storage 中已有的旧格式队列** 在 `migrateLegacyStorage` 调用点(`app.tsx` onLaunch)添加队列清理: ```typescript // 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) => evt.event) // 保留有效事件 .map((evt: Record) => { delete evt.userId; delete evt.patientId; return evt; }); Taro.setStorageSync('analytics_queue', cleaned); } } catch { /* best-effort */ } ``` - [ ] **Step 4: 编译验证** 运行命令: ```bash 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.ts` ```typescript import { 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`/`secureSet` ```typescript import { secureGet, secureSet } from '@/utils/secure-storage'; // ... 在 getHealthThresholds 函数中: export async function getHealthThresholds(): Promise { // 尝试从加密缓存读取 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('/health/critical-value-thresholds/public'); // 使用加密存储写入 secureSet(THRESHOLD_CACHE_KEY, JSON.stringify({ data, ts: Date.now() })); return data; } catch (err) { console.warn('[health] 数据加载失败:', err); return []; } } ``` 运行命令: ```bash 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`。旧数据会在自然过期后被忽略,无需手动清理。 编译验证: ```bash 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.rs` ```rust use 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, pub birth_date: Option, pub relation: Option, pub status: Option, // 注意:不包含 id_number, phone, phone_hash, id_card_number 等敏感字段 } ``` 在 `crates/erp-health/src/handler/patient_handler.rs` 新增: ```rust /// GET /health/patients/summary — 列表用摘要(字段最小化) pub async fn list_patient_summaries( State(state): State, Extension(claims): Extension, Query(params): Query, ) -> Result>>, 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` 新增: ```rust pub async fn list_summaries( db: &DatabaseConnection, tenant_id: &str, user_id: &str, page: u64, page_size: u64, ) -> Result<(Vec, 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`): ```rust // 在 patient 路由组中新增 .route("/patients/summary", get(patient_handler::list_patient_summaries)) ``` 后端测试: ```bash cd G:/hms && cargo test -p erp-health -- patient_summary ``` - [ ] **Step 2: 前端新增 PatientSummary 类型和 service(GREEN)** 文件: `apps/miniprogram/src/services/auth.ts` ```typescript // 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>('/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` ```typescript // 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` 中过滤敏感字段: ```typescript 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: 编译验证** ```bash 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` 核心改动: 1. 导入 `useCanvasTokens` 2. 替换所有硬编码字号 `'10px sans-serif'` → `tokens.yLabelFontSize + 'px sans-serif'` 3. 替换硬编码颜色 → token 颜色 4. 关怀模式异常点半径放大到 `tokens.pointAbnormalRadius` 5. 参考区间带增加斜线纹理(hatch pattern) 6. tooltip 改为常驻显示(最后 N 个数据点) 改动要点(非完整文件,仅关键 diff): ```typescript 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,无需用户触摸。触摸时切换到对应数据点。 ```typescript // 初始化时显示最后一个数据点 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` ```scss .trend-tooltip { // 关怀模式增大 tooltip .elder-mode & { padding: 12px 16px; font-size: 16px; min-height: 44px; // 触摸目标 } } ``` - [ ] **Step 4: 验证** ```bash 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.ts` ```typescript import { 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 }; } ``` 注意:微信小程序 `` 不支持 imperative focus。替代方案是在 `` 上设置 `focus={focusedField === 'xxx'}` prop,通过状态控制。 - [ ] **Step 2: 修改体征录入页 — 血压字段跳焦** 文件: `apps/miniprogram/src/pages/pkg-health/input/index.tsx` 改动点: 1. 新增 `focusedField` state(默认 `'systolic'`) 2. 收缩压 `` 添加 `returnKeyType="next"` + `onConfirm={() => setFocusedField('diastolic')}` 3. 舒张压 `` 添加 `focus={focusedField === 'diastolic'}` + `returnKeyType="done"` + `onConfirm={() => setFocusedField(null)}` 4. 收缩压 `` 显示上次测量参考值 ```tsx const [focusedField, setFocusedField] = useState(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 }); // 血压输入区域修改 收缩压 {lastBp && 上次 {lastBp.systolic}} setSystolic(e.detail.value)} onConfirm={() => setFocusedField('diastolic')} /> // ... 中间分隔线 舒张压 {lastBp && 上次 {lastBp.diastolic}} setDiastolic(e.detail.value)} onConfirm={() => setFocusedField(null)} /> ``` - [ ] **Step 3: 日常监测页链式跳焦** 文件: `apps/miniprogram/src/pages/pkg-health/daily-monitoring/index.tsx` 类似改造:字段顺序为 晨间收缩压 → 晨间舒张压 → 晚间收缩压 → 晚间舒张压 → 体重 → 血糖 → 入量 → 出量 → 备注 → 提交 每个字段设置 `returnKeyType="next"` 和 `onConfirm` 跳转到下一个。 ```tsx const FIELD_ORDER = [ 'morningSystolic', 'morningDiastolic', 'eveningSystolic', 'eveningDiastolic', 'weight', 'bloodSugar', 'fluidIntake', 'urineOutput', 'notes', ]; const [focusedField, setFocusedField] = useState(null); // 每个 Input: setFocusedField('morningDiastolic')} // ... /> ``` - [ ] **Step 4: 添加历史参考样式** ```scss .input-field-ref { color: var(--tk-text-secondary); font-size: var(--tk-font-cap); margin-left: 8px; } ``` - [ ] **Step 5: 编译验证** ```bash cd apps/miniprogram && npx tsc --noEmit ``` --- ### U2-3: Loading/骨架屏统一(0.5d) **目标:** 统一全项目 Loading 规范。 #### TDD 步骤 - [ ] **Step 1: 审计现有 Loading 用法** 运行命令: ```bash cd G:/hms && grep -rn "Loading\|loading" apps/miniprogram/src/pages/ --include="*.tsx" | grep -i "import.*Loading\|` | `'card'` | | 列表页(紧凑列表) | `` | `'list'` | | 详情页加载 | `` | `'detail'` | | 操作反馈(提交中) | ``(旋转器) | 默认 | 修改所有页面中直接使用 `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: 验证** ```bash 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.ts` ```typescript import { 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); }); }); ``` 运行命令: ```bash cd apps/miniprogram && npx vitest run src/utils/__tests__/request-signer.test.ts ``` 预期: 失败 - [ ] **Step 2: 实现请求签名工具(GREEN)** 文件: `apps/miniprogram/src/utils/request-signer.ts` ```typescript import { 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 { // 使用 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> { 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, }; } ``` 运行命令: ```bash 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 实现: ```typescript 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` 改动点: 1. 在 `getHeaders()` 中加入签名头 2. `signingKey` 从登录响应获取,仅存内存 ```typescript 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> { if (Date.now() - headersCacheTs > HEADERS_CACHE_TTL) { refreshHeadersCache(); } const headers: Record = { '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(method: string, path: string, data?: unknown, timeout?: number, signal?: AbortSignal, bypassLimiter = false): Promise { // ... 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 下发 + 校验中间件** 后端改动(简述,具体实现由后端工程师负责): 1. `login` / `refresh` 响应新增 `signing_key` 字段(256-bit 随机,与 access_token 同生命周期) 2. 新增 `SignatureMiddleware`: - 从 `X-Signature`/`X-Timestamp`/`X-Nonce` 头获取签名参数 - 用该用户的 `signing_key` 重新计算 HMAC-SHA256 - 验证签名匹配 + timestamp 偏移 <= 300s - 验证通过则放行,否则返回 401 文件: `crates/erp-server/src/middleware/signature.rs`(新增) 前端在 `auth.ts` 登录成功后保存 `signingKey`: ```typescript // auth.ts credentialLogin / wechatLogin 成功后 if (resp.signing_key) { setSigningKey(resp.signing_key); } ``` `logout` 中清理: ```typescript clearSigningKey(); ``` - [ ] **Step 5: 编译验证** ```bash cd G:/hms && cargo check && cargo test cd apps/miniprogram && npx tsc --noEmit ``` --- ### E3-1: 消灭所有 any 类型(1d) **目标:** 将所有 `: any` 替换为具体类型或 `unknown`。 #### TDD 步骤 - [ ] **Step 1: 审计所有 any 使用** 运行命令: ```bash 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 any` | 1 | 使用 Taro `RequestMethod` 类型 | | `Taro.requestSubscribeMessage as any` | 2 | 类型声明文件 | | `__wxConfig as any` | 1 | 类型声明文件 | | 其他 `as any` | ~16 | 逐个分析替换 | - [ ] **Step 2: 修复 catch (err: any) → catch (err: unknown)** 全局替换: ```bash # 列出所有 catch (err: any) 或 catch (e: any) grep -rn "catch.*(e\|err): any" apps/miniprogram/src/ --include="*.ts" --include="*.tsx" -l ``` 每个文件修改: ```typescript // 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` 新增: ```typescript /** 微信 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`: ```typescript 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` ```typescript // 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` 中声明: ```typescript declare module '@tarojs/taro' { type RequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS'; } ``` - [ ] **Step 5: 处理其他 as any** 创建 `apps/miniprogram/src/types/wx.d.ts`: ```typescript 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; } } ``` 修改引用: ```typescript // 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: 全量验证** ```bash 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.ts` ```typescript import { 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` 简化为: ```typescript export default function DailyMonitoring() { const modeClass = useElderClass(); const { /* 解构所有 state 和 handlers */ } = useDailyMonitoring(); return ( {/* 纯 UI 渲染 */} ); } ``` 预期行数: hook ~180 行, 页面 ~120 行 - [ ] **Step 2: 拆分 request.ts — 提取辅助类** 新增文件: `apps/miniprogram/src/services/request/cache.ts` ```typescript // 提取 ResponseCache 类(~65 行) export class ResponseCache { /* ... */ } ``` 新增文件: `apps/miniprogram/src/services/request/limiter.ts` ```typescript // 提取 ConcurrencyLimiter 类(~25 行) export class ConcurrencyLimiter { /* ... */ } ``` `request.ts` 改为导入: ```typescript 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` ```typescript // 提取连接/断开/服务发现逻辑(~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: 全量验证** ```bash 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: 安装分析工具** ```bash cd apps/miniprogram && npm install --save-dev webpack-bundle-analyzer ``` - [ ] **Step 2: 添加 sideEffects 标记** 文件: `apps/miniprogram/package.json` ```json { "sideEffects": [ "*.scss", "*.css", "src/app.tsx", "src/app.config.ts" ] } ``` 注意: 不能设为 `false`,因为 SCSS 文件有副作用(样式注入),`app.tsx` 有全局初始化。用数组列出有副作用的文件。 - [ ] **Step 3: 配置 splitChunks** 文件: `apps/miniprogram/config/index.ts` 在 `mini` 配置中修改: ```typescript 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` ```json { "scripts": { "analyze": "cross-env ANALYZE=1 npm run build:weapp", "analyze:h5": "cross-env ANALYZE=1 npm run build:h5" } } ``` 安装: ```bash cd apps/miniprogram && npm install --save-dev cross-env ``` 在 `config/index.ts` 中添加分析插件: ```typescript 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: 构建验证** ```bash cd apps/miniprogram && npm run build:weapp # 记录优化前后主包体积对比 du -sh dist/ ``` 运行分析: ```bash 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.yml` ```yaml name: 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 使用 `acts` runner,语法与 GitHub Actions 高度兼容。 - [ ] **Step 2: 添加根目录 CI(可选)** 如果项目根目录已有 CI 配置,在根 `.gitea/workflows/` 中添加小程序检查步骤,或在 `apps/miniprogram/` 中独立配置。 根级配置参考(如果需要协调多模块): ```yaml # .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 配置** 推送测试: ```bash 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.ts` ```typescript import { useCallback, useRef } from 'react'; import Taro from '@tarojs/taro'; interface NavigationState { scrollTop?: number; searchText?: string; filters?: Record; formData?: Record; } const STORAGE_PREFIX = 'nav_state_'; const MAX_STATES = 20; // 最多保存 20 个页面状态 /** * 导航状态持久化 hook。 * 页面隐藏时自动保存状态,恢复时自动读取。 * 适用于医生端高频页面的状态保持。 */ export function useNavigationState(pageKey: string) { const stateRef = useRef({}); 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.tsx` ```typescript import { 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: 编译验证** ```bash cd apps/miniprogram && npx tsc --noEmit ``` --- ### U3-2: 微交互统一(0.5d) **目标:** 统一触觉反馈、动画时序、加载到内容的 fade-in 过渡。 #### TDD 步骤 - [ ] **Step 1: 创建触觉反馈工具** 新增文件: `apps/miniprogram/src/utils/haptic.ts` ```typescript import 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` 中添加: ```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` 中添加: ```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` 中添加工具类: ```scss .fade-in-enter { opacity: 0; } .fade-in-enter-active { opacity: 1; transition: opacity var(--tk-duration-normal) var(--tk-easing); } ``` - [ ] **Step 4: 在关键交互点添加触觉反馈** 修改 `PrimaryButton`: ```typescript // src/components/ui/PrimaryButton/index.tsx import { hapticLight } from '@/utils/haptic'; // onClick handler 中 const handleClick = (e) => { hapticLight(); onClick?.(e); }; ``` 修改成功/失败操作: ```typescript // 录入成功 import { hapticMedium } from '@/utils/haptic'; hapticMedium(); Taro.showToast({ title: '录入成功', icon: 'success' }); ``` - [ ] **Step 5: 编译验证** ```bash 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 配置渐进式调整 |