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

2380 lines
68 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 小程序 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全面提升 + CI7d](#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 接口和 trackEventGREEN**
文件: `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 类型和 serviceGREEN**
文件: `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 使用 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:
```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全面提升 + 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`
```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 或 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,
};
}
```
运行命令:
```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 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 配置渐进式调整 |