# 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:** 基于 Taro 4 + React 19 构建微信小程序患者端,直连 erp-server 后端 API。 **Architecture:** 小程序代码位于 `apps/miniprogram/`,通过 Taro 编译为微信小程序。后端在 erp-auth 新增微信登录支持,erp-health 新增小程序所需 API。前端用 Zustand 管理状态,services 层封装 Taro.request 调用后端 `/api/v1/` 端点。 **Tech Stack:** Taro 4, React 19, TypeScript 6, Zustand 5, SCSS, echarts-taro3-react, Rust/Axum/SeaORM (后端) **Spec:** `docs/superpowers/specs/2026-04-23-hms-miniprogram-design.md` --- ## Chunk 1: Phase 1 — 后端微信认证 ### Task 1: 创建 wechat_users 数据库迁移 **Files:** - Create: `crates/erp-server/migration/src/m20260423_000001_wechat_users.rs` - [ ] **Step 1: 创建迁移文件** ```rust // m20260423_000001_wechat_users.rs use sea_orm_migration::prelude::*; #[derive(DeriveMigrationAction)] pub struct Migration; #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .create_table( Table::create() .table(WechatUsers::Table) .if_not_exists() .col(ColumnDef::new(WechatUsers::Id).uuid().not_null().primary_key()) .col(ColumnDef::new(WechatUsers::TenantId).uuid().not_null()) .col(ColumnDef::new(WechatUsers::Openid).string().not_null()) .col(ColumnDef::new(WechatUsers::UnionId).string()) .col(ColumnDef::new(WechatUsers::UserId).uuid().not_null()) .col(ColumnDef::new(WechatUsers::Phone).string()) .col(ColumnDef::new(WechatUsers::CreatedAt).timestamp_with_time_zone().not_null().default(Expr::current_timestamp())) .col(ColumnDef::new(WechatUsers::UpdatedAt).timestamp_with_time_zone().not_null().default(Expr::current_timestamp())) .col(ColumnDef::new(WechatUsers::DeletedAt).timestamp_with_time_zone()) .to_owned(), ) .await?; manager .create_index( Index::create() .name("idx_wechat_users_openid") .table(WechatUsers::Table) .col(WechatUsers::Openid) .col(WechatUsers::TenantId) .unique() .to_owned(), ) .await } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager.drop_index(Index::drop().name("idx_wechat_users_openid").table(WechatUsers::Table).to_owned()).await?; manager.drop_table(Table::drop().table(WechatUsers::Table).to_owned()).await } } #[derive(DeriveIden)] enum WechatUsers { Table, Id, TenantId, Openid, UnionId, UserId, Phone, CreatedAt, UpdatedAt, DeletedAt, } ``` - [ ] **Step 2: 注册迁移** 在 `crates/erp-server/migration/src/lib.rs` 的 migration 列表中添加新迁移。 - [ ] **Step 3: 验证迁移** Run: `cargo check -p erp-server` Expected: 编译通过 - [ ] **Step 4: 提交** ```bash git add crates/erp-server/migration/src/m20260423_000001_wechat_users.rs crates/erp-server/migration/src/lib.rs git commit -m "feat(auth): 添加 wechat_users 表迁移" ``` --- ### Task 2: 创建 wechat_users Entity **Files:** - Create: `crates/erp-auth/src/entity/wechat_user.rs` - Modify: `crates/erp-auth/src/entity/mod.rs` - [ ] **Step 1: 创建 Entity 文件** 参照 `crates/erp-auth/src/entity/user_token.rs` 的模式,创建 `wechat_user.rs`,包含 `Model`、`Relation`、`ActiveModelBehavior`。 - [ ] **Step 2: 在 mod.rs 注册** 在 `crates/erp-auth/src/entity/mod.rs` 添加 `pub mod wechat_user;`。 - [ ] **Step 3: 验证** Run: `cargo check -p erp-auth` Expected: 编译通过 - [ ] **Step 4: 提交** ```bash git add crates/erp-auth/src/entity/wechat_user.rs crates/erp-auth/src/entity/mod.rs git commit -m "feat(auth): 添加 wechat_user SeaORM entity" ``` --- ### Task 3: 微信登录 DTO **Files:** - Modify: `crates/erp-auth/src/dto.rs` - [ ] **Step 1: 添加微信登录相关 DTO** 在 `crates/erp-auth/src/dto.rs` 中新增: ```rust // 微信登录请求 #[derive(Deserialize, ToSchema)] pub struct WechatLoginReq { pub code: String, } // 微信登录响应 #[derive(Serialize, ToSchema)] pub struct WechatLoginResp { pub bound: bool, pub openid: String, pub token: Option, // 已绑定时返回 } // 绑定手机号请求 #[derive(Deserialize, ToSchema)] pub struct WechatBindPhoneReq { pub openid: String, pub encrypted_data: String, pub iv: String, } ``` - [ ] **Step 2: 验证** Run: `cargo check -p erp-auth` - [ ] **Step 3: 提交** ```bash git add crates/erp-auth/src/dto.rs git commit -m "feat(auth): 添加微信登录 DTO" ``` --- ### Task 4: 微信登录 Service **Files:** - Create: `crates/erp-auth/src/service/wechat_service.rs` - Modify: `crates/erp-auth/src/service/mod.rs` - [ ] **Step 1: 实现 wechat_service.rs** 核心逻辑: - `login(state, tenant_id, code)`: 用 code 调用微信 API 换 openid → 查 wechat_users 表 → 返回绑定状态 - `bind_phone(state, tenant_id, req)`: 解密手机号 → 查找/创建 user → 创建 wechat_user 记录 → 签发 JWT - 微信 API 调用:`GET https://api.weixin.qq.com/sns/jscode2session?appid=...&secret=...&js_code={code}&grant_type=authorization_code` 注意:appid 和 secret 从 AppConfig 中读取,需在 erp-server 配置中新增 `wechat` 段。 - [ ] **Step 2: 在 mod.rs 注册** 添加 `pub mod wechat_service;` - [ ] **Step 3: 验证** Run: `cargo check -p erp-auth` - [ ] **Step 4: 提交** ```bash git add crates/erp-auth/src/service/wechat_service.rs crates/erp-auth/src/service/mod.rs git commit -m "feat(auth): 实现微信登录/绑定手机号 service" ``` --- ### Task 5: 微信登录 Handler + 路由注册 **Files:** - Create: `crates/erp-auth/src/handler/wechat_handler.rs` - Modify: `crates/erp-auth/src/handler/mod.rs` - Modify: `crates/erp-auth/src/lib.rs` (路由注册) - [ ] **Step 1: 创建 handler** ```rust // wechat_handler.rs pub async fn wechat_login(State(state): State, ...) -> Result>, AppError> pub async fn wechat_bind_phone(State(state): State, ...) -> Result>, AppError> ``` - [ ] **Step 2: 注册公开路由** 微信登录端点是公开的(无需 JWT),在 `AuthModule::public_routes()` 中添加: - `POST /auth/wechat/login` - `POST /auth/wechat/bind-phone` - [ ] **Step 3: 在 AppConfig 中添加微信配置** 在 `crates/erp-server/src/config.rs` 中新增: ```rust pub wechat: WechatConfig, pub struct WechatConfig { pub appid: String, pub secret: String } ``` - [ ] **Step 4: 验证** Run: `cargo check -p erp-server` - [ ] **Step 5: 提交** ```bash git add crates/erp-auth/src/handler/wechat_handler.rs crates/erp-auth/src/handler/mod.rs crates/erp-auth/src/lib.rs crates/erp-server/src/config.rs git commit -m "feat(auth): 添加微信登录 handler 和公开路由" ``` --- ### Task 6: 验证微信登录端到端 - [ ] **Step 1: 启动后端** Run: `cargo run -p erp-server` - [ ] **Step 2: 测试端点** 用 curl 测试: ```bash curl -X POST http://localhost:3000/api/v1/auth/wechat/login -H "Content-Type: application/json" -d '{"code":"test_code"}' ``` Expected: 返回 `{ "success": true, "data": { "bound": false, "openid": "..." } }` 或类似响应 - [ ] **Step 3: 确认迁移执行** 检查 `wechat_users` 表已创建: ```bash PGPASSWORD=123123 "D:\postgreSQL\bin\psql.exe" -U postgres -h localhost -d erp -c "\dt wechat_users" ``` - [ ] **Step 4: 提交确认** 如果有修复: ```bash git add -A && git commit -m "fix(auth): 微信登录端到端验证修复" ``` --- ## Chunk 2: Phase 1 — 小程序项目骨架 + 登录 + 首页 ### Task 7: 初始化 Taro 项目 - [ ] **Step 1: 用 Taro CLI 初始化项目** ```bash cd apps npx @tarojs/cli init miniprogram --template react-ts ``` 选择: - 框架:React - TypeScript:Yes - CSS 预处理:Sass - 模板:默认模板 - 编译工具:Webpack5 - [ ] **Step 2: 安装核心依赖** ```bash cd apps/miniprogram pnpm add zustand pnpm add echarts echarts-taro3-react ``` - [ ] **Step 3: 配置 project.config.json** 设置 appid(先用测试号 `touristappid`)、ES6 转 ES5、增强编译等。 - [ ] **Step 4: 验证** ```bash cd apps/miniprogram && pnpm dev:weapp ``` 用微信开发者工具打开 `dist/` 目录,确认空白小程序可运行。 - [ ] **Step 5: 提交** ```bash git add apps/miniprogram/ git commit -m "feat(miniprogram): 初始化 Taro 4 + React 项目骨架" ``` --- ### Task 8: 全局样式 + 主题变量 **Files:** - Modify: `apps/miniprogram/src/app.scss` - Create: `apps/miniprogram/src/styles/variables.scss` - Create: `apps/miniprogram/src/styles/mixins.scss` - [ ] **Step 1: 创建主题变量** `src/styles/variables.scss`: ```scss $pri: #0891B2; $pri-l: #E0F7FA; $pri-d: #065A73; $pri-surface: #ECFEFF; $acc: #059669; $acc-l: #D1FAE5; $bg: #F0FDFA; $card: #FFFFFF; $tx: #134E4A; $tx2: #6B7280; $tx3: #94A3B8; $bd: #E5E7EB; $bd-l: #F3F4F6; $dan: #DC2626; $dan-l: #FEE2E2; $wrn: #D97706; $wrn-l: #FEF3C7; $r: 12px; $r-sm: 8px; $r-lg: 16px; ``` - [ ] **Step 2: 创建常用 mixins** `src/styles/mixins.scss`: ```scss @mixin card { background: $card; border-radius: $r; box-shadow: 0 2px 8px rgba(0,0,0,.06); padding: 16px; margin: 0 16px 12px; } ``` - [ ] **Step 3: 更新 app.scss** 导入变量和全局基础样式(字体、背景色)。 - [ ] **Step 4: 提交** ```bash git add apps/miniprogram/src/styles/ apps/miniprogram/src/app.scss git commit -m "feat(miniprogram): 添加医疗清新主题样式变量" ``` --- ### Task 9: services/request.ts — API 请求层 **Files:** - Create: `apps/miniprogram/src/services/request.ts` - [ ] **Step 1: 实现 request 封装** ```typescript import Taro from '@tarojs/taro'; const BASE_URL = process.env.TARO_APP_API_URL || 'http://localhost:3000/api/v1'; interface ApiResponse { success: boolean; data?: T; message?: string; } async function getHeaders(): Promise> { const headers: Record = { 'Content-Type': 'application/json' }; const token = Taro.getStorageSync('access_token'); if (token) headers['Authorization'] = `Bearer ${token}`; const patientId = Taro.getStorageSync('current_patient_id'); if (patientId) headers['X-Patient-Id'] = patientId; const tenantId = Taro.getStorageSync('tenant_id'); if (tenantId) headers['X-Tenant-Id'] = tenantId; return headers; } export async function request(method: string, path: string, data?: unknown): Promise { const headers = await getHeaders(); const res = await Taro.request({ url: `${BASE_URL}${path}`, method, data, header: headers }); if (res.statusCode === 401) { const refreshed = await tryRefreshToken(); if (refreshed) return request(method, path, data); Taro.redirectTo({ url: '/pages/login/index' }); throw new Error('登录已过期'); } const body = res.data as ApiResponse; if (!body.success) throw new Error(body.message || '请求失败'); return body.data as T; } async function tryRefreshToken(): Promise { const refreshToken = Taro.getStorageSync('refresh_token'); if (!refreshToken) return false; try { const res = await Taro.request({ url: `${BASE_URL}/auth/refresh`, method: 'POST', data: { refresh_token: refreshToken }, }); if (res.statusCode === 200 && res.data?.success) { Taro.setStorageSync('access_token', res.data.data.access_token); Taro.setStorageSync('refresh_token', res.data.data.refresh_token); return true; } } catch {} Taro.removeStorageSync('access_token'); Taro.removeStorageSync('refresh_token'); return false; } export const api = { get: (path: string) => request('GET', path), post: (path: string, data?: unknown) => request('POST', path, data), put: (path: string, data?: unknown) => request('PUT', path, data), delete: (path: string) => request('DELETE', path), }; ``` - [ ] **Step 2: 提交** ```bash git add apps/miniprogram/src/services/request.ts git commit -m "feat(miniprogram): 实现 API 请求层封装" ``` --- ### Task 10: services/auth.ts + auth store **Files:** - Create: `apps/miniprogram/src/services/auth.ts` - Create: `apps/miniprogram/src/stores/auth.ts` - [ ] **Step 1: auth service** ```typescript // services/auth.ts import { api } from './request'; export interface LoginResp { bound: boolean; openid: string; token?: { access_token: string; refresh_token: string; user: UserInfo }; } export interface UserInfo { id: string; name: string; phone: string; avatar?: string; tenant_id: string; } export interface PatientInfo { id: string; name: string; gender?: string; birthday?: string; relation: string; } export async function wechatLogin(code: string): Promise { return api.post('/auth/wechat/login', { code }); } export async function wechatBindPhone(openid: string, encryptedData: string, iv: string) { return api.post('/auth/wechat/bind-phone', { openid, encrypted_data: encryptedData, iv }); } export async function getPatients() { return api.get('/health/patients'); } ``` - [ ] **Step 2: auth store** 参照 Web 端 `stores/auth.ts` 模式,使用 `Taro.getStorageSync` / `setStorageSync` 替代 `localStorage`。 State: `token, refreshToken, user, currentPatient, patients, loading` Actions: `login(), bindPhone(), setCurrentPatient(), logout()` - [ ] **Step 3: 提交** ```bash git add apps/miniprogram/src/services/auth.ts apps/miniprogram/src/stores/auth.ts git commit -m "feat(miniprogram): 实现 auth service 和 store" ``` --- ### Task 11: 登录页 **Files:** - Create: `apps/miniprogram/src/pages/login/index.tsx` - Create: `apps/miniprogram/src/pages/login/index.scss` - [ ] **Step 1: 实现登录页 UI** 登录页包含: - 品牌标识(Logo + "健康管理" 标题) - 微信一键登录按钮(调用 `wx.login` → `wechatLogin(code)`) - 如果返回 `bound: false`,显示手机号授权按钮(使用 `