Files
hms/docs/superpowers/plans/2026-05-21-miniprogram-phase2-phase3-plan.md
iven 408527375f docs(mp): Phase 2+3 实施计划 — Canvas 适老 + 全面提升 + CI(14 Tasks)
Phase 2: Token 常量生成 + Analytics PII 清理 + 阈值加密 + DTO 最小化 + Canvas 适老 + 表单跳焦
Phase 3: API 签名 + any 清零 + 大文件拆分 + 构建优化 + CI + 导航状态 + 微交互
2026-05-21 23:47:16 +08:00

68 KiB
Raw Blame History

小程序 Phase 2-3 实施计划

日期: 2026-05-21 | 设计规格: docs/superpowers/specs/2026-05-21-miniprogram-comprehensive-improvement-design.md §6-7 前置: Phase 0 + Phase 1 已完成AES-GCM 加密、TypeScript strict、ESLint、secure-storage 测试)

目录


Phase 2加密替换 + Canvas 适老11d

文件结构表

新增/修改 文件路径 类型
新增 apps/miniprogram/scripts/generate-tokens.ts Token 生成脚本
新增 apps/miniprogram/src/styles/token-values.ts 自动生成,勿手动编辑
新增 apps/miniprogram/src/hooks/useCanvasTokens.ts Canvas Token hook
新增 apps/miniprogram/src/hooks/useAutoFocus.ts 表单跳焦 hook
修改 apps/miniprogram/src/services/analytics.ts PII 清理
修改 apps/miniprogram/src/services/health.ts 阈值缓存加密
修改 apps/miniprogram/src/services/auth.ts PatientDTO 最小化
修改 apps/miniprogram/src/services/patient.ts PatientSummary 类型
修改 apps/miniprogram/src/stores/auth.ts summary 端点调用
修改 apps/miniprogram/src/components/TrendChart/index.tsx Canvas 适老
修改 apps/miniprogram/src/components/TrendChart/index.scss 适老样式
修改 apps/miniprogram/src/pages/pkg-health/input/index.tsx 跳焦
修改 apps/miniprogram/src/pages/pkg-health/daily-monitoring/index.tsx 跳焦
修改 apps/miniprogram/package.json 脚本入口

E2-1: Token 常量生成脚本1d

目标:tokens.scss 构建时生成 token-values.ts,供 Canvas 和 JS 运行时使用。微信小程序无 getComputedStyle/document,必须用构建时生成方案。

TDD 步骤

  • Step 1: 编写脚本骨架 + 单元测试RED

    文件: apps/miniprogram/scripts/generate-tokens.ts

    // scripts/generate-tokens.ts
    // 从 src/styles/tokens.scss + variables.scss 解析 CSS 变量,
    // 输出 src/styles/token-values.ts 供 JS 运行时使用
    import { readFileSync, writeFileSync } from 'fs';
    import { resolve, dirname } from 'path';
    import { fileURLToPath } from 'url';
    
    const __dirname = dirname(fileURLToPath(import.meta.url));
    
    interface TokenMap {
      [key: string]: string;
    }
    
    // 解析 SCSS 文件中的 CSS 变量声明
    function parseTokensFromScss(
      scssContent: string,
      selector: string = 'page',
    ): TokenMap {
      const tokens: TokenMap = {};
      // 匹配选择器块内的 --tk-* 变量
      const blockRegex = new RegExp(
        `${selector.replace('.', '\\.')}\\s*\\{([^}]+)}`,
        's',
      );
      const blockMatch = scssContent.match(blockRegex);
      if (!blockMatch) return tokens;
    
      const varRegex = /--tk-([\w-]+)\s*:\s*([^;]+);/g;
      let match: RegExpExecArray | null;
      while ((match = varRegex.exec(blockMatch[1])) !== null) {
        // 跳过 SCSS 变量引用(如 #{$pri}),仅保留已解析的值
        const value = match[2].trim();
        if (!value.includes('#{')) {
          tokens[match[1]] = value;
        }
      }
      return tokens;
    }
    
    // 解析 SCSS 变量(如 $pri: #C4623A用于替换 #{...} 引用
    function parseScssVariables(scssContent: string): Record<string, string> {
      const vars: Record<string, string> = {};
      const regex = /\$([\w-]+)\s*:\s*([^;]+);/g;
      let match: RegExpExecArray | null;
      while ((match = regex.exec(scssContent)) !== null) {
        vars[match[1]] = match[2].trim();
      }
      return vars;
    }
    
    // 解析 tokens.scss替换 SCSS 变量引用
    function resolveTokenValues(
      tokensContent: string,
      variablesContent: string,
      selector: string,
    ): TokenMap {
      const scssVars = parseScssVariables(variablesContent);
      const rawTokens = parseTokensFromScss(tokensContent, selector);
      const resolved: TokenMap = {};
      for (const [key, value] of Object.entries(rawTokens)) {
        // 替换 #{...} 引用
        let resolvedValue = value;
        resolvedValue = resolvedValue.replace(
          /#\{\$([\w-]+)\}/g,
          (_, varName) => scssVars[varName] || '',
        );
        resolved[key] = resolvedValue;
      }
      return resolved;
    }
    
    function generateTokenFile(
      tokensPath: string,
      variablesPath: string,
      outputPath: string,
    ): void {
      const tokensContent = readFileSync(tokensPath, 'utf-8');
      const variablesContent = readFileSync(variablesPath, 'utf-8');
    
      const normalTokens = resolveTokenValues(
        tokensContent,
        variablesContent,
        'page',
      );
      const elderTokens = resolveTokenValues(
        tokensContent,
        variablesContent,
        '.elder-mode',
      );
      const doctorTokens = resolveTokenValues(
        tokensContent,
        variablesContent,
        '.doctor-mode',
      );
    
      const output = `// Auto-generated by scripts/generate-tokens.ts — DO NOT EDIT
    

// Generated at: ${new Date().toISOString()}

export const TOKEN_VALUES = ${JSON.stringify(normalTokens, null, 2)} as const;

export const ELDER_TOKEN_OVERRIDES = ${JSON.stringify(elderTokens, null, 2)} as const;

export const DOCTOR_TOKEN_OVERRIDES = ${JSON.stringify(doctorTokens, null, 2)} as const;

// Canvas 专用:字号(数字,单位 px export const CANVAS_FONT_NORMAL = { yLabel: 10, xLabel: 10, tooltip: 12, pointNormal: 3, pointAbnormal: 5, } as const;

export const CANVAS_FONT_ELDER = { yLabel: 14, xLabel: 14, tooltip: 16, pointNormal: 5, pointAbnormal: 8, } as const; `;

writeFileSync(outputPath, output, 'utf-8');
console.log(`[generate-tokens] Generated ${outputPath}`);
console.log(
  `  Normal: ${Object.keys(normalTokens).length} tokens`,
);
console.log(
  `  Elder: ${Object.keys(elderTokens).length} overrides`,
);
console.log(
  `  Doctor: ${Object.keys(doctorTokens).length} overrides`,
);

}

// CLI 入口 if (process.argv[1]?.endsWith('generate-tokens.ts')) { const root = resolve(__dirname, '..'); generateTokenFile( resolve(root, 'src/styles/tokens.scss'), resolve(root, 'src/styles/variables.scss'), resolve(root, 'src/styles/token-values.ts'), ); }

export { parseTokensFromScss, parseScssVariables, resolveTokenValues, generateTokenFile };


测试文件: `apps/miniprogram/scripts/__tests__/generate-tokens.test.ts`

```typescript
import { describe, it, expect } from 'vitest';
import {
  parseTokensFromScss,
  parseScssVariables,
  resolveTokenValues,
} from '../generate-tokens';

describe('parseScssVariables', () => {
  it('extracts SCSS variable declarations', () => {
    const scss = '$pri: #C4623A;\n$bg: #F5F0EB;';
    const vars = parseScssVariables(scss);
    expect(vars).toEqual({ pri: '#C4623A', bg: '#F5F0EB' });
  });

  it('ignores comments and non-variable lines', () => {
    const scss = '// comment\n$pri: #C4623A;';
    const vars = parseScssVariables(scss);
    expect(vars).toEqual({ pri: '#C4623A' });
  });
});

describe('parseTokensFromScss', () => {
  it('extracts --tk-* CSS variables from page selector', () => {
    const scss = `page {
      --tk-font-h1: 28px;
      --tk-font-body: 16px;
    }`;
    const tokens = parseTokensFromScss(scss, 'page');
    expect(tokens).toEqual({ 'font-h1': '28px', 'font-body': '16px' });
  });

  it('extracts from class selector', () => {
    const scss = `.elder-mode {
      --tk-font-h1: 32px;
    }`;
    const tokens = parseTokensFromScss(scss, '.elder-mode');
    expect(tokens).toEqual({ 'font-h1': '32px' });
  });

  it('skips SCSS variable references', () => {
    const scss = `page {
      --tk-pri: #{$pri};
      --tk-font-body: 16px;
    }`;
    const tokens = parseTokensFromScss(scss, 'page');
    expect(tokens).toEqual({ 'font-body': '16px' });
    expect(tokens).not.toHaveProperty('pri');
  });
});

describe('resolveTokenValues', () => {
  it('replaces SCSS variable references with actual values', () => {
    const tokensScss = `page {
      --tk-pri: #{$pri};
    }`;
    const varsScss = '$pri: #C4623A;';
    const result = resolveTokenValues(tokensScss, varsScss, 'page');
    expect(result).toEqual({ pri: '#C4623A' });
  });

  it('handles multi-level variable references', () => {
    const tokensScss = `page {
      --tk-card-radius: 16px;
      --tk-font-body: 16px;
    }`;
    const varsScss = '$r: 16px;';
    const result = resolveTokenValues(tokensScss, varsScss, 'page');
    expect(result['card-radius']).toBe('16px');
    expect(result['font-body']).toBe('16px');
  });
});

运行命令:

cd apps/miniprogram && npx vitest run scripts/__tests__/generate-tokens.test.ts

预期: 测试失败(文件不存在)

  • Step 2: 创建脚本文件GREEN

    创建 apps/miniprogram/scripts/generate-tokens.ts,内容见 Step 1。

    运行命令:

    cd apps/miniprogram && npx vitest run scripts/__tests__/generate-tokens.test.ts
    

    预期: 测试通过

  • Step 3: 执行脚本生成 token-values.ts

    运行命令:

    cd apps/miniprogram && npx tsx scripts/generate-tokens.ts
    

    预期: 生成 src/styles/token-values.ts,包含约 40 个 token + elder 覆盖 + doctor 覆盖

    验证:

    # 确认生成的文件存在且非空
    test -s apps/miniprogram/src/styles/token-values.ts && echo "OK"
    
  • Step 4: 添加 npm script 到 package.json

    文件: apps/miniprogram/package.json

    scripts 中新增:

    {
      "scripts": {
        "generate-tokens": "tsx scripts/generate-tokens.ts",
        "prebuild:weapp": "npm run generate-tokens",
        "predev:weapp": "npm run generate-tokens"
      }
    }
    

    运行命令:

    cd apps/miniprogram && npm run generate-tokens
    
  • Step 5: 创建 useCanvasTokens hook

    文件: apps/miniprogram/src/hooks/useCanvasTokens.ts

    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]);
    }
    

    验证:

    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

    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<Record<string, unknown>> };
        const evt = body.events[0];
    
        // 事件级别不应有 userId/patientId
        expect(evt).not.toHaveProperty('userId');
        expect(evt).not.toHaveProperty('patientId');
    
        // properties 中不应有 PII 字段
        const props = evt.properties as Record<string, unknown>;
        expect(props).not.toHaveProperty('userId');
        expect(props).not.toHaveProperty('patientId');
        expect(props).not.toHaveProperty('user_name');
        expect(props).not.toHaveProperty('phone');
        expect(props).not.toHaveProperty('id_card');
    
        // 正常字段保留
        expect(props.page).toBe('health');
      });
    });
    

    运行命令:

    cd apps/miniprogram && npx vitest run src/services/__tests__/analytics-pii.test.ts
    

    预期: 测试失败analytics.ts 尚未改造)

  • Step 2: 修改 AnalyticsEvent 接口和 trackEventGREEN

    文件: apps/miniprogram/src/services/analytics.ts

    改动点:

    1. 移除 AnalyticsEvent 接口中的 userIdpatientId 字段
    2. 添加 sanitizeProperties 运行时过滤函数
    3. trackEvent 调用时自动清理 properties
    // PII 字段黑名单 — 运行时过滤
    const PII_KEYS = new Set([
      'userId', 'user_id', 'patientId', 'patient_id',
      'user_name', 'username', 'phone', 'mobile',
      'id_card', 'id_number', 'email', 'address',
      'openid', 'access_token', 'refresh_token',
    ]);
    
    /** 运行时过滤 properties 中的 PII 标识 */
    function sanitizeProperties(
      properties?: Record<string, unknown>,
    ): Record<string, unknown> | undefined {
      if (!properties) return undefined;
      const cleaned: Record<string, unknown> = {};
      for (const [key, value] of Object.entries(properties)) {
        if (!PII_KEYS.has(key)) {
          cleaned[key] = value;
        }
      }
      return Object.keys(cleaned).length > 0 ? cleaned : undefined;
    }
    
    interface AnalyticsEvent {
      event: string;
      properties?: Record<string, unknown>;
      timestamp: number;
      // 注意:不再包含 userId 和 patientId
    }
    
    export function trackEvent(
      event: EventName | string,
      properties?: Record<string, unknown>,
    ): void {
      loadQueue();
      const evt: AnalyticsEvent = {
        event,
        properties: sanitizeProperties(properties),
        timestamp: Date.now(),
      };
      // ... 其余不变
    }
    

    运行命令:

    cd apps/miniprogram && npx vitest run src/services/__tests__/analytics-pii.test.ts
    

    预期: 测试通过

  • Step 3: 清理 Storage 中已有的旧格式队列

    migrateLegacyStorage 调用点(app.tsx onLaunch添加队列清理:

    // apps/miniprogram/src/app.tsx — onLaunch 中
    // 清理旧格式 analytics 队列(含 PII 的旧事件)
    try {
      const raw = Taro.getStorageSync('analytics_queue');
      if (raw && Array.isArray(raw)) {
        const cleaned = raw
          .filter((evt: Record<string, unknown>) => evt.event) // 保留有效事件
          .map((evt: Record<string, unknown>) => {
            delete evt.userId;
            delete evt.patientId;
            return evt;
          });
        Taro.setStorageSync('analytics_queue', cleaned);
      }
    } catch { /* best-effort */ }
    
  • Step 4: 编译验证

    运行命令:

    cd apps/miniprogram && npx tsc --noEmit
    cd G:/hms && cargo check
    

S2-2: 健康阈值缓存加密1d

目标: 复用 Phase 0 的 secureSet/secureGetAES-GCM替换 getHealthThresholds 中的明文 Storage 调用。

TDD 步骤

  • Step 1: 编写加密缓存测试RED

    文件: apps/miniprogram/src/services/__tests__/health-threshold-encrypt.test.ts

    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

    import { secureGet, secureSet } from '@/utils/secure-storage';
    
    // ... 在 getHealthThresholds 函数中:
    
    export async function getHealthThresholds(): Promise<HealthThreshold[]> {
      // 尝试从加密缓存读取
      try {
        const cachedRaw = secureGet(THRESHOLD_CACHE_KEY);
        if (cachedRaw) {
          const cached = JSON.parse(cachedRaw) as { data: HealthThreshold[]; ts: number };
          if (cached && Date.now() - cached.ts < THRESHOLD_TTL) {
            return cached.data;
          }
        }
      } catch { /* cache miss */ }
    
      try {
        const data = await api.get<HealthThreshold[]>('/health/critical-value-thresholds/public');
        // 使用加密存储写入
        secureSet(THRESHOLD_CACHE_KEY, JSON.stringify({ data, ts: Date.now() }));
        return data;
      } catch (err) {
        console.warn('[health] 数据加载失败:', err);
        return [];
      }
    }
    

    运行命令:

    cd apps/miniprogram && npx vitest run src/services/__tests__/health-threshold-encrypt.test.ts
    
  • Step 3: 修改 THRESHOLD_CACHE_KEY 前缀

    因为 secureSet 会添加 _es_ 前缀,旧明文缓存的 key 是 health_thresholds,新加密缓存的 Storage key 是 _es_health_thresholds。旧数据会在自然过期后被忽略,无需手动清理。

    编译验证:

    cd apps/miniprogram && npx tsc --noEmit
    

S2-3: Patient DTO 字段最小化2d

目标: 后端新增 PatientSummary DTO列表用前端调用 summary 端点,减少敏感字段传输和存储。

TDD 步骤

  • Step 1: 后端新增 PatientSummary DTO 和端点RED

    先检查后端已有的 patient handler 和 DTO 结构。

    新增文件: crates/erp-health/src/dto/patient_summary.rs

    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<String>,
        pub birth_date: Option<String>,
        pub relation: Option<String>,
        pub status: Option<String>,
        // 注意:不包含 id_number, phone, phone_hash, id_card_number 等敏感字段
    }
    

    crates/erp-health/src/handler/patient_handler.rs 新增:

    /// GET /health/patients/summary — 列表用摘要(字段最小化)
    pub async fn list_patient_summaries(
        State(state): State<AppState>,
        Extension(claims): Extension<Claims>,
        Query(params): Query<Pagination>,
    ) -> Result<Json<ApiResponse<PaginatedResponse<PatientSummary>>>, AppError> {
        let tenant_id = &claims.tenant_id;
        let user_id = &claims.user_id;
    
        let page = params.page.unwrap_or(1).max(1).min(100);
        let page_size = params.page_size.unwrap_or(20).max(1).min(100);
    
        let (patients, total) = PatientService::list_summaries(
            &state.db,
            tenant_id,
            user_id,
            page,
            page_size,
        ).await?;
    
        Ok(Json(ApiResponse::success(PaginatedResponse {
            data: patients,
            total,
            page,
            page_size,
        })))
    }
    

    crates/erp-health/src/service/patient_service.rs 新增:

    pub async fn list_summaries(
        db: &DatabaseConnection,
        tenant_id: &str,
        user_id: &str,
        page: u64,
        page_size: u64,
    ) -> Result<(Vec<PatientSummary>, i64), AppError> {
        // 查询关联患者的摘要信息
        // SELECT id, name, gender, birth_date, relation, status
        // FROM patients WHERE tenant_id = ? AND deleted_at IS NULL
        // ...
    }
    

    路由注册(crates/erp-health/src/module.rs:

    // 在 patient 路由组中新增
    .route("/patients/summary", get(patient_handler::list_patient_summaries))
    

    后端测试:

    cd G:/hms && cargo test -p erp-health -- patient_summary
    
  • Step 2: 前端新增 PatientSummary 类型和 serviceGREEN

    文件: apps/miniprogram/src/services/auth.ts

    // PatientInfo 保持不变(详情用)
    export interface PatientInfo {
      id: string;
      name: string;
      gender?: string;
      birth_date?: string;
      relation: string;
    }
    
    /** 患者摘要 — 列表用,字段最小化,不含敏感信息 */
    export interface PatientSummary {
      id: string;
      name: string;
      gender?: string;
      birth_date?: string;
      relation?: string;
      status?: string;
    }
    
    /** 获取患者摘要列表(字段最小化,替代 getPatients */
    export async function getPatientSummaries() {
      const res = await api.get<PaginatedData<PatientSummary>>('/health/patients/summary');
      return Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : []);
    }
    
  • Step 3: 修改 auth store 使用 summary 端点

    文件: apps/miniprogram/src/stores/auth.ts

    // loadPatients 改用 summary 端点
    loadPatients: async () => {
      try {
        const patients = await authApi.getPatientSummaries();
        // PatientSummary → PatientInfo 兼容映射
        const mapped: authApi.PatientInfo[] = patients.map((p) => ({
          id: p.id,
          name: p.name,
          gender: p.gender,
          birth_date: p.birth_date,
          relation: p.relation || 'self',
        }));
        set({ patients: mapped });
        if (mapped.length > 0 && !get().currentPatient) {
          get().setCurrentPatient(mapped[0]);
        }
      } catch (err) {
        console.warn('[auth] 患者列表加载失败:', err);
      }
    },
    
  • Step 4: 过滤 current_patient 存储字段

    setCurrentPatient 中过滤敏感字段:

    setCurrentPatient: (patient) => {
      // 仅保留非敏感字段到 Storage
      const safePatient: authApi.PatientInfo = {
        id: patient.id,
        name: patient.name,
        gender: patient.gender,
        birth_date: patient.birth_date,
        relation: patient.relation,
      };
      secureSet('current_patient_id', safePatient.id);
      secureSet('current_patient', JSON.stringify(safePatient));
      setCachedPatientId(safePatient.id);
      clearRequestCache();
      set({ currentPatient: safePatient });
    },
    
  • Step 5: 编译验证

    cd G:/hms && cargo check && cargo test -p erp-health
    cd apps/miniprogram && npx tsc --noEmit
    

U2-1: TrendChart Canvas 适老化2d

目标: 替换所有硬编码字号/颜色,关怀模式放大字号至 14-16px异常点放大至 7-8px参考区间带增加纹理tooltip 改常驻。

TDD 步骤

  • Step 1: 重构 TrendChart 使用 useCanvasTokensGREEN

    文件: 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:

    import { useCanvasTokens, type CanvasTokens } from '@/hooks/useCanvasTokens';
    
    export default React.memo(function TrendChart({
      data,
      referenceMin,
      referenceMax,
      unit = '',
      height = 500,
    }: TrendChartProps) {
      const tokens = useCanvasTokens();
      // ...
    
      const draw = useCallback(() => {
        // ... 获取 ctx 后
    
        // Reference band with hatch pattern for elder mode
        if (referenceMin != null && referenceMax != null) {
          const ry1 = toY(referenceMax);
          const ry2 = toY(referenceMin);
    
          // 底色填充
          ctx.fillStyle = tokens.referenceBandColor;
          ctx.fillRect(pad.left, ry1, cw, ry2 - ry1);
    
          // 关怀模式增加斜线纹理增强区分度
          if (tokens.yLabelFontSize >= 14) {
            ctx.save();
            ctx.strokeStyle = 'rgba(5,150,105,0.18)';
            ctx.lineWidth = 1;
            const step = 8;
            ctx.beginPath();
            for (let x = pad.left; x < pad.left + cw; x += step) {
              const x1 = Math.min(x, pad.left + cw);
              const x2 = Math.min(x + (ry2 - ry1), pad.left + cw);
              ctx.moveTo(x1, ry2);
              ctx.lineTo(x2, ry1);
            }
            ctx.stroke();
            ctx.restore();
          }
        }
    
        // Y-axis labels — 使用 token 字号
        ctx.fillStyle = tokens.tx2;
        ctx.font = `${tokens.yLabelFontSize}px sans-serif`;
        // ...
    
        // X-axis labels
        ctx.font = `${tokens.xLabelFontSize}px sans-serif`;
        // ...
    
        // Data points — 使用 token 半径
        for (let i = 0; i < data.length; i++) {
          const d = data[i];
          const isAbnormal = /* ... */;
          ctx.beginPath();
          ctx.arc(
            chartPoints[i].x,
            chartPoints[i].y,
            isAbnormal ? tokens.pointAbnormalRadius : tokens.pointNormalRadius,
            0,
            Math.PI * 2,
          );
          ctx.fillStyle = isAbnormal ? tokens.abnormalColor : tokens.lineColor;
          ctx.fill();
        }
      }, [data, referenceMin, referenceMax, tokens]);
    
      // ...
    });
    
  • Step 2: Tooltip 常驻显示(关怀模式)

    tokens.yLabelFontSize >= 14(关怀模式)时,默认显示最后一个数据点的 tooltip无需用户触摸。触摸时切换到对应数据点。

    // 初始化时显示最后一个数据点
    useEffect(() => {
      if (tokens.yLabelFontSize >= 14 && data && data.length > 0) {
        const lastIdx = data.length - 1;
        const w = /* canvas width */;
        const pad = { left: 45, right: 15 };
        const cw = w - pad.left - pad.right;
        setTooltip({
          date: data[lastIdx].date,
          value: data[lastIdx].value,
          x: pad.left + (lastIdx / Math.max(data.length - 1, 1)) * cw,
        });
      } else {
        setTooltip(null);
      }
    }, [data, tokens]);
    
  • Step 3: 修改 TrendChart SCSS 适老样式

    文件: apps/miniprogram/src/components/TrendChart/index.scss

    .trend-tooltip {
      // 关怀模式增大 tooltip
      .elder-mode & {
        padding: 12px 16px;
        font-size: 16px;
        min-height: 44px; // 触摸目标
      }
    }
    
  • Step 4: 验证

    cd apps/miniprogram && npx tsc --noEmit
    # 在微信开发者工具中预览 TrendChart分别检查
    # 1. 正常模式:字号 10px数据点半径 3/5
    # 2. 关怀模式:字号 14px数据点半径 5/8斜线纹理tooltip 常驻
    

U2-2: 表单自动跳焦 + 历史参考1.5d

目标: 血压录入页收缩压输入完自动跳到舒张压;日常监测页字段链式跳焦;显示上次测量值参考。

TDD 步骤

  • Step 1: 创建 useAutoFocus hook

    文件: apps/miniprogram/src/hooks/useAutoFocus.ts

    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 };
    }
    

    注意:微信小程序 <Input> 不支持 imperative focus。替代方案是在 <Input> 上设置 focus={focusedField === 'xxx'} prop通过状态控制。

  • Step 2: 修改体征录入页 — 血压字段跳焦

    文件: apps/miniprogram/src/pages/pkg-health/input/index.tsx

    改动点:

    1. 新增 focusedField state默认 'systolic'
    2. 收缩压 <Input> 添加 returnKeyType="next" + onConfirm={() => setFocusedField('diastolic')}
    3. 舒张压 <Input> 添加 focus={focusedField === 'diastolic'} + returnKeyType="done" + onConfirm={() => setFocusedField(null)}
    4. 收缩压 <Input> 显示上次测量参考值
    const [focusedField, setFocusedField] = useState<string | null>(null);
    const [lastBp, setLastBp] = useState<{ systolic: number; diastolic: number } | null>(null);
    
    // 加载上次血压记录
    usePageData(async () => {
      // ... 已有阈值加载逻辑
      try {
        const pid = currentPatient?.id;
        if (pid) {
          const res = await api.get<{ data: DailyMonitoring[] }>(
            `/health/patients/${pid}/daily-monitoring`,
            { page: 1, page_size: 1 },
          );
          const latest = res?.data?.[0];
          if (latest?.morning_bp_systolic && latest?.morning_bp_diastolic) {
            setLastBp({ systolic: latest.morning_bp_systolic, diastolic: latest.morning_bp_diastolic });
          }
        }
      } catch { /* 不影响主流程 */ }
    }, { throttleMs: 10000 });
    
    // 血压输入区域修改
    <View className='input-bp-field'>
      <Text className='input-field-label'>
        收缩压
        {lastBp && <Text className='input-field-ref'>上次 {lastBp.systolic}</Text>}
      </Text>
      <Input
        type='digit'
        className='input-field-box'
        placeholder='如 120'
        value={systolic}
        focus={focusedField === 'systolic'}
        returnKeyType='next'
        onInput={(e) => setSystolic(e.detail.value)}
        onConfirm={() => setFocusedField('diastolic')}
      />
    </View>
    // ... 中间分隔线
    <View className='input-bp-field'>
      <Text className='input-field-label'>
        舒张压
        {lastBp && <Text className='input-field-ref'>上次 {lastBp.diastolic}</Text>}
      </Text>
      <Input
        type='digit'
        className='input-field-box'
        placeholder='如 80'
        value={diastolic}
        focus={focusedField === 'diastolic'}
        returnKeyType='done'
        onInput={(e) => setDiastolic(e.detail.value)}
        onConfirm={() => setFocusedField(null)}
      />
    </View>
    
  • Step 3: 日常监测页链式跳焦

    文件: apps/miniprogram/src/pages/pkg-health/daily-monitoring/index.tsx

    类似改造:字段顺序为 晨间收缩压 → 晨间舒张压 → 晚间收缩压 → 晚间舒张压 → 体重 → 血糖 → 入量 → 出量 → 备注 → 提交

    每个字段设置 returnKeyType="next"onConfirm 跳转到下一个。

    const FIELD_ORDER = [
      'morningSystolic', 'morningDiastolic',
      'eveningSystolic', 'eveningDiastolic',
      'weight', 'bloodSugar',
      'fluidIntake', 'urineOutput',
      'notes',
    ];
    
    const [focusedField, setFocusedField] = useState<string | null>(null);
    
    // 每个 Input:
    <Input
      focus={focusedField === 'morningSystolic'}
      returnKeyType='next'
      onConfirm={() => setFocusedField('morningDiastolic')}
      // ...
    />
    
  • Step 4: 添加历史参考样式

    .input-field-ref {
      color: var(--tk-text-secondary);
      font-size: var(--tk-font-cap);
      margin-left: 8px;
    }
    
  • Step 5: 编译验证

    cd apps/miniprogram && npx tsc --noEmit
    

U2-3: Loading/骨架屏统一0.5d

目标: 统一全项目 Loading 规范。

TDD 步骤

  • Step 1: 审计现有 Loading 用法

    运行命令:

    cd G:/hms && grep -rn "Loading\|loading" apps/miniprogram/src/pages/ --include="*.tsx" | grep -i "import.*Loading\|<Loading" | head -30
    

    确认当前 Loading 组件使用情况,识别不一致的地方。

  • Step 2: 建立 Loading 规范文档并统一

    规范:

    场景 组件 layout 参数
    列表页初始加载 <LoadingCard layout="card" count={3} /> 'card'
    列表页(紧凑列表) <LoadingCard layout="list" count={5} /> 'list'
    详情页加载 <LoadingCard layout="detail" count={1} /> 'detail'
    操作反馈(提交中) <Loading />(旋转器) 默认

    修改所有页面中直接使用 loading && <Loading /> 但场景为列表/详情的地方,改为 LoadingCard

    修改文件清单(典型):

    • src/pages/health/index.tsx — 列表页 → LoadingCard layout="card"
    • src/pages/pkg-profile/reports/index.tsx — 列表 → LoadingCard layout="card"
    • src/pages/pkg-doctor-core/patients/index.tsx — 列表 → LoadingCard layout="list"
  • Step 3: 验证

    cd apps/miniprogram && npx tsc --noEmit
    

Phase 2 验收清单

  • grep -rn 'userId\|patientId' apps/miniprogram/src/services/analytics.ts 返回 0 匹配
  • secureGet('health_thresholds') 返回加密值(非明文 JSON
  • /health/patients/summary 端点返回 5-8 字段,不含 id_number/phone
  • TrendChart 关怀模式 Y 轴字号 >= 14px
  • useCanvasTokens() hook 存在且被 TrendChart 引用
  • 血压录入页收缩压 onConfirm 跳转到舒张压
  • npm run generate-tokens 成功执行,生成 token-values.ts

Phase 3全面提升 + CI7d

文件结构表

新增/修改 文件路径 类型
新增 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

    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);
      });
    });
    

    运行命令:

    cd apps/miniprogram && npx vitest run src/utils/__tests__/request-signer.test.ts
    

    预期: 失败

  • Step 2: 实现请求签名工具GREEN

    文件: apps/miniprogram/src/utils/request-signer.ts

    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 或 polyfillPhase 0 已配置)
      if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
        crypto.getRandomValues(arr);
      } else {
        for (let i = 0; i < arr.length; i++) {
          arr[i] = Math.floor(Math.random() * 256);
        }
      }
      return Array.from(arr)
        .map((b) => b.toString(16).padStart(2, '0'))
        .join('');
    }
    
    /**
     * 简单的 SHA-256 HMAC 实现(用于请求签名)
     * 依赖 Phase 0 安装的 @noble/hashes
     */
    async function hmacSha256(key: string, message: string): Promise<string> {
      // 使用 SubtleCrypto微信小程序基础库 2.17.3+ 支持)
      // 或 fallback 到纯 JS 实现
      const encoder = new TextEncoder();
      const keyData = encoder.encode(key);
      const msgData = encoder.encode(message);
    
      const cryptoKey = await crypto.subtle.importKey(
        'raw',
        keyData,
        { name: 'HMAC', hash: 'SHA-256' },
        false,
        ['sign'],
      );
    
      const signature = await crypto.subtle.sign('HMAC', cryptoKey, msgData);
      return Array.from(new Uint8Array(signature))
        .map((b) => b.toString(16).padStart(2, '0'))
        .join('');
    }
    
    /**
     * 为请求生成签名头
     * @returns X-Signature, X-Timestamp, X-Nonce
     */
    export async function signRequest(
      method: string,
      path: string,
      body: unknown,
      signingKey: string,
    ): Promise<Record<string, string>> {
      const timestamp = String(Math.floor(Date.now() / 1000));
      const nonce = generateNonce();
    
      // body hashJSON stringify 后取 SHA256
      const bodyStr = body !== undefined ? JSON.stringify(body) : '';
      const bodyHash = bodyStr
        ? await hmacSha256(signingKey, bodyStr)
        : '';
    
      // 签名消息method + path + bodyHash + timestamp + nonce
      const message = `${method.toUpperCase()}${path}${bodyHash}${timestamp}${nonce}`;
      const signature = await hmacSha256(signingKey, message);
    
      return {
        'X-Signature': signature,
        'X-Timestamp': timestamp,
        'X-Nonce': nonce,
      };
    }
    

    运行命令:

    cd apps/miniprogram && npx vitest run src/utils/__tests__/request-signer.test.ts
    

    预期: 通过

    注意: 小程序环境可能不支持 crypto.subtle。如不支持,改用 @noble/hashes/hmac + @noble/hashes/sha256Phase 0 已安装 @noble/ciphers,同生态)。

    备选纯 JS 实现:

    import { hmac } from '@noble/hashes/hmac';
    import { sha256 } from '@noble/hashes/sha256';
    
    function hmacSha256Sync(key: string, message: string): string {
      const encoder = new TextEncoder();
      const mac = hmac(sha256, encoder.encode(key), encoder.encode(message));
      return Array.from(mac)
        .map((b) => b.toString(16).padStart(2, '0'))
        .join('');
    }
    
  • Step 3: 集成到 request.ts

    文件: apps/miniprogram/src/services/request.ts

    改动点:

    1. getHeaders() 中加入签名头
    2. signingKey 从登录响应获取,仅存内存
    import { signRequest } from '@/utils/request-signer';
    
    // 新增:签名密钥(仅内存,不持久化)
    let signingKey = '';
    
    export function setSigningKey(key: string): void {
      signingKey = key;
    }
    
    export function clearSigningKey(): void {
      signingKey = '';
    }
    
    // 修改 getHeaders 为 async已是 async
    async function getHeaders(): Promise<Record<string, string>> {
      if (Date.now() - headersCacheTs > HEADERS_CACHE_TTL) {
        refreshHeadersCache();
      }
      const headers: Record<string, string> = { 'Content-Type': 'application/json' };
      if (cachedToken) headers['Authorization'] = `Bearer ${cachedToken}`;
      if (responseCache.getPatientId()) headers['X-Patient-Id'] = responseCache.getPatientId();
      if (cachedTenantId) headers['X-Tenant-Id'] = cachedTenantId;
    
      // 签名头
      if (signingKey) {
        // 签名在 request() 函数中计算(需要 method + path + body
      }
    
      return headers;
    }
    
    // 在 request() 函数中headers 之后、Taro.request 之前添加签名
    async function request<T>(method: string, path: string, data?: unknown, timeout?: number, signal?: AbortSignal, bypassLimiter = false): Promise<T> {
      // ... acquire limiter, get headers ...
      const headers = await getHeaders();
    
      // 添加请求签名
      if (signingKey) {
        try {
          const signHeaders = await signRequest(method, path, data, signingKey);
          Object.assign(headers, signHeaders);
        } catch {
          // 签名失败不阻断请求(降级策略)
        }
      }
    
      // ... Taro.request ...
    }
    
  • Step 4: 后端配合 — signing_key 下发 + 校验中间件

    后端改动(简述,具体实现由后端工程师负责):

    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:

    // auth.ts credentialLogin / wechatLogin 成功后
    if (resp.signing_key) {
      setSigningKey(resp.signing_key);
    }
    

    logout 中清理:

    clearSigningKey();
    
  • Step 5: 编译验证

    cd G:/hms && cargo check && cargo test
    cd apps/miniprogram && npx tsc --noEmit
    

E3-1: 消灭所有 any 类型1d

目标: 将所有 : any 替换为具体类型或 unknown

TDD 步骤

  • Step 1: 审计所有 any 使用

    运行命令:

    cd G:/hms && grep -rn ": any\|as any" apps/miniprogram/src/ --include="*.ts" --include="*.tsx" > /tmp/any-audit.txt
    wc -l /tmp/any-audit.txt
    

    当前约 38 处。分类处理:

    类别 数量 处理方式
    catch (err: any) ~12 改为 catch (err: unknown)
    BLE 回调 (res: any) ~6 提取 BLECallbackResult 接口
    method as any 1 使用 Taro RequestMethod 类型
    Taro.requestSubscribeMessage as any 2 类型声明文件
    __wxConfig as any 1 类型声明文件
    其他 as any ~16 逐个分析替换
  • Step 2: 修复 catch (err: any) → catch (err: unknown)

    全局替换:

    # 列出所有 catch (err: any) 或 catch (e: any)
    grep -rn "catch.*(e\|err): any" apps/miniprogram/src/ --include="*.ts" --include="*.tsx" -l
    

    每个文件修改:

    // BEFORE
    } catch (err: any) {
      console.warn('xxx:', err);
    }
    
    // AFTER
    } catch (err: unknown) {
      const message = err instanceof Error ? err.message : '未知错误';
      console.warn('xxx:', message);
    }
    

    涉及文件:

    • src/services/ble/BLEManager.ts — 5 处
    • src/services/ble/DataSyncScheduler.ts — 1 处
    • src/services/request.ts — 1 处
    • src/stores/auth.ts — 1 处
    • src/pages/login/index.tsx — 3 处
    • src/pages/pkg-health/device-sync/index.tsx — 3 处
    • src/pages/appointment/create/index.tsx — 1 处
    • src/pages/pkg-profile/events/index.tsx — 1 处
  • Step 3: BLE 回调类型提取

    文件: apps/miniprogram/src/services/ble/types.ts

    新增:

    /** 微信 BLE 扫描回调结果 */
    export interface BLEScanResult {
      devices: Array<{
        deviceId: string;
        name?: string;
        RSSI?: number;
        localName?: string;
      }>;
    }
    
    /** 微信 BLE 连接状态变更回调结果 */
    export interface BLEConnectionChangeResult {
      deviceId: string;
      connected: boolean;
    }
    
    /** 微信 BLE 特征值变更回调结果 */
    export interface BLECharacteristicChangeResult {
      deviceId: string;
      serviceId: string;
      characteristicId: string;
      value: ArrayBuffer;
    }
    
    /** 微信 BLE 服务发现结果 */
    export interface BLEServiceResult {
      services: Array<{
        uuid: string;
        isPrimary: boolean;
      }>;
    }
    

    修改 BLEManager.ts:

    import type {
      BLEScanResult,
      BLEConnectionChangeResult,
      BLECharacteristicChangeResult,
      BLEServiceResult,
    } from './types';
    
    // BEFORE: private connChangeHandler: ((res: any) => void) | null = null;
    // AFTER:
    private connChangeHandler: ((res: BLEConnectionChangeResult) => void) | null = null;
    private charChangeHandler: ((res: BLECharacteristicChangeResult) => void) | null = null;
    
    // BEFORE: const onFound = (res: any) => {
    // AFTER: const onFound = (res: BLEScanResult) => {
    
    // BEFORE: const svc = services.find((s: any) => s.uuid...
    // AFTER: const svc = services.find((s: { uuid: string }) => s.uuid...
    
  • Step 4: 修复 method as any

    文件: apps/miniprogram/src/services/request.ts

    // BEFORE
    res = await Taro.request({ url, method: method as any, ... });
    
    // AFTER — 使用 Taro 导出的 method 类型
    import type { RequestMethod } from '@tarojs/taro';
    
    // 或者直接使用字符串字面量类型
    type ValidMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'TRACE' | 'CONNECT';
    res = await Taro.request({ url, method: method as ValidMethod, ... });
    

    如果 Taro 没有导出 RequestMethod,在 src/types/taro.d.ts 中声明:

    declare module '@tarojs/taro' {
      type RequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS';
    }
    
  • Step 5: 处理其他 as any

    创建 apps/miniprogram/src/types/wx.d.ts:

    declare const __wxConfig: {
      envVersion: 'develop' | 'trial' | 'release';
    };
    
    declare module '@tarojs/taro' {
      interface TaroStatic {
        requestSubscribeMessage(options: {
          tmplIds: string[];
          success?: (res: { [key: string]: 'accept' | 'reject' | 'ban' }) => void;
          fail?: (err: { errMsg: string }) => void;
        }): void;
      }
    }
    

    修改引用:

    // login.tsx
    // BEFORE: const IS_SIMULATOR = typeof __wxConfig !== 'undefined' && (__wxConfig as any).envVersion === 'develop';
    // AFTER:
    const IS_SIMULATOR = typeof __wxConfig !== 'undefined' && __wxConfig.envVersion === 'develop';
    
    // appointment/create/index.tsx
    // BEFORE: await (Taro.requestSubscribeMessage as any)({ tmplIds: [tmplId] });
    // AFTER:
    await Taro.requestSubscribeMessage({ tmplIds: [tmplId] });
    
  • Step 6: 全量验证

    cd apps/miniprogram && npx tsc --noEmit
    grep -rn ": any" src/ --include="*.ts" --include="*.tsx" | wc -l
    # 预期: 0 或极少数(通过 ts-expect-error 标注的已知限制)
    

E3-2: 大文件拆分1d

目标: 将 8 个 >300 行文件拆分至 <=250 行。

目标文件清单

文件 当前行数 拆分策略
pages/pkg-health/daily-monitoring/index.tsx 449 提取 useDailyMonitoring hook
services/request.ts 376 提取 ResponseCache/ConcurrencyLimiter 到独立文件
pages/health/index.tsx 376 提取 useHealthData hook已部分提取
pages/index/index.tsx 371 提取 useHomeData hook已存在但未完全拆分
services/ble/BLEManager.ts 356 提取连接管理逻辑到 BLEConnection.ts
pages/pkg-health/device-sync/index.tsx 324 提取设备扫描 UI 为子组件
pages/appointment/create/index.tsx 304 提取时间段选择为子组件
pages/pkg-health/input/index.tsx 294 提取血压输入组为子组件

TDD 步骤

  • Step 1: 拆分 daily-monitoring — 提取 hook

    新增文件: apps/miniprogram/src/pages/pkg-health/daily-monitoring/useDailyMonitoring.ts

    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 简化为:

    export default function DailyMonitoring() {
      const modeClass = useElderClass();
      const {
        /* 解构所有 state 和 handlers */
      } = useDailyMonitoring();
    
      return (
        <PageShell>
          {/* 纯 UI 渲染 */}
        </PageShell>
      );
    }
    

    预期行数: hook ~180 行, 页面 ~120 行

  • Step 2: 拆分 request.ts — 提取辅助类

    新增文件: apps/miniprogram/src/services/request/cache.ts

    // 提取 ResponseCache 类(~65 行)
    export class ResponseCache { /* ... */ }
    

    新增文件: apps/miniprogram/src/services/request/limiter.ts

    // 提取 ConcurrencyLimiter 类(~25 行)
    export class ConcurrencyLimiter { /* ... */ }
    

    request.ts 改为导入:

    import { ConcurrencyLimiter } from './request/limiter';
    import { ResponseCache } from './request/cache';
    

    预期行数: request.ts ~200 行, cache.ts ~65 行, limiter.ts ~25 行

  • Step 3: 拆分 BLEManager — 提取连接管理

    新增文件: apps/miniprogram/src/services/ble/BLEConnection.ts

    // 提取连接/断开/服务发现逻辑(~120 行)
    export class BLEConnection { /* ... */ }
    

    BLEManager.ts 保留扫描/同步调度,委托连接给 BLEConnection。

    预期行数: BLEManager.ts ~180 行, BLEConnection.ts ~120 行

  • Step 4: 拆分其余文件

    采用类似策略:

    • input/index.tsx — 提取 BloodPressureInputGroup 子组件
    • appointment/create/index.tsx — 提取 TimeSlotPicker 子组件
    • device-sync/index.tsx — 提取 DeviceScanPanel 子组件
  • Step 5: 全量验证

    cd apps/miniprogram && npx tsc --noEmit
    # 确认所有文件 <= 250 行
    find src/ -name "*.tsx" -o -name "*.ts" | xargs wc -l | sort -rn | head -10
    

E3-3: 构建优化1d

目标: 主包体积减少 >=15%。

TDD 步骤

  • Step 1: 安装分析工具

    cd apps/miniprogram && npm install --save-dev webpack-bundle-analyzer
    
  • Step 2: 添加 sideEffects 标记

    文件: apps/miniprogram/package.json

    {
      "sideEffects": [
        "*.scss",
        "*.css",
        "src/app.tsx",
        "src/app.config.ts"
      ]
    }
    

    注意: 不能设为 false,因为 SCSS 文件有副作用(样式注入),app.tsx 有全局初始化。用数组列出有副作用的文件。

  • Step 3: 配置 splitChunks

    文件: apps/miniprogram/config/index.ts

    mini 配置中修改:

    mini: {
      // ... 现有配置
    
      // 优化 webpack splitChunks
      webpackChain(chain) {
        chain.optimization.splitChunks({
          chunks: 'all',
          maxInitialRequests: Infinity,
          minSize: 20000,
          cacheGroups: {
            // React 家族
            react: {
              test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
              name: 'react-vendor',
              priority: 10,
            },
            // Zustand
            zustand: {
              test: /[\\/]node_modules[\\/]zustand[\\/]/,
              name: 'zustand-vendor',
              priority: 10,
            },
            // Taro 运行时
            taro: {
              test: /[\\/]node_modules[\\/]@tarojs[\\/]/,
              name: 'taro-vendor',
              priority: 8,
            },
            // 其他 node_modules
            vendors: {
              test: /[\\/]node_modules[\\/]/,
              name: 'vendors',
              priority: 1,
              reuseExistingChunk: true,
            },
            // 公共业务代码
            common: {
              minChunks: 2,
              name: 'common',
              priority: 5,
              reuseExistingChunk: true,
            },
          },
        });
      },
    },
    
  • Step 4: 添加分析脚本

    文件: apps/miniprogram/package.json

    {
      "scripts": {
        "analyze": "cross-env ANALYZE=1 npm run build:weapp",
        "analyze:h5": "cross-env ANALYZE=1 npm run build:h5"
      }
    }
    

    安装:

    cd apps/miniprogram && npm install --save-dev cross-env
    

    config/index.ts 中添加分析插件:

    const isAnalyze = process.env.ANALYZE === '1';
    // 在 webpackChain 中:
    if (isAnalyze) {
      const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
      chain.plugin('bundle-analyzer').use(BundleAnalyzerPlugin, [{
        analyzerMode: 'static',
        reportFilename: 'bundle-report.html',
        openAnalyzer: false,
      }]);
    }
    
  • Step 5: 构建验证

    cd apps/miniprogram && npm run build:weapp
    # 记录优化前后主包体积对比
    du -sh dist/
    

    运行分析:

    cd apps/miniprogram && npm run analyze
    # 打开 dist/bundle-report.html 查看产物分析
    

E3-4: CI 集成1d

目标: PR 触发自动检查TypeScript 编译 + ESLint + 单元测试。

TDD 步骤

  • Step 1: 创建 Gitea CI 配置

    新增文件: apps/miniprogram/.gitea/workflows/ci.yml

    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/ 中独立配置。

    根级配置参考(如果需要协调多模块):

    # .gitea/workflows/miniprogram.yml
    name: Miniprogram Pipeline
    
    on:
      push:
        paths:
          - 'apps/miniprogram/**'
          - 'crates/erp-health/**'  # 后端 DTO 变更也需要检查前端
      pull_request:
        paths:
          - 'apps/miniprogram/**'
    
    jobs:
      miniprogram-ci:
        uses: ./.gitea/workflows/miniprogram-reusable.yml
    
  • Step 3: 验证 CI 配置

    推送测试:

    git add apps/miniprogram/.gitea/
    git commit -m "chore(web): add miniprogram CI pipeline"
    git push
    # 在 Gitea Web UI 中检查 CI 触发和执行结果
    

U3-1: 医生端导航状态保持1d

目标: 高频页面(患者列表/详情/咨询列表Tab 切换后恢复搜索/滚动位置/表单草稿。

TDD 步骤

  • Step 1: 创建 useNavigationState hook

    新增文件: apps/miniprogram/src/hooks/useNavigationState.ts

    import { useCallback, useRef } from 'react';
    import Taro from '@tarojs/taro';
    
    interface NavigationState {
      scrollTop?: number;
      searchText?: string;
      filters?: Record<string, string>;
      formData?: Record<string, string>;
    }
    
    const STORAGE_PREFIX = 'nav_state_';
    const MAX_STATES = 20; // 最多保存 20 个页面状态
    
    /**
     * 导航状态持久化 hook。
     * 页面隐藏时自动保存状态,恢复时自动读取。
     * 适用于医生端高频页面的状态保持。
     */
    export function useNavigationState(pageKey: string) {
      const stateRef = useRef<NavigationState>({});
      const storageKey = `${STORAGE_PREFIX}${pageKey}`;
    
      /** 保存当前状态到 Storage */
      const saveState = useCallback(
        (state: NavigationState) => {
          stateRef.current = state;
          try {
            Taro.setStorageSync(storageKey, JSON.stringify(state));
          } catch { /* best-effort */ }
        },
        [storageKey],
      );
    
      /** 读取之前保存的状态 */
      const restoreState = useCallback((): NavigationState | null => {
        try {
          const raw = Taro.getStorageSync(storageKey);
          if (raw && typeof raw === 'string') {
            const state = JSON.parse(raw) as NavigationState;
            stateRef.current = state;
            return state;
          }
        } catch { /* ignore */ }
        return null;
      }, [storageKey]);
    
      /** 清除保存的状态 */
      const clearState = useCallback(() => {
        stateRef.current = {};
        try {
          Taro.removeStorageSync(storageKey);
        } catch { /* ignore */ }
      }, [storageKey]);
    
      return { saveState, restoreState, clearState, stateRef };
    }
    
  • Step 2: 集成到医生端患者列表页

    文件: apps/miniprogram/src/pages/pkg-doctor-core/patients/index.tsx

    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: 编译验证

    cd apps/miniprogram && npx tsc --noEmit
    

U3-2: 微交互统一0.5d

目标: 统一触觉反馈、动画时序、加载到内容的 fade-in 过渡。

TDD 步骤

  • Step 1: 创建触觉反馈工具

    新增文件: apps/miniprogram/src/utils/haptic.ts

    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 中添加:

    page {
      // 动画时序 Token
      --tk-duration-fast: 150ms;
      --tk-duration-normal: 200ms;
      --tk-duration-slow: 300ms;
      --tk-easing: cubic-bezier(0.16, 1, 0.3, 1);  // ease-out-expo
      --tk-easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
    }
    

    token-values.ts 中自动生成对应常量。

  • Step 3: 添加 fade-in 过渡工具类

    apps/miniprogram/src/styles/mixins.scss 中添加:

    // 加载完成后的 fade-in 过渡
    @mixin fade-in($duration: 200ms) {
      opacity: 0;
      transition: opacity $duration cubic-bezier(0.16, 1, 0.3, 1);
    
      &--visible {
        opacity: 1;
      }
    }
    

    apps/miniprogram/src/styles/tokens.scss 中添加工具类:

    .fade-in-enter {
      opacity: 0;
    }
    .fade-in-enter-active {
      opacity: 1;
      transition: opacity var(--tk-duration-normal) var(--tk-easing);
    }
    
  • Step 4: 在关键交互点添加触觉反馈

    修改 PrimaryButton:

    // src/components/ui/PrimaryButton/index.tsx
    import { hapticLight } from '@/utils/haptic';
    
    // onClick handler 中
    const handleClick = (e) => {
      hapticLight();
      onClick?.(e);
    };
    

    修改成功/失败操作:

    // 录入成功
    import { hapticMedium } from '@/utils/haptic';
    hapticMedium();
    Taro.showToast({ title: '录入成功', icon: 'success' });
    
  • Step 5: 编译验证

    cd apps/miniprogram && npx tsc --noEmit
    npm run generate-tokens  # 重新生成包含动画时序的 token
    

Phase 3 验收清单

  • grep -rn ": any" apps/miniprogram/src/ --include="*.ts" --include="*.tsx" 返回 0 结果
  • 所有源文件 <= 250 行(wc -l 验证)
  • 主包体积相比 Phase 2 结束时减少 >=15%
  • .gitea/workflows/ci.yml 存在且可触发
  • API 请求头包含 X-Signature/X-Timestamp/X-Nonce(抓包验证)
  • 医生端 Tab 切换后搜索文本保持(手动验证)
  • 按钮点击有触觉反馈(真机测试)

全局依赖关系

E2-1 (Token 生成) ──────┐
                         ├── U2-1 (TrendChart 适老)
                         │
S2-1 (Analytics PII) ──── 独立
S2-2 (阈值加密) ───────── 独立
S2-3 (Patient DTO) ────── 独立(后端先完成)

U2-2 (表单跳焦) ───────── 独立
U2-3 (Loading 统一) ───── 独立

S3-1 (请求签名) ───────── 依赖后端 signing_key
E3-1 (any 清零) ───────── 独立
E3-2 (大文件拆分) ──────── 独立
E3-3 (构建优化) ───────── 依赖 E3-2 完成
E3-4 (CI) ─────────────── 依赖 E3-1 + E3-3
U3-1 (导航状态) ───────── 独立
U3-2 (微交互) ─────────── 独立

可并行任务分组

并行组 任务 预计耗时
Group A安全 S2-1 + S2-2 + S2-3 + S3-1 6.5d
Group B工程 E2-1 + E3-1 + E3-2 + E3-3 + E3-4 5d
Group CUX 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 配置渐进式调整