Phase 2: Token 常量生成 + Analytics PII 清理 + 阈值加密 + DTO 最小化 + Canvas 适老 + 表单跳焦 Phase 3: API 签名 + any 清零 + 大文件拆分 + 构建优化 + CI + 导航状态 + 微交互
2380 lines
68 KiB
Markdown
2380 lines
68 KiB
Markdown
# 小程序 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<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');
|
||
});
|
||
});
|
||
```
|
||
|
||
运行命令:
|
||
```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<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');
|
||
});
|
||
});
|
||
```
|
||
|
||
运行命令:
|
||
```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<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(),
|
||
};
|
||
// ... 其余不变
|
||
}
|
||
```
|
||
|
||
运行命令:
|
||
```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<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: 编译验证**
|
||
|
||
运行命令:
|
||
```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<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 [];
|
||
}
|
||
}
|
||
```
|
||
|
||
运行命令:
|
||
```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<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` 新增:
|
||
|
||
```rust
|
||
/// 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` 新增:
|
||
|
||
```rust
|
||
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`):
|
||
|
||
```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<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`
|
||
|
||
```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 };
|
||
}
|
||
```
|
||
|
||
注意:微信小程序 `<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>` 显示上次测量参考值
|
||
|
||
```tsx
|
||
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` 跳转到下一个。
|
||
|
||
```tsx
|
||
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: 添加历史参考样式**
|
||
|
||
```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\|<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: 验证**
|
||
|
||
```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<string> {
|
||
// 使用 SubtleCrypto(微信小程序基础库 2.17.3+ 支持)
|
||
// 或 fallback 到纯 JS 实现
|
||
const encoder = new TextEncoder();
|
||
const keyData = encoder.encode(key);
|
||
const msgData = encoder.encode(message);
|
||
|
||
const cryptoKey = await crypto.subtle.importKey(
|
||
'raw',
|
||
keyData,
|
||
{ name: 'HMAC', hash: 'SHA-256' },
|
||
false,
|
||
['sign'],
|
||
);
|
||
|
||
const signature = await crypto.subtle.sign('HMAC', cryptoKey, msgData);
|
||
return Array.from(new Uint8Array(signature))
|
||
.map((b) => b.toString(16).padStart(2, '0'))
|
||
.join('');
|
||
}
|
||
|
||
/**
|
||
* 为请求生成签名头
|
||
* @returns X-Signature, X-Timestamp, X-Nonce
|
||
*/
|
||
export async function signRequest(
|
||
method: string,
|
||
path: string,
|
||
body: unknown,
|
||
signingKey: string,
|
||
): Promise<Record<string, string>> {
|
||
const timestamp = String(Math.floor(Date.now() / 1000));
|
||
const nonce = generateNonce();
|
||
|
||
// body hash:JSON stringify 后取 SHA256
|
||
const bodyStr = body !== undefined ? JSON.stringify(body) : '';
|
||
const bodyHash = bodyStr
|
||
? await hmacSha256(signingKey, bodyStr)
|
||
: '';
|
||
|
||
// 签名消息:method + path + bodyHash + timestamp + nonce
|
||
const message = `${method.toUpperCase()}${path}${bodyHash}${timestamp}${nonce}`;
|
||
const signature = await hmacSha256(signingKey, message);
|
||
|
||
return {
|
||
'X-Signature': signature,
|
||
'X-Timestamp': timestamp,
|
||
'X-Nonce': nonce,
|
||
};
|
||
}
|
||
```
|
||
|
||
运行命令:
|
||
```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<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`:
|
||
```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 (
|
||
<PageShell>
|
||
{/* 纯 UI 渲染 */}
|
||
</PageShell>
|
||
);
|
||
}
|
||
```
|
||
|
||
预期行数: 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<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`
|
||
|
||
```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 配置渐进式调整 |
|