diff --git a/docs/superpowers/plans/2026-05-21-miniprogram-phase2-phase3-plan.md b/docs/superpowers/plans/2026-05-21-miniprogram-phase2-phase3-plan.md new file mode 100644 index 0000000..ef9d533 --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-miniprogram-phase2-phase3-plan.md @@ -0,0 +1,2379 @@ +# 小程序 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 配置渐进式调整 |