Files
hms/docs/superpowers/plans/2026-04-24-hms-miniprogram-iteration-plan.md
iven 19be2a08c7
Some checks failed
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
docs(miniprogram): 新增小程序迭代设计规格 + 25 Task 实施计划
设计规格:4 Sprint 混合策略(Sprint 0 修基础 → Sprint 1-3 模块打磨),
覆盖 18 个问题,含健康数据、预约挂号、报告详情、安全加固、增长基础。

实施计划:25 个 Task,4 个 Chunk,经 4 轮审查修复关键问题:
- Task 10 依赖后端 today 端点 status/reference_range 字段
- Task 14/15 补全 StepIndicator 连接线 + WeekCalendar 完整实现
- Task 21 request.ts Token 加密绕过修复
- Task 22 手机号解密前后端 API 契约明确(推荐 code 模式)
- Task 24 埋点补充核心页面手动调用
- Task 25 hooks 无条件调用修复
2026-04-24 12:08:13 +08:00

55 KiB
Raw Permalink Blame History

HMS 小程序迭代实施计划

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 基于混合策略(先基建再模块),分 4 个 Sprint 修复小程序工程质量、打磨用户体验、加固安全合规、建设增长基础,从开发阶段推进到可测试状态。

Architecture: 小程序代码位于 apps/miniprogram/src/Taro 4 + React 18 + Zustand 5 + SCSS通过 services 层调用后端 /api/v1/ 端点。后端 Rust 模块erp-auth、erp-health需配合新增部分端点。前端遵循 Pages → Services → Backend 分层Stores 管理全局状态。

Tech Stack: Taro 4.2.0, React 18.3, TypeScript 5.8, Zustand 5.0, SCSS, Rust/Axum/SeaORM (后端)

Spec: docs/superpowers/specs/2026-04-24-hms-miniprogram-iteration-design.md


Chunk 1: Sprint 0 — 工程基础修复

Task 1: 后端新增小程序专用 GET /health/vital-signs/today 端点

Files:

  • Modify: crates/erp-health/src/handler/health_data_handler.rs

  • Modify: crates/erp-health/src/dto/health_data_dto.rs

  • Modify: crates/erp-health/src/module.rs

  • Modify: crates/erp-health/src/service/health_data_service.rs

  • Step 1: 定义 DTO

crates/erp-health/src/dto/health_data_dto.rs 新增:

#[derive(Debug, Serialize)]
pub struct MiniTodayResp {
    pub blood_pressure: Option<IndicatorSummary>,
    pub heart_rate: Option<IndicatorSummary>,
    pub blood_sugar: Option<IndicatorSummary>,
    pub weight: Option<IndicatorSummary>,
}

#[derive(Debug, Serialize)]
pub struct IndicatorSummary {
    pub value: f64,
    pub status: String, // "normal" | "high" | "low"
    pub reference_range: Option<String>, // 如 "60-100"
    // 血压专用
    pub systolic: Option<f64>,
    pub diastolic: Option<f64>,
}
  • Step 2: 实现 handler

crates/erp-health/src/handler/health_data_handler.rs 新增 get_mini_today。参照已有的 get_mini_trend(第 337-352 行)模式:通过 Extension<TenantContext> 获取 ctx.user_id,调用 service 层获取今日最新体征数据。

  • Step 3: 实现 service 方法

crates/erp-health/src/service/health_data_service.rs 新增查询方法:按 user_id 查找关联 patient查询今日最新体征记录按指标类型聚合为 summary计算 statusnormal/high/low

  • Step 4: 注册路由

crates/erp-health/src/module.rsprotected_routes 中,在 get_mini_trend 路由旁新增:

.route("/health/vital-signs/today", get(health_data_handler::get_mini_today))
  • Step 5: 验证

Run: cargo check -p erp-health Expected: 编译通过

  • Step 6: 提交
git add crates/erp-health/src/
git commit -m "feat(health): 新增小程序专用今日体征摘要端点 GET /health/vital-signs/today"

Task 2: 前端对接 getTodaySummary 新端点

Depends on: Task 1

Files:

  • Modify: apps/miniprogram/src/services/health.ts

  • Step 1: 修改 API 路径

services/health.ts 第 19 行的 API 路径从不存在的端点改为新端点:

// 旧: return api.get<TodaySummary>('/health/vital-signs?date=today');
// 新:
export async function getTodaySummary() {
  return api.get<TodaySummary>('/health/vital-signs/today');
}
  • Step 2: 扩展 TodaySummary 类型

更新 TodaySummary 接口,增加 reference_range 字段:

export interface TodaySummary {
  blood_pressure?: { systolic: number; diastolic: number; status: string; reference_range?: string };
  heart_rate?: { value: number; status: string; reference_range?: string };
  blood_sugar?: { value: number; status: string; reference_range?: string };
  weight?: { value: number; status: string; reference_range?: string };
}
  • Step 3: 验证构建

Run: cd apps/miniprogram && pnpm build:weapp Expected: 编译通过

  • Step 4: 提交
git add apps/miniprogram/src/services/health.ts
git commit -m "fix(health): 对接今日体征摘要新端点 /health/vital-signs/today"

Task 3: 删除重复页面 + 修复路由

Files:

  • Delete: apps/miniprogram/src/pages/report/index.tsx

  • Delete: apps/miniprogram/src/pages/report/index.scss

  • Delete: apps/miniprogram/src/pages/followup/index.tsx

  • Delete: apps/miniprogram/src/pages/followup/index.scss

  • Modify: apps/miniprogram/src/app.config.ts

  • Modify: apps/miniprogram/src/pages/index/index.tsx (修复 EmptyState 导入 bug)

  • Step 1: 删除重复文件

rm apps/miniprogram/src/pages/report/index.tsx
rm apps/miniprogram/src/pages/report/index.scss
rm apps/miniprogram/src/pages/followup/index.tsx
rm apps/miniprogram/src/pages/followup/index.scss
  • Step 2: 从 app.config.ts 移除路由

删除 app.config.ts 中第 13-14 行的 pages/report/indexpages/followup/index

// 删除这两行:
// 'pages/report/index',
// 'pages/followup/index',
  • Step 3: 修复首页 EmptyState 导入 bug

修改 pages/index/index.tsx 第 5 行,从命名导入改为默认导入:

// 旧: import { EmptyState } from '../../components/EmptyState';
// 新:
import EmptyState from '../../components/EmptyState';
  • Step 4: 验证构建

Run: cd apps/miniprogram && pnpm build:weapp Expected: 编译通过,无 Unresolved import 错误

  • Step 5: 提交
git add apps/miniprogram/src/
git commit -m "fix(miniprogram): 删除重复页面 report/followup修复 EmptyState 导入 bug"

Task 4: 统一错误处理 — ErrorBoundary + ErrorState + tryRefreshToken

Files:

  • Create: apps/miniprogram/src/components/ErrorBoundary/index.tsx

  • Modify: apps/miniprogram/src/app.tsx

  • Modify: apps/miniprogram/src/services/request.ts

  • Step 1: 创建 ErrorBoundary 组件

创建 components/ErrorBoundary/index.tsx

import React, { Component } from 'react';
import { View, Text } from '@tarojs/components';

interface Props {
  children: React.ReactNode;
}

interface State {
  hasError: boolean;
}

export default class ErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(): State {
    return { hasError: true };
  }

  componentDidCatch(error: Error, info: React.ErrorInfo) {
    console.error('[ErrorBoundary]', error, info.componentStack);
  }

  render() {
    if (this.state.hasError) {
      return (
        <View style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '60vh', padding: '40px' }}>
          <Text style={{ fontSize: '48px', marginBottom: '20px' }}>😵</Text>
          <Text style={{ fontSize: '32px', color: '#134E4A', marginBottom: '12px' }}>页面出了点问题</Text>
          <Text style={{ fontSize: '24px', color: '#94A3B8', marginBottom: '24px' }}>请返回重试</Text>
        </View>
      );
    }
    return this.props.children;
  }
}
  • Step 2: 在 app.tsx 包裹 ErrorBoundary

修改 app.tsx

import { PropsWithChildren } from 'react';
import ErrorBoundary from './components/ErrorBoundary';
import './app.scss';

function App({ children }: PropsWithChildren<Record<string, unknown>>) {
  return <ErrorBoundary>{children}</ErrorBoundary>;
}

export default App;
  • Step 3: 修复 tryRefreshToken 静默吞异常

修改 services/request.ts 第 36-38 行:

    } catch (err) {
      console.error('[tryRefreshToken] token 刷新失败:', err);
    }
  • Step 4: 验证构建

Run: cd apps/miniprogram && pnpm build:weapp Expected: 编译通过

  • Step 5: 提交
git add apps/miniprogram/src/components/ErrorBoundary/ apps/miniprogram/src/app.tsx apps/miniprogram/src/services/request.ts
git commit -m "fix(miniprogram): 添加全局 ErrorBoundary修复 tryRefreshToken 静默吞异常"

Task 5: 后端新增预约/随访单条查询端点

Files:

  • Modify: crates/erp-health/src/handler/appointment_handler.rs

  • Modify: crates/erp-health/src/handler/follow_up_handler.rs

  • Modify: crates/erp-health/src/module.rs

  • Step 1: 预约 handler 新增 get_appointment

appointment_handler.rs 新增 get_appointment 函数:从路径参数获取 appointment_id,调用 service 查询单条,返回 ApiResponse::ok(data)

  • Step 2: 随访 handler 新增 get_task

follow_up_handler.rs 新增 get_task 函数:从路径参数获取 task_id,调用 service 查询单条,返回 ApiResponse::ok(data)

  • Step 3: 注册路由

module.rsprotected_routes 中,在已有的 appointments 路由组添加 .route("/health/appointments/{id}", get(appointment_handler::get_appointment)),在 follow-up-tasks 路由组添加 .route("/health/follow-up-tasks/{id}", get(follow_up_handler::get_task))

  • Step 4: 验证

Run: cargo check -p erp-health Expected: 编译通过

  • Step 5: 提交
git add crates/erp-health/src/
git commit -m "feat(health): 新增预约/随访单条查询 GET 端点"

Task 6: 前端修复预约详情/随访详情数据获取

Depends on: Task 5

Files:

  • Modify: apps/miniprogram/src/services/appointment.ts

  • Modify: apps/miniprogram/src/services/followup.ts

  • Modify: apps/miniprogram/src/pages/appointment/detail/index.tsx

  • Modify: apps/miniprogram/src/pages/followup/detail/index.tsx

  • Step 1: services/appointment.ts 新增 getAppointment

export async function getAppointment(id: string) {
  return api.get<Appointment>(`/health/appointments/${id}`);
}
  • Step 2: services/followup.ts 新增 getTaskDetail
export async function getTaskDetail(id: string) {
  return api.get<FollowUpTask>(`/health/follow-up-tasks/${id}`);
}
  • Step 3: 重写预约详情页

修改 pages/appointment/detail/index.tsx

  • 移除 Taro.getStorageSync('appointment_detail_cache') 相关代码
  • 改为 useEffect 中调用 getAppointment(id) 获取数据
  • 增加 loading 状态(使用 Loading 组件)
  • 增加错误状态(使用 ErrorState 组件)
// 核心变更:用 useEffect + API 替换 Storage 缓存
useEffect(() => {
  if (!id) return;
  setLoading(true);
  getAppointment(id)
    .then((data) => setAppointment(data))
    .catch((err) => {
      console.error('[AppointmentDetail]', err);
      setError(true);
    })
    .finally(() => setLoading(false));
}, [id]);
  • Step 4: 重写随访详情页

修改 pages/followup/detail/index.tsx

  • 移除 listTasks().find() 低效查询

  • 改为调用 getTaskDetail(id) 直接获取

  • 增加 loading使用 Loading)和 error使用 ErrorState)状态

  • Step 5: 验证构建

Run: cd apps/miniprogram && pnpm build:weapp Expected: 编译通过

  • Step 6: 提交
git add apps/miniprogram/src/
git commit -m "fix(miniprogram): 预约详情/随访详情改为 API 获取数据,移除 Storage 缓存传递"

Task 7: 统一 Loading 状态

Files:

  • Modify: apps/miniprogram/src/pages/index/index.tsx

  • Modify: apps/miniprogram/src/pages/health/index.tsx

  • Modify: apps/miniprogram/src/pages/report/detail/index.tsx

  • Modify: apps/miniprogram/src/pages/appointment/create/index.tsx

  • Step 1: 首页增加 loading

修改 pages/index/index.tsx:从 useHealthStore 额外解构 loading,在 todaySummary 为 null 且 loading 为 true 时展示 Loading 组件。

import Loading from '../../components/Loading';
// ...
const { todaySummary, loading, refreshToday } = useHealthStore();
// ...
// 在 health-card 渲染前增加判断
{loading && !todaySummary ? (
  <Loading />
) : (
  <View className='health-card'>...</View>
)}
  • Step 2: 健康页增加 loading

修改 pages/health/index.tsx:已有 loading 解构但未使用,在数据为空且 loading 时展示 Loading

import Loading from '../../components/Loading';
// ...
// 在 health-grid 渲染前
{loading && !todaySummary ? (
  <Loading />
) : (
  <View className='health-grid'>...</View>
)}
  • Step 3: 详情页统一 Loading

修改 pages/report/detail/index.tsx 第 38-43 行,将内联 <Text>加载中...</Text> 替换为 <Loading />

import Loading from '../../../components/Loading';
// ...
if (loading) {
  return (
    <View className='detail-page'>
      <Loading />
    </View>
  );
}
  • Step 3.5: 预约创建页步骤切换 loading

修改 pages/appointment/create/index.tsx:在步骤切换(goNextgoPrev)时,如果正在加载医生列表,展示 Loading 组件。已有 loading state只需在步骤内容区域加入 loading 判断。

  • Step 4: 验证构建

Run: cd apps/miniprogram && pnpm build:weapp Expected: 编译通过

  • Step 5: 提交
git add apps/miniprogram/src/
git commit -m "fix(miniprogram): 首页/健康页/详情页统一使用 Loading 组件"

Task 8: 路径别名启用

应在 Task 2-7 全部完成后执行,避免与其他 Task 修改同一文件产生冲突。

Files:

  • Modify: apps/miniprogram/src/services/health.ts

  • Modify: apps/miniprogram/src/services/appointment.ts

  • Modify: apps/miniprogram/src/services/followup.ts

  • Modify: apps/miniprogram/src/services/report.ts

  • Modify: apps/miniprogram/src/services/patient.ts

  • Modify: apps/miniprogram/src/services/article.ts

  • Modify: apps/miniprogram/src/services/auth.ts

  • Modify: apps/miniprogram/src/stores/auth.ts

  • Modify: apps/miniprogram/src/stores/health.ts

  • Step 1: 替换 services 层 import

所有 services 文件中的 import { api } from './request' 保持不变(同目录相对路径更清晰)。不强制改为 @/

  • Step 2: 替换 stores 层 import

将 stores 中对 services 的导入改为 @/ 别名:

// stores/auth.ts
import * as authApi from '@/services/auth';

// stores/health.ts
import * as healthApi from '@/services/health';
  • Step 3: 验证构建

Run: cd apps/miniprogram && pnpm build:weapp Expected: 编译通过tsconfig.json 已配置 @/*./src/*

  • Step 4: 提交
git add apps/miniprogram/src/stores/
git commit -m "chore(miniprogram): stores 层启用 @/ 路径别名"

Chunk 2: Sprint 1 — 健康数据模块打磨

Task 9: ECharts 技术预研Spike

Files:

  • 无代码提交,仅验证可行性

  • Step 1: 安装 echarts-taro3-react

Run: cd apps/miniprogram && pnpm add echarts-taro3-react echarts

  • Step 2: 创建最小验证页面

pages/health/trend/index.tsx 中创建最简 echarts 实例:

import React, { useEffect, useRef } from 'react';
import { View } from '@tarojs/components';
import * as echarts from 'echarts/core';
import { LineChart } from 'echarts/charts';
import { GridComponent, TooltipComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';

echarts.use([LineChart, GridComponent, TooltipComponent, CanvasRenderer]);

// 如果 Taro 4 不支持直接操作 DOM则改用 echarts-for-weixin 方案
  • Step 3: 执行 pnpm dev:weapp 并在微信开发者工具验证

如果 echarts-taro3-react 在 Taro 4 + webpack5 下能正常渲染折线图 → 采用方案 Aecharts-taro3-react。 如果报错或不渲染 → 切换到方案 Becharts-for-weixin + 手动 Canvas 封装)。

  • Step 4: 记录结论

将可行性结论记录到 commit message 中。如不可用则卸载 echarts-taro3-react改安装备选方案。

# 如果方案 A 可用:
git commit --allow-empty -m "spike(miniprogram): echarts-taro3-react 在 Taro 4 webpack5 下验证通过"

# 如果方案 A 不可用,切换方案 B:
pnpm remove echarts-taro3-react
pnpm add echarts-for-weixin
git commit --allow-empty -m "spike(miniprogram): echarts-taro3-react 不兼容 Taro 4改用 echarts-for-weixin"

Task 10: 健康卡片状态色

Files:

  • Modify: apps/miniprogram/src/pages/health/index.tsx
  • Modify: apps/miniprogram/src/pages/health/index.scss
  • Modify: apps/miniprogram/src/pages/index/index.tsx

Depends on: Task 1 + Task 2(后端 /health/vital-signs/today 端点必须已实现并返回 statusreference_range 字段)

  • Step 1: 扩展 TodaySummary 类型(如果 Task 2 未覆盖)

确认 services/health.tsTodaySummary 接口包含 reference_range 字段Task 2 Step 2 已定义)。如 Task 2 中未添加,在此补充:

// 确认 TodaySummary 中每个指标都包含:
reference_range?: string; // 如 "60-100"
  • Step 2: 在健康页卡片中增加状态色逻辑

修改 pages/health/index.tsxitems 数组,根据 status 字段计算边条颜色和趋势标签:

const getStatusStyle = (status?: string) => {
  if (status === 'high') return { borderColor: '$dan', label: '偏高 ▲', labelColor: '$dan' };
  if (status === 'low') return { borderColor: '$dan', label: '偏低 ▼', labelColor: '$dan' };
  if (status === 'normal') return { borderColor: '$acc', label: '正常 ─', labelColor: '$acc' };
  return { borderColor: '$bd', label: '', labelColor: '' };
};

在卡片 View 上增加 style={{ borderLeftColor: style.borderColor }},在底部增加状态标签 + 参考范围(reference_range 显示在卡片底部灰色文字)。

  • Step 3: 添加 SCSS 样式

pages/health/index.scss 中增加状态色相关样式:

.health-card {
  // 已有样式基础上增加:
  border-left: 6px solid $bd;
  transition: border-left-color 0.2s;

  &.status-normal { border-left-color: $acc; }
  &.status-high { border-left-color: $dan; }
  &.status-low { border-left-color: $dan; }
}

.health-card-status-tag {
  font-size: 20px;
  margin-top: 8px;

  &.normal { color: $acc; }
  &.high, &.low { color: $dan; }
}

.health-card-ref {
  font-size: 20px;
  color: $tx3;
  margin-top: 4px;
}
  • Step 4: 首页健康卡片同步更新

修改 pages/index/index.tsxhealthItems 数组,增加 status 和 reference_range 显示。卡片同样增加状态色边条和标签。

  • Step 5: 验证构建

Run: cd apps/miniprogram && pnpm build:weapp Expected: 编译通过

  • Step 6: 提交
git add apps/miniprogram/src/pages/health/ apps/miniprogram/src/pages/index/
git commit -m "feat(health): 健康卡片增加状态色(正常绿/异常红)+ 参考范围显示"

Task 11: ECharts 趋势图组件

Files:

  • Create: apps/miniprogram/src/components/TrendChart/index.tsx
  • Create: apps/miniprogram/src/components/TrendChart/index.scss

Depends on: Task 9echarts 技术预研结果)

  • Step 1: 创建 TrendChart 组件

创建 components/TrendChart/index.tsx,封装 echarts 折线图:

import React, { useEffect, useRef } from 'react';
import { View } from '@tarojs/components';

interface TrendChartProps {
  data: { date: string; value: number }[];
  referenceMin?: number;
  referenceMax?: number;
  unit?: string;
  height?: number; // 默认 500px
}

export default function TrendChart({ data, referenceMin, referenceMax, unit = '', height = 500 }: TrendChartProps) {
  // 基于 Task 9 spike 结论选择 echarts 初始化方式
  // 空数据状态:显示"暂无数据"灰色文字
  // 核心配置:
  // - line seriesdata points 连线
  // - markArea: 参考范围色带 (referenceMin ~ referenceMax 半透明绿)
  // - markPoint: 异常值标红放大 (超出参考范围的点)
  // - tooltip: 点击显示 {date}: {value} {unit}
}
  • Step 2: 创建组件样式

创建 components/TrendChart/index.scss

.trend-chart {
  width: 100%;
  height: 500px; // 图表容器必须有明确高度
}
  • Step 3: 验证组件独立渲染

在微信开发者工具中验证折线图能正常显示。

  • Step 4: 提交
git add apps/miniprogram/src/components/TrendChart/
git commit -m "feat(health): 新增 TrendChart ECharts 折线图组件"

Task 12: 趋势图页面重写 + 缓存 TTL

Depends on: Task 11(需要 TrendChart 组件)

Files:

  • Modify: apps/miniprogram/src/pages/health/trend/index.tsx

  • Modify: apps/miniprogram/src/pages/health/trend/index.scss

  • Modify: apps/miniprogram/src/stores/health.ts

  • Step 1: 重写趋势图页面

替换 pages/health/trend/index.tsx 中的纯 CSS 柱状图为 TrendChart 组件:

import TrendChart from '@/components/TrendChart';
// ...
return (
  <View className='trend-page'>
    <View className='trend-header'>
      <Text className='trend-title'>{indicator.replace(/_/g, ' ')} 趋势</Text>
      {/* 7d/30d/90d tab 切换 */}
    </View>

    <TrendChart
      data={points}
      referenceMin={getReferenceMin(indicator)}
      referenceMax={getReferenceMax(indicator)}
      unit={getUnit(indicator)}
    />

    {/* 保留数据列表作为可折叠的详情区 */}
  </View>
);
  • Step 2: stores/health.ts 添加缓存 TTL

修改 getTrend 方法,缓存增加 5 分钟过期机制:

interface CachedTrend {
  data: { date: string; value: number }[];
  cachedAt: number; // Date.now()
}

// 更新 HealthState 中 trendData 类型:
// trendData: Record<string, CachedTrend>

// 在 getTrend 中:
const cacheKey = `${indicator}_${range}`;
const cached = get().trendData[cacheKey];
if (cached && Date.now() - cached.cachedAt < 5 * 60 * 1000) {
  return cached.data; // 缓存未过期,直接返回
}
// 缓存过期或不存在,发起请求...
// 请求成功后:
set((s) => ({ trendData: { ...s.trendData, [cacheKey]: { data: points, cachedAt: Date.now() } } }));

interface CachedTrend { data: { date: string; value: number }[]; cachedAt: number; // Date.now() }

// 在 getTrend 中: const cached = get().trendData[cacheKey]; if (cached && Date.now() - cached.cachedAt < 5 * 60 * 1000) { return cached.data; } // 重新请求... set((s) => ({ trendData: { ...s.trendData, [cacheKey]: { data: points, cachedAt: Date.now() } } }));


同步更新 `HealthState` 接口中 `trendData` 的类型。

- [ ] **Step 3: 验证构建**

Run: `cd apps/miniprogram && pnpm build:weapp`
Expected: 编译通过

- [ ] **Step 4: 提交**

```bash
git add apps/miniprogram/src/
git commit -m "feat(health): 趋势图升级为 ECharts 折线图 + 缓存 TTL 5分钟"

Task 13: 表单验证升级zod

Depends on: Task 12clearCache 方法定义在 Task 12 的 store 修改中)

Files:

  • Modify: apps/miniprogram/src/pages/health/input/index.tsx

  • Modify: apps/miniprogram/src/stores/health.ts

  • Step 1: 安装 zod

Run: cd apps/miniprogram && pnpm add zod

  • Step 2: 定义验证 schema

pages/health/input/index.tsx 顶部新增:

import { z } from 'zod';

const vitalSignSchema = z.object({
  indicator_type: z.enum(['blood_pressure', 'heart_rate', 'blood_sugar_fasting', 'blood_sugar_postprandial', 'weight', 'temperature']),
  // 注意:枚举值必须与 pages/health/input/index.tsx 中 INDICATORS 数组的 value 字段完全一致
  value: z.number().positive({ message: '请输入有效数值' }),
  extra: z.object({
    systolic: z.number().min(60, '收缩压过低').max(250, '收缩压过高,请及时就医').optional(),
    diastolic: z.number().min(40, '舒张压过低').max(150, '舒张压过高,请及时就医').optional(),
  }).optional(),
  note: z.string().max(200, '备注不能超过200字').optional(),
});

// 异常值警告阈值
const WARN_THRESHOLDS: Record<string, { max?: number; min?: number; warning: string }> = {
  blood_pressure: { max: 180, warning: '收缩压偏高,建议及时就医' },
  heart_rate: { max: 120, min: 50, warning: '心率异常,请注意休息' },
  blood_sugar_fasting: { max: 11.0, warning: '血糖偏高,建议就医检查' },
};
  • Step 3: 重构 handleSubmit

将手动 if 验证替换为 zod schema 验证:

const handleSubmit = async () => {
  if (!currentPatient) { /* ... */ return; }

  const currentIndicator = INDICATORS[indicatorIdx].value;

  // 构建输入对象
  const input = currentIndicator === 'blood_pressure'
    ? { indicator_type: 'blood_pressure', value: parseFloat(systolic), extra: { systolic: parseFloat(systolic), diastolic: parseFloat(diastolic) } }
    : { indicator_type: currentIndicator, value: parseFloat(value) };

  // zod 验证
  const result = vitalSignSchema.safeParse(input);
  if (!result.success) {
    Taro.showToast({ title: result.error.errors[0].message, icon: 'none' });
    return;
  }

  // 异常值警告
  const threshold = WARN_THRESHOLDS[currentIndicator];
  if (threshold) {
    const val = input.value;
    if ((threshold.max && val > threshold.max) || (threshold.min && val < threshold.min)) {
      await Taro.showModal({ title: '健康提示', content: threshold.warning, showCancel: false });
    }
  }

  // 提交...
};
  • Step 4: 录入成功后清除缓存

修改 stores/health.ts,新增 clearCache 方法:

clearCache: () => set({ trendData: {}, todaySummary: null }),

pages/health/input/index.tsx 录入成功回调中调用:

const { clearCache } = useHealthStore();
// ... 录入成功后:
clearCache();
  • Step 5: 验证构建

Run: cd apps/miniprogram && pnpm build:weapp Expected: 编译通过

  • Step 6: 提交
git add apps/miniprogram/src/ apps/miniprogram/package.json apps/miniprogram/pnpm-lock.yaml
git commit -m "feat(health): 表单验证升级为 zod schema + 异常值警告 + 录入后清除缓存"

Chunk 3: Sprint 2 — 预约挂号 + 通知触达

Task 14: StepIndicator 步骤指示器组件

Files:

  • Create: apps/miniprogram/src/components/StepIndicator/index.tsx

  • Create: apps/miniprogram/src/components/StepIndicator/index.scss

  • Step 1: 创建组件

创建 components/StepIndicator/index.tsx

import React from 'react';
import { View, Text } from '@tarojs/components';
import './index.scss';

interface Step {
  label: string;
}

interface StepIndicatorProps {
  steps: Step[];
  current: number;
  onChange?: (index: number) => void;
}

export default function StepIndicator({ steps, current, onChange }: StepIndicatorProps) {
  return (
    <View className='step-indicator'>
      {steps.map((step, idx) => {
        const isCurrent = idx === current;
        const isDone = idx < current;
        const isClickable = isDone && onChange;

        return (
          <View className='step-item' key={step.label}>
            {idx > 0 && (
              <View className={`step-line ${isDone ? 'step-line-done' : ''}`} />
            )}
            <View
              className={`step-dot ${isCurrent ? 'step-current' : ''} ${isDone ? 'step-done' : ''}`}
              onClick={isClickable ? () => onChange(idx) : undefined}
            >
              {isDone ? <Text className='step-check'></Text> : <Text className='step-num'>{idx + 1}</Text>}
            </View>
            <Text className={`step-label ${isCurrent ? 'step-current' : ''} ${isDone ? 'step-done' : ''}`}>
              {step.label}
            </Text>
          </View>
        );
      })}
    </View>
  );
}
  • Step 2: 创建样式
@import '../../styles/variables.scss';

.step-indicator {
  display: flex;
  align-items: center;
  justify-content: space-around;
  padding: 24px 32px;
  background: $card;
}

.step-item {
  display: flex;
  align-items: center;
  gap: 8px;
  flex: 1;
  justify-content: center;
}

.step-line {
  flex: 1;
  height: 4px;
  background: $bd-l;
  transition: background 0.3s ease;

  &.step-line-done {
    background: $acc;
  }
}

.step-dot {
  width: 48px;
  height: 48px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: $bd-l;
  color: $tx3;
  font-size: 24px;
  transition: all 0.3s ease;
}

.step-dot.step-current {
  background: $pri;
  color: white;
}

.step-dot.step-done {
  background: $acc;
  color: white;
}

.step-label {
  font-size: 22px;
  color: $tx3;
  position: absolute;
  margin-top: 60px;
}

.step-label.step-current {
  color: $pri;
  font-weight: bold;
}

.step-label.step-done {
  color: $acc;
}
  • Step 3: 验证构建

Run: cd apps/miniprogram && pnpm build:weapp Expected: 编译通过

  • Step 4: 提交
git add apps/miniprogram/src/components/StepIndicator/
git commit -m "feat(appointment): 新增 StepIndicator 步骤指示器组件"

Task 15: WeekCalendar 周视图日历组件

Files:

  • Create: apps/miniprogram/src/components/WeekCalendar/index.tsx

  • Create: apps/miniprogram/src/components/WeekCalendar/index.scss

  • Step 1: 创建组件

创建 components/WeekCalendar/index.tsx,纯 React 实现的周视图日历:

import React, { useState } from 'react';
import { View, Text } from '@tarojs/components';
import './index.scss';

interface WeekCalendarProps {
  // 有排班的日期集合,格式 'YYYY-MM-DD'
  scheduledDates: Set<string>;
  selectedDate: string;
  onSelectDate: (date: string) => void;
}

function getWeekDates(offset: number): string[] {
  const now = new Date();
  const monday = new Date(now);
  monday.setDate(now.getDate() - now.getDay() + 1 + offset * 7); // 获取周一
  const dates: string[] = [];
  for (let i = 0; i < 7; i++) {
    const d = new Date(monday);
    d.setDate(monday.getDate() + i);
    dates.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
  }
  return dates;
}

const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日'];

export default function WeekCalendar({ scheduledDates, selectedDate, onSelectDate }: WeekCalendarProps) {
  const [weekOffset, setWeekOffset] = useState(0);
  const dates = getWeekDates(weekOffset);
  const today = (() => {
    const d = new Date();
    return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
  })();

  return (
    <View className='week-calendar'>
      <View className='week-nav'>
        <Text className='week-arrow' onClick={() => setWeekOffset(weekOffset - 1)}></Text>
        <Text className='week-label'>{dates[0].slice(5)} ~ {dates[6].slice(5)}</Text>
        <Text className='week-arrow' onClick={() => setWeekOffset(weekOffset + 1)}></Text>
      </View>
      <View className='week-grid'>
        {WEEKDAYS.map((day, idx) => {
          const dateStr = dates[idx];
          const isScheduled = scheduledDates.has(dateStr);
          const isSelected = dateStr === selectedDate;
          const isToday = dateStr === today;
          const isPast = dateStr < today;
          return (
            <View
              className={`week-cell ${isSelected ? 'cell-selected' : ''} ${!isScheduled ? 'cell-empty' : ''} ${isPast ? 'cell-past' : ''}`}
              key={dateStr}
              onClick={isScheduled && !isPast ? () => onSelectDate(dateStr) : undefined}
            >
              <Text className='cell-weekday'>{day}</Text>
              <Text className={`cell-date ${isToday ? 'cell-today' : ''}`}>{parseInt(dateStr.slice(8))}</Text>
              {isScheduled && <View className='cell-dot' />}
            </View>
          );
        })}
      </View>
    </View>
  );
}
  • Step 2: 创建样式
@import '../../styles/variables.scss';

.week-calendar { background: $card; border-radius: 12px; padding: 16px; }
.week-nav { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.week-arrow { font-size: 28px; color: $pri; padding: 0 16px; }
.week-label { font-size: 24px; color: $tx1; font-weight: bold; }
.week-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 8px; text-align: center; }
.week-cell { padding: 8px 4px; border-radius: 8px; position: relative; }
.cell-weekday { font-size: 20px; color: $tx3; display: block; }
.cell-date { font-size: 26px; color: $tx1; display: block; margin-top: 4px; }
.cell-today { color: $pri; font-weight: bold; }
.cell-dot { position: absolute; bottom: 2px; left: 50%; transform: translateX(-50%); width: 8px; height: 8px; background: $acc; border-radius: 50%; }
.cell-selected { background: $pri; border-radius: 12px; }
.cell-selected .cell-date { color: white; }
.cell-selected .cell-dot { background: white; }
.cell-empty .cell-date { color: $bd; }
.cell-past { opacity: 0.4; }
  • Step 3: 验证构建

Run: cd apps/miniprogram && pnpm build:weapp Expected: 编译通过

  • Step 4: 提交
git add apps/miniprogram/src/components/WeekCalendar/
git commit -m "feat(appointment): 新增 WeekCalendar 周视图日历组件"

Task 16: 预约创建页重写

Files:

  • Modify: apps/miniprogram/src/pages/appointment/create/index.tsx
  • Modify: apps/miniprogram/src/pages/appointment/create/index.scss

Depends on: Task 14, Task 15StepIndicator + WeekCalendar 组件)

  • Step 1: 替换步骤指示器

将内联的 step-bar / step-line-wrapper 替换为 <StepIndicator> 组件:

import StepIndicator from '../../../components/StepIndicator';
// ...
<StepIndicator
  steps={[{ label: '选科室' }, { label: '选医生' }, { label: '选时段' }]}
  current={currentStep}
  onChange={(idx) => {
    // 回退时清除下级已选状态
    if (idx < currentStep) {
      if (idx <= 1) setSelectedDoctor(null);
      if (idx <= 2) { setAppointmentDate(''); setTimeSlot(''); }
    }
    setCurrentStep(idx);
  }}
/>
  • Step 2: 科室选择改为宫格卡片

Picker 组件替换为 2×3 宫格卡片布局,每个科室卡片包含图标 + 科室名 + 医生数。选中后高亮边框。

  • Step 3: Step 3 整合 WeekCalendar + 时段卡片

替换 Picker mode='date'<WeekCalendar>,排班数据从 calendarView() API 获取。 需要从排班数据提取排班日期集合传给 WeekCalendar

const scheduledDates = useMemo(() => {
  if (!schedules) return new Set<string>();
  return new Set(schedules.map((s) => s.date));
}, [schedules]);

替换文本输入时段为时段卡片列表,时段卡片按剩余名额着色,名额为 0 时禁用点击:

const getSlotStyle = (available: number) => {
  if (available === 0) return 'slot-full';
  if (available <= 3) return 'slot-few';
  return 'slot-available';
};

// 渲染时段卡片时:
{timeSlots.map((slot) => (
  <View
    className={`slot-card ${getSlotStyle(slot.available_count)} ${selectedSlot === slot.time_slot ? 'slot-selected' : ''}`}
    onClick={slot.available_count > 0 ? () => setSelectedSlot(slot.time_slot) : undefined}
  >
    <Text className='slot-time'>{slot.time_slot}</Text>
    <Text className='slot-count'>{slot.available_count > 0 ? `剩余 ${slot.available_count} 位` : '已满'}</Text>
  </View>
))}
  • Step 4: 清理旧 SCSS

删除 pages/appointment/create/index.scss 中不再使用的旧样式:.step-bar.step-line-wrapper.picker-card 等已被 StepIndicator、WeekCalendar 和时段卡片替换的类。

  • Step 5: 验证构建

Run: cd apps/miniprogram && pnpm build:weapp Expected: 编译通过

  • Step 6: 提交
git add apps/miniprogram/src/pages/appointment/create/
git commit -m "feat(appointment): 预约创建页重写 — 宫格科室+周视图日历+时段卡片"

Task 17: 微信订阅消息(前端引导)

Files:

  • Modify: apps/miniprogram/src/pages/appointment/create/index.tsx
  • Modify: apps/miniprogram/src/pages/followup/detail/index.tsx
  • Modify: apps/miniprogram/src/pages/profile/index.tsx

注意:此 Task 仅实现前端订阅引导 UI。后端订阅消息模板注册和定时推送作为独立后端 Task不在此计划中。后端需先在微信公众平台注册模板获取模板 ID。

  • Step 0: 创建订阅消息模板 ID 常量

创建 services/wechat-templates.ts,集中管理模板 ID

// 后端在微信公众平台注册后填入实际值
// 注册前保持为空数组,订阅调用将自动跳过
export const TEMPLATE_IDS = {
  APPOINTMENT_REMINDER: '', // 预约就诊提醒
  FOLLOWUP_REMINDER: '',   // 随访任务提醒
  REPORT_NOTIFICATION: '', // 报告出具通知
};
  • Step 1: 预约创建成功后订阅引导

pages/appointment/create/index.tsx预约创建成功API 返回成功)后调用 Taro.requestSubscribeMessage

import { TEMPLATE_IDS } from '@/services/wechat-templates';

const requestSubscribe = async () => {
  const tmplId = TEMPLATE_IDS.APPOINTMENT_REMINDER;
  if (!tmplId) return; // 模板未注册时跳过

  try {
    const res = await Taro.requestSubscribeMessage({ tmplIds: [tmplId] });
    if (res[tmplId] === 'accept') {
      console.info('[Subscribe] 用户接受预约提醒订阅');
    }
  } catch {
    console.info('[Subscribe] 用户拒绝订阅');
  }
};

// 在预约创建 API 成功回调中:
// createAppointment(...).then(() => { requestSubscribe(); Taro.navigateTo({ url: '...' }); })
  • Step 2: 随访提交后订阅引导

pages/followup/detail/index.tsx 中,随访记录提交成功后同样调用 Taro.requestSubscribeMessage,使用 TEMPLATE_IDS.FOLLOWUP_REMINDER

  • Step 3: 降级设计 — profile 页消息红点

pages/profile/index.tsx 的菜单项旁增加未读消息数显示(后续对接 erp-message APIMVP 阶段仅预留 UI 位置和数据占位 unreadCount: 0)。

后端 erp-message 集成说明:当用户拒绝订阅时,消息应写入 erp-message 消息中心。此集成需要后端配合:预约创建/随访提交时,无论用户是否订阅微信消息,都同时写入 erp-message。前端后续通过 GET /api/v1/messages?unread=true 获取未读数。本 Task 仅预留 UI 位置。

  • Step 4: 验证构建

Run: cd apps/miniprogram && pnpm build:weapp Expected: 编译通过

  • Step 5: 提交
git add apps/miniprogram/src/
git commit -m "feat(notification): 预约/随访成功后微信订阅消息引导 + 消息红点预留"

Chunk 4: Sprint 3 — 报告/随访/个人中心 + 安全 + 增长

Task 18: 报告详情页指标状态色

Files:

  • Modify: apps/miniprogram/src/pages/report/detail/index.tsx

  • Modify: apps/miniprogram/src/pages/report/detail/index.scss

  • Step 1: 指标卡片按状态着色

修改 pages/report/detail/index.tsxindicators.map 渲染逻辑,根据 item.status 切换卡片背景色和标签:

const getStatusDisplay = (status?: string) => {
  switch (status) {
    case 'high': return { text: '↑ 偏高', cls: 'status-high' };
    case 'low': return { text: '↓ 偏低', cls: 'status-low' };
    default: return { text: '✓ 正常', cls: 'status-normal' };
  }
};

// 在渲染中:
<View className={`indicator-item ${statusDisplay.cls}`}>
  • Step 2: 添加汇总标签

在指标列表上方添加汇总:

const abnormalCount = indicators.filter((i) => i.status === 'high' || i.status === 'low').length;
const normalCount = indicators.length - abnormalCount;
// ...
<View className='indicator-summary'>
  {abnormalCount > 0 && <Text className='tag-abnormal'>{abnormalCount} 项异常</Text>}
  {normalCount > 0 && <Text className='tag-normal'>{normalCount} 项正常</Text>}
</View>
  • Step 3: 添加 SCSS 样式
.indicator-item {
  // 已有样式基础上:
  &.status-normal { background: #F0FDF4; border-left: 4px solid $acc; }
  &.status-high { background: #FEF2F2; border-left: 4px solid $dan; }
  &.status-low { background: #FEF2F2; border-left: 4px solid $dan; }
}
.indicator-status-text {
  &.status-normal { color: $acc; }
  &.status-high, &.status-low { color: $dan; font-weight: bold; }
}
.indicator-summary { display: flex; gap: 16px; margin-bottom: 20px; }
.tag-abnormal { background: $dan; color: white; padding: 4px 16px; border-radius: 20px; font-size: 22px; }
.tag-normal { background: $acc; color: white; padding: 4px 16px; border-radius: 20px; font-size: 22px; }
  • Step 4: 验证构建

Run: cd apps/miniprogram && pnpm build:weapp

  • Step 5: 提交
git add apps/miniprogram/src/pages/report/detail/
git commit -m "feat(report): 报告详情指标按状态着色 + 异常汇总标签"

Task 19: 随访 UX 细节

Files:

  • Modify: apps/miniprogram/src/pages/profile/followups/index.tsx

  • Modify: apps/miniprogram/src/pages/profile/followups/index.scss

  • Modify: apps/miniprogram/src/pages/followup/detail/index.tsx

  • Modify: apps/miniprogram/src/pages/followup/detail/index.scss

  • Step 1: 任务卡片增加截止日期倒计时 + 过期变灰

修改 pages/profile/followups/index.tsx,在 task-due 后增加倒计时计算:

const getDueCountdown = (dueDate: string) => {
  const now = new Date();
  // 避免时区偏移:只比较日期部分
  const [y, m, d] = dueDate.split('-').map(Number);
  const due = new Date(y, m - 1, d + 1); // 设为次日 0 点,确保当天算"今天截止"
  const diffDays = Math.ceil((due.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
  if (diffDays < 0) return { text: `已过期 ${Math.abs(diffDays)} 天`, cls: 'overdue' };
  if (diffDays === 0) return { text: '今天截止', cls: 'urgent' };
  if (diffDays <= 3) return { text: `还剩 ${diffDays} 天`, cls: 'urgent' };
  return { text: `还剩 ${diffDays} 天`, cls: 'normal' };
};

pages/profile/followups/index.scss 中添加过期任务灰色样式:

.task-card.overdue { opacity: 0.5; }
.task-card.overdue .task-title { text-decoration: line-through; color: $tx3; }
  • Step 2: 提交成功确认动画

修改 pages/followup/detail/index.tsx,提交成功后展示 checkmark 动画。 在 pages/followup/detail/index.scss 中添加动画样式:

const [showSuccess, setShowSuccess] = useState(false);

// handleSubmit 成功后:
setShowSuccess(true);
setTimeout(() => setShowSuccess(false), 2000);

// 渲染:
{showSuccess && (
  <View className='success-overlay'>
    <View className='success-check'></View>
    <Text className='success-text'>提交成功</Text>
  </View>
)}
.success-overlay {
  position: fixed; top: 0; left: 0; right: 0; bottom: 0;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  background: rgba(255,255,255,0.9); z-index: 999;
}
.success-check {
  font-size: 80px; color: $acc;
  animation: checkScale 0.4s ease-out;
}
@keyframes checkScale {
  0% { transform: scale(0); }
  50% { transform: scale(1.2); }
  100% { transform: scale(1); }
}
  • Step 3: 验证构建 + 提交
cd apps/miniprogram && pnpm build:weapp
git add apps/miniprogram/src/pages/profile/followups/ apps/miniprogram/src/pages/followup/detail/
git commit -m "feat(followup): 截止日期倒计时 + 提交成功确认动画"

Task 20: 个人中心改进 — 用药提醒 + 就诊人编辑

Files:

  • Modify: apps/miniprogram/src/pages/profile/medication/index.tsx

  • Modify: apps/miniprogram/src/pages/profile/family/index.tsx

  • Modify: apps/miniprogram/src/pages/profile/family-add/index.tsx

  • Step 1: 用药提醒时间选择器

修改 pages/profile/medication/index.tsx,在 @tarojs/components 导入中增加 PickerSwitch

import { View, Text, Input, Picker, Switch } from '@tarojs/components';

将静态时间文本替换为 Picker mode='time'

<Picker mode='time' value={med.time} onChange={(e) => updateMedicine(index, 'time', e.detail.value)}>
  <View className='time-picker'>
    <Text>{med.time || '选择时间'}</Text>
    <Text className='picker-arrow'></Text>
  </View>
</Picker>

增加 enabled 开关:

<Switch checked={med.enabled} onChange={(e) => updateMedicine(index, 'enabled', e.detail.value)} />
  • Step 2: 就诊人编辑功能

修改 pages/profile/family/index.tsx,每个就诊人卡片增加"编辑"按钮,点击后 navigateTo 到 family-add 页面只传 id

const handleEdit = (patient: Patient) => {
  Taro.navigateTo({
    url: `/pages/profile/family-add/index?id=${patient.id}`,
  });
};

修改 pages/profile/family-add/index.tsx

  1. services/patient.ts 额外导入 updatePatient
  2. useEffect 中检测 router.params.id,如有则通过 listPatients() 或按 ID 查询获取完整数据(包括 version 字段),预填表单
  3. 编辑模式下提交调用 updatePatient(id, data, version) 而非 createPatient
  • Step 3: 验证构建 + 提交
cd apps/miniprogram && pnpm build:weapp
git add apps/miniprogram/src/pages/profile/
git commit -m "feat(profile): 用药提醒时间选择器 + 就诊人编辑功能"

Task 21: Token 轻量混淆存储

Files:

  • Create: apps/miniprogram/src/utils/crypto.ts

  • Modify: apps/miniprogram/src/stores/auth.ts

  • Modify: apps/miniprogram/src/services/request.ts

  • Step 1: 创建 crypto 工具

创建 utils/crypto.ts

import Taro from '@tarojs/taro';

const KEY_STORAGE = '_hms_crypto_key';

// 兼容小程序环境的 Base64 编解码
function base64Encode(str: string): string {
  // JWT token 仅含 ASCII 字符XOR 后仍为 Latin-1可安全用 charCodeAt
  const bytes = Array.from(str, (c) => c.charCodeAt(0));
  // 小程序可能无 btoa使用手动编码
  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
  let result = '';
  for (let i = 0; i < bytes.length; i += 3) {
    const b0 = bytes[i], b1 = bytes[i + 1] ?? 0, b2 = bytes[i + 2] ?? 0;
    result += chars[b0 >> 2] + chars[((b0 & 3) << 4) | (b1 >> 4)];
    result += (i + 1 < bytes.length) ? chars[((b1 & 15) << 2) | (b2 >> 6)] : '=';
    result += (i + 2 < bytes.length) ? chars[b2 & 63] : '=';
  }
  return result;
}

function base64Decode(encoded: string): string {
  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
  const lookup = Object.fromEntries(Array.from(chars, (c, i) => [c, i]));
  const bytes: number[] = [];
  for (let i = 0; i < encoded.length; i += 4) {
    const b0 = lookup[encoded[i]] ?? 0, b1 = lookup[encoded[i + 1]] ?? 0;
    const b2 = lookup[encoded[i + 2]] ?? 0, b3 = lookup[encoded[i + 3]] ?? 0;
    bytes.push((b0 << 2) | (b1 >> 4), ((b1 & 15) << 4) | (b2 >> 2), ((b2 & 3) << 6) | b3);
  }
  // 移除 padding 对应的多余字节
  if (encoded.endsWith('==')) bytes.length -= 2;
  else if (encoded.endsWith('=')) bytes.length -= 1;
  return String.fromCharCode(...bytes);
}

function getKey(): string {
  let key = Taro.getStorageSync(KEY_STORAGE);
  if (!key) {
    const arr = new Uint8Array(16);
    if (typeof wx !== 'undefined' && wx.getRandomValues) {
      wx.getRandomValues(arr);
    } else {
      for (let i = 0; i < 16; i++) arr[i] = Math.floor(Math.random() * 256);
    }
    key = Array.from(arr, (b) => b.toString(16).padStart(2, '0')).join('');
    Taro.setStorageSync(KEY_STORAGE, key);
  }
  return key;
}

export function encrypt(value: string): string {
  if (!value) return '';
  const key = getKey();
  const encrypted = Array.from(value)
    .map((char, i) => String.fromCharCode(char.charCodeAt(0) ^ key.charCodeAt(i % key.length)))
    .join('');
  return base64Encode(encrypted);
}

export function decrypt(encoded: string): string {
  if (!encoded) return '';
  const key = getKey();
  try {
    const decoded = base64Decode(encoded);
    return Array.from(decoded)
      .map((char, i) => String.fromCharCode(char.charCodeAt(0) ^ key.charCodeAt(i % key.length)))
      .join('');
  } catch {
    return '';
  }
}
  • Step 2: auth store 集成加密层

修改 stores/auth.tstoken 的 Storage 读写走加密:

import { encrypt, decrypt } from '@/utils/crypto';

// restore 方法:
const encryptedToken = Taro.getStorageSync('access_token') || '';
const token = encryptedToken ? decrypt(encryptedToken) : null;

// login/bindPhone 成功后:
Taro.setStorageSync('access_token', encrypt(access_token));

// logout:
Taro.removeStorageSync('_hms_crypto_key'); // 连密钥一起清除
  • Step 2.5: 修复 request.ts Token 读取绕过(关键)

services/request.ts 直接从 Storage 读取 access_token,绕过了加密层。必须同步修改。

修改 services/request.tsgetHeaders 函数(第 13 行附近):

import { decrypt } from '@/utils/crypto';

// getHeaders 中:
const encryptedToken = Taro.getStorageSync('access_token');
const token = encryptedToken ? decrypt(encryptedToken) : '';

修改 tryRefreshToken 函数中的 token 读写(第 23、32-33 行),同样通过 encrypt/decrypt 处理。

  • Step 3: 验证构建 + 提交
cd apps/miniprogram && pnpm build:weapp
git add apps/miniprogram/src/utils/crypto.ts apps/miniprogram/src/stores/auth.ts
git commit -m "feat(security): Token 存储增加 XOR 混淆加密"

Task 22: 后端手机号真实解密

Files:

  • Modify: crates/erp-auth/src/service/wechat_service.rs
  • Modify: apps/miniprogram/src/services/auth.ts(如果切换到 code 模式)

前置决策(执行前必须确定):微信提供两种手机号获取方式:

  • 方案 A推荐:前端用 getPhoneNumber 获取 code,后端调用微信 getPhoneNumber API 用 code 换手机号。前端需改为发送 code 字段。
  • 方案 B:前端发送 encryptedData + iv,后端用登录时保存的 session_key 做 AES-CBC 解密。需在登录时持久化 session_key

推荐方案 A:微信新版 API 更简洁,不需要管理 session_key。以下步骤基于方案 A。

  • Step 0: 前端改造(如选方案 A

修改 services/auth.tsbindPhone 方法,改为发送 code 字段:

// 旧: { openid, encrypted_data, iv }
// 新: { openid, code }  // code 来自 getPhoneNumber 事件

同步修改 pages/login/index.tsxhandleGetPhone,从事件中提取 code

const handleGetPhone = async (e) => {
  if (e.detail.code) {
    await authApi.bindPhone({ openid, code: e.detail.code });
  }
};
  • Step 1: 实现微信手机号解密

修改 wechat_service.rsbind_phone 方法(第 74-129 行),替换第 82 行的硬编码 "13800000000"

// 方案 A: 用 code 调用微信 API
// 1. 使用 appid + secret 获取 access_token可缓存 2 小时)
// 2. POST https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=xxx
//    body: { "code": code }
// 3. 解析 response.info.phoneNumber
  • Step 2: 验证

Run: cargo check -p erp-auth Expected: 编译通过

  • Step 3: 提交
git add crates/erp-auth/src/service/wechat_service.rs
git commit -m "fix(auth): 替换手机号硬编码为微信真实解密"

Task 23: 用户协议与隐私政策

Files:

  • Create: apps/miniprogram/src/pages/agreement/index.tsx

  • Create: apps/miniprogram/src/pages/agreement/index.scss

  • Modify: apps/miniprogram/src/pages/login/index.tsx

  • Modify: apps/miniprogram/src/app.config.ts

  • Step 1: 创建协议页面

创建 pages/agreement/index.tsx,接收 type 参数(termsprivacy展示对应协议文本。文本内容硬编码在页面中MVP 阶段,后续可改为后端管理)。

  • Step 2: 登录页增加协议勾选

修改 pages/login/index.tsx,在登录按钮上方增加协议勾选(确保用户点击前可见):

const [agreed, setAgreed] = useState(false);

<View className='agreement-row'>
  <View className='agreement-check' onClick={() => setAgreed(!agreed)}>
    <Text>{agreed ? '☑' : '☐'}</Text>
  </View>
  <Text className='agreement-text'>
    阅读并同意
    <Text className='agreement-link' onClick={() => Taro.navigateTo({ url: '/pages/agreement/index?type=terms' })}>《用户协议》</Text>
    
    <Text className='agreement-link' onClick={() => Taro.navigateTo({ url: '/pages/agreement/index?type=privacy' })}>《隐私政策》</Text>
  </Text>
</View>

handleWechatLoginhandleGetPhone 开头增加 if (!agreed) 检查。

  • Step 3: 注册路由

app.config.ts 的 pages 数组中添加 'pages/agreement/index'

  • Step 4: 验证构建 + 提交
cd apps/miniprogram && pnpm build:weapp
git add apps/miniprogram/src/
git commit -m "feat(login): 新增用户协议/隐私政策页面 + 登录前强制勾选"

Task 24: 数据埋点 service

Files:

  • Create: apps/miniprogram/src/services/analytics.ts

  • Modify: apps/miniprogram/src/app.tsx

  • Step 1: 创建 analytics service

创建 services/analytics.ts

import Taro from '@tarojs/taro';

const STORAGE_KEY = 'analytics_events';
const MAX_EVENTS = 100;

type AnalyticsEvent =
  | { type: 'page_view'; page: string; entered_at: number }
  | { type: 'feature_use'; feature: string; action: string }
  | { type: 'error'; message: string; stack?: string; timestamp: number };

function getEvents(): AnalyticsEvent[] {
  return Taro.getStorageSync(STORAGE_KEY) || [];
}

function saveEvent(event: AnalyticsEvent) {
  const events = getEvents();
  events.push(event);
  // 保留最近 100 条
  const trimmed = events.slice(-MAX_EVENTS);
  Taro.setStorageSync(STORAGE_KEY, trimmed);
  console.info('[Analytics]', event);
}

export function trackPageView(page: string) {
  saveEvent({ type: 'page_view', page, entered_at: Date.now() });
}

export function trackFeatureUse(feature: string, action: string) {
  saveEvent({ type: 'feature_use', feature, action });
}

export function trackError(message: string, stack?: string) {
  saveEvent({ type: 'error', message, stack, timestamp: Date.now() });
}
  • Step 2: 核心页面接入埋点

在 3 个核心页面中添加 trackPageView 调用Taro App 无法全局监听页面切换,需手动调用):

修改 pages/index/index.tsx

import { trackPageView } from '@/services/analytics';
import { useDidShow } from '@tarojs/taro';
// 在组件中:
useDidShow(() => { trackPageView('home'); });

修改 pages/health/index.tsx

useDidShow(() => { trackPageView('health'); });

修改 pages/login/index.tsx

useDidShow(() => { trackPageView('login'); });
  • Step 3: 验证构建 + 提交
cd apps/miniprogram && pnpm build:weapp
git add apps/miniprogram/src/services/analytics.ts apps/miniprogram/src/pages/index/ apps/miniprogram/src/pages/health/ apps/miniprogram/src/pages/login/
git commit -m "feat(analytics): 新增轻量数据埋点 service + 核心页面接入"

Task 25: 文章分享能力

Files:

  • Modify: apps/miniprogram/src/pages/article/detail/index.tsx

  • Step 1: 添加 onShareAppMessage

pages/article/detail/index.tsx 中添加分享 hooks。Hooks 必须在组件顶层无条件调用,在回调中处理 null

import { useShareAppMessage, useShareTimeline } from '@tarojs/taro';

// 在组件函数体顶部(不能放在条件语句内):
useShareAppMessage(() => ({
  title: article?.title || '健康资讯',
  path: `/pages/article/detail/index?id=${id}`,
}));

useShareTimeline(() => ({
  title: article?.title || '健康资讯',
  query: `id=${id}`,
}));

注意:需确认 @tarojs/taro 在项目使用的 Taro 4.x 版本中导出了 useShareAppMessageuseShareTimeline。如不可用,改用页面配置 enableShareAppMessage: true + onShareAppMessage 生命周期。

  • Step 2: 验证构建 + 提交
cd apps/miniprogram && pnpm build:weapp
git add apps/miniprogram/src/pages/article/detail/
git commit -m "feat(article): 文章详情支持微信好友/朋友圈分享"