diff --git a/apps/miniprogram/src/pages/health/daily-monitoring/index.tsx b/apps/miniprogram/src/pages/health/daily-monitoring/index.tsx index 877763a..a6fdb08 100644 --- a/apps/miniprogram/src/pages/health/daily-monitoring/index.tsx +++ b/apps/miniprogram/src/pages/health/daily-monitoring/index.tsx @@ -1,11 +1,17 @@ import { useState } from 'react'; import { View, Text, Input, Picker } from '@tarojs/components'; import Taro, { useDidShow } from '@tarojs/taro'; +import { z } from 'zod'; import { createDailyMonitoring } from '@/services/health'; import { useAuthStore } from '@/stores/auth'; import { trackEvent } from '@/services/analytics'; import './index.scss'; +const bpSchema = z.number().min(30, '血压值不能低于30').max(300, '血压值不能高于300').optional(); +const weightSchema = z.number().min(1, '体重不能低于1kg').max(500, '体重不能高于500kg').optional(); +const bloodSugarSchema = z.number().min(0.1, '血糖值不能低于0.1').max(50, '血糖值不能高于50').optional(); +const volumeSchema = z.number().min(0, '数值不能为负').max(10000, '数值超出合理范围').optional(); + function formatDate(date: Date): string { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); @@ -81,6 +87,40 @@ export default function DailyMonitoring() { return; } + // Zod 验证数值范围 + const parseNum = (v: string) => v ? parseFloat(v) : undefined; + const fields = { + morningSystolic: parseNum(morningSystolic), + morningDiastolic: parseNum(morningDiastolic), + eveningSystolic: parseNum(eveningSystolic), + eveningDiastolic: parseNum(eveningDiastolic), + weight: parseNum(weight), + bloodSugar: parseNum(bloodSugar), + fluidIntake: parseNum(fluidIntake), + urineOutput: parseNum(urineOutput), + }; + + const validations: Array<[z.ZodTypeAny, number | undefined, string]> = [ + [bpSchema, fields.morningSystolic, '晨起收缩压'], + [bpSchema, fields.morningDiastolic, '晨起舒张压'], + [bpSchema, fields.eveningSystolic, '晚间收缩压'], + [bpSchema, fields.eveningDiastolic, '晚间舒张压'], + [weightSchema, fields.weight, '体重'], + [bloodSugarSchema, fields.bloodSugar, '血糖'], + [volumeSchema, fields.fluidIntake, '饮水量'], + [volumeSchema, fields.urineOutput, '尿量'], + ]; + + for (const [schema, value, label] of validations) { + if (value !== undefined) { + const result = schema.safeParse(value); + if (!result.success) { + Taro.showToast({ title: `${label}: ${result.error.errors[0].message}`, icon: 'none' }); + return; + } + } + } + setSubmitting(true); try { await createDailyMonitoring({ diff --git a/apps/miniprogram/src/pages/mall/index.tsx b/apps/miniprogram/src/pages/mall/index.tsx index b98b2cd..4277eca 100644 --- a/apps/miniprogram/src/pages/mall/index.tsx +++ b/apps/miniprogram/src/pages/mall/index.tsx @@ -8,6 +8,7 @@ import { listProducts, } from '../../services/points'; import type { PointsAccount, PointsProduct, CheckinStatus } from '../../services/points'; +import { useAuthStore } from '../../stores/auth'; import EmptyState from '../../components/EmptyState'; import Loading from '../../components/Loading'; import './index.scss'; @@ -26,6 +27,7 @@ const TYPE_COLORS: Record = { }; export default function Mall() { + const { currentPatient } = useAuthStore(); const [account, setAccount] = useState(null); const [checkinStatus, setCheckinStatus] = useState(null); const [products, setProducts] = useState([]); @@ -34,9 +36,15 @@ export default function Mall() { const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); const [checkinLoading, setCheckinLoading] = useState(false); + const [noProfile, setNoProfile] = useState(false); const loadingRef = useRef(false); const fetchAccountAndCheckin = useCallback(async () => { + if (!currentPatient) { + setNoProfile(true); + return; + } + setNoProfile(false); try { const [acct, status] = await Promise.all([ getAccount(), @@ -45,9 +53,9 @@ export default function Mall() { setAccount(acct); setCheckinStatus(status); } catch { - // 账户可能尚未创建,静默处理 + // 账户可能尚未创建 } - }, []); + }, [currentPatient]); const fetchProducts = useCallback( async (pageNum: number, type: string, isRefresh = false) => { @@ -144,6 +152,21 @@ export default function Mall() { return ( + {/* 未关联患者档案时显示引导 */} + {noProfile && ( + + Taro.navigateTo({ url: '/pages/profile/family-add/index' })} + /> + + )} + + {!noProfile && ( + <> {/* 积分余额卡片 */} @@ -239,6 +262,8 @@ export default function Mall() { )} )} + + )} ); } diff --git a/apps/miniprogram/src/services/analytics.ts b/apps/miniprogram/src/services/analytics.ts index a31a4d5..7cf7b67 100644 --- a/apps/miniprogram/src/services/analytics.ts +++ b/apps/miniprogram/src/services/analytics.ts @@ -1,4 +1,6 @@ import Taro from '@tarojs/taro'; +import { api } from './request'; +import { secureGet } from '@/utils/secure-storage'; type EventName = | 'page_view' @@ -36,7 +38,11 @@ function setQueue(queue: AnalyticsEvent[]): void { } export function trackEvent(event: EventName | string, properties?: Record): void { - const userId = Taro.getStorageSync('user')?.id; + let userId: string | undefined; + try { + const raw = secureGet('user_data'); + userId = raw ? JSON.parse(raw).id : undefined; + } catch { /* ignore */ } const patientId = Taro.getStorageSync('current_patient_id'); const evt: AnalyticsEvent = { @@ -64,12 +70,7 @@ export async function flushEvents(): Promise { setQueue([]); try { - const BASE_URL = process.env.TARO_APP_API_URL || 'http://localhost:3000/api/v1'; - await Taro.request({ - url: `${BASE_URL}/analytics/batch`, - method: 'POST', - data: { events: batch }, - }); + await api.post('/analytics/batch', { events: batch }); } catch { // 发送失败,回填队列 const current = getQueue(); diff --git a/apps/miniprogram/src/services/auth.ts b/apps/miniprogram/src/services/auth.ts index 6ebd734..2b19af8 100644 --- a/apps/miniprogram/src/services/auth.ts +++ b/apps/miniprogram/src/services/auth.ts @@ -42,8 +42,3 @@ export async function wechatBindPhone(openid: string, encryptedData: string, iv: export async function getPatients() { return api.get('/health/patients'); } - -/** 开发模式:用户名密码直登 */ -export async function devLogin(username: string, password: string) { - return api.post('/auth/login', { username, password }); -} diff --git a/apps/miniprogram/src/services/health.ts b/apps/miniprogram/src/services/health.ts index e4deb59..469c0ea 100644 --- a/apps/miniprogram/src/services/health.ts +++ b/apps/miniprogram/src/services/health.ts @@ -15,8 +15,10 @@ export interface TodaySummary { weight?: { value: number; status: string; reference_range?: string }; } -export async function getTodaySummary() { - return api.get('/health/vital-signs/today'); +export async function getTodaySummary(patientId?: string) { + const params: Record = {}; + if (patientId) params.patient_id = patientId; + return api.get('/health/vital-signs/today', params); } /** diff --git a/apps/miniprogram/src/services/request.ts b/apps/miniprogram/src/services/request.ts index 995acdc..c258eec 100644 --- a/apps/miniprogram/src/services/request.ts +++ b/apps/miniprogram/src/services/request.ts @@ -16,12 +16,21 @@ async function getHeaders(): Promise> { 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'); + const tenantId = secureGet('tenant_id'); if (tenantId) headers['X-Tenant-Id'] = tenantId; return headers; } +let refreshPromise: Promise | null = null; + async function tryRefreshToken(): Promise { + if (refreshPromise) return refreshPromise; + refreshPromise = doRefresh(); + refreshPromise.finally(() => { refreshPromise = null; }); + return refreshPromise; +} + +async function doRefresh(): Promise { const refreshToken = secureGet('refresh_token'); if (!refreshToken) return false; try { @@ -35,8 +44,8 @@ async function tryRefreshToken(): Promise { secureSet('refresh_token', res.data.data.refresh_token); return true; } - } catch (err) { - console.error('[tryRefreshToken] token 刷新失败:', err); + } catch { + // token 刷新失败 } secureRemove('access_token'); secureRemove('refresh_token'); @@ -47,7 +56,7 @@ export async function request(method: string, path: string, data?: unknown): const headers = await getHeaders(); const url = `${BASE_URL}${path}`; if (IS_DEV) { - console.log(`[API] ${method} ${path}`, data ?? ''); + console.log(`[API] ${method} ${path}`); } const res = await Taro.request({ url, method: method as any, data, header: headers, timeout: 30000 }); if (IS_DEV) { diff --git a/apps/miniprogram/src/stores/auth.ts b/apps/miniprogram/src/stores/auth.ts index 56fdb52..9f71bb4 100644 --- a/apps/miniprogram/src/stores/auth.ts +++ b/apps/miniprogram/src/stores/auth.ts @@ -10,8 +10,6 @@ interface BindPhoneResp { } interface AuthState { - token: string | null; - refreshToken: string | null; user: { id: string; username: string; display_name?: string; phone?: string; tenant_id?: string } | null; roles: string[]; currentPatient: authApi.PatientInfo | null; @@ -25,11 +23,10 @@ interface AuthState { logout: () => void; restore: () => void; isMedicalStaff: () => boolean; + hasPatientProfile: () => boolean; } export const useAuthStore = create((set, get) => ({ - token: null, - refreshToken: null, user: null, roles: [], currentPatient: null, @@ -41,31 +38,36 @@ export const useAuthStore = create((set, get) => ({ return roles.some((r) => r === 'doctor' || r === 'nurse' || r === 'admin'); }, + hasPatientProfile: () => { + return !!get().currentPatient; + }, + restore: () => { - const token = secureGet('access_token') || null; - const refreshToken = secureGet('refresh_token') || null; const user = Taro.getStorageSync('user') || null; const roles = Taro.getStorageSync('user_roles') || []; const currentPatient = Taro.getStorageSync('current_patient') || null; - set({ token, refreshToken, user, roles, currentPatient }); + set({ user, roles, currentPatient }); }, login: async (code: string) => { + if (get().loading) return false; set({ loading: true }); try { const resp = await authApi.wechatLogin(code); if (resp.bound && resp.token) { const { access_token, refresh_token, user } = resp.token; - const roles = (resp as any).roles?.map((r: any) => r.code || r.name || r) || []; + const roles = (resp as Record).roles instanceof Array + ? ((resp as Record).roles as Array>).map((r) => r.code || r.name || String(r)) + : []; secureSet('access_token', access_token); secureSet('refresh_token', refresh_token); - Taro.setStorageSync('user', user); - Taro.setStorageSync('user_roles', roles); - Taro.setStorageSync('tenant_id', (user as any).tenant_id || ''); - set({ token: access_token, refreshToken: refresh_token, user, roles, loading: false }); + secureSet('user_data', JSON.stringify(user)); + secureSet('user_roles', JSON.stringify(roles)); + secureSet('tenant_id', user.tenant_id || ''); + set({ user, roles, loading: false }); return true; } - Taro.setStorageSync('wechat_openid', resp.openid); + secureSet('wechat_openid', resp.openid); set({ loading: false }); return false; } catch { @@ -75,23 +77,26 @@ export const useAuthStore = create((set, get) => ({ }, bindPhone: async (encryptedData: string, iv: string) => { + if (get().loading) return false; set({ loading: true }); try { - const openid = Taro.getStorageSync('wechat_openid') || ''; + const openid = secureGet('wechat_openid') || ''; if (!openid) { set({ loading: false }); return false; } - const resp = await authApi.wechatBindPhone(openid, encryptedData, iv) as any; - const { access_token, refresh_token, user } = resp; - const roles = resp.roles?.map((r: any) => r.code || r.name || r) || []; - secureSet('access_token', access_token); - secureSet('refresh_token', refresh_token); - Taro.setStorageSync('user', user); - Taro.setStorageSync('user_roles', roles); - Taro.setStorageSync('tenant_id', user.tenant_id || ''); - Taro.removeStorageSync('wechat_openid'); - set({ token: access_token, refreshToken: refresh_token, user, roles, loading: false }); + const resp = await authApi.wechatBindPhone(openid, encryptedData, iv) as Record; + const tokenData = resp as { access_token: string; refresh_token: string; user: AuthState['user'] }; + const roles = resp.roles instanceof Array + ? (resp.roles as Array>).map((r) => r.code || r.name || String(r)) + : []; + secureSet('access_token', tokenData.access_token); + secureSet('refresh_token', tokenData.refresh_token); + secureSet('user_data', JSON.stringify(tokenData.user)); + secureSet('user_roles', JSON.stringify(roles)); + secureSet('tenant_id', tokenData.user?.tenant_id || ''); + secureRemove('wechat_openid'); + set({ user: tokenData.user, roles, loading: false }); return true; } catch { set({ loading: false }); @@ -113,18 +118,22 @@ export const useAuthStore = create((set, get) => ({ get().setCurrentPatient(patients[0]); } } catch { - // ignore + // 患者列表加载失败不阻塞流程 } }, logout: () => { secureRemove('access_token'); secureRemove('refresh_token'); - Taro.removeStorageSync('user'); - Taro.removeStorageSync('user_roles'); + secureRemove('user_data'); + secureRemove('user_roles'); + secureRemove('tenant_id'); + secureRemove('wechat_openid'); Taro.removeStorageSync('current_patient'); Taro.removeStorageSync('current_patient_id'); - set({ token: null, refreshToken: null, user: null, roles: [], currentPatient: null, patients: [] }); + Taro.removeStorageSync('analytics_queue'); + Taro.removeStorageSync('edit_patient'); + set({ user: null, roles: [], currentPatient: null, patients: [] }); Taro.redirectTo({ url: '/pages/login/index' }); }, })); diff --git a/apps/miniprogram/src/stores/health.ts b/apps/miniprogram/src/stores/health.ts index 7bc1971..a68711b 100644 --- a/apps/miniprogram/src/stores/health.ts +++ b/apps/miniprogram/src/stores/health.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import Taro from '@tarojs/taro'; import * as healthApi from '@/services/health'; interface CachedTrend { @@ -25,7 +26,8 @@ export const useHealthStore = create((set, get) => ({ refreshToday: async () => { set({ loading: true }); try { - const data = await healthApi.getTodaySummary(); + const patientId = Taro.getStorageSync('current_patient_id') || undefined; + const data = await healthApi.getTodaySummary(patientId); set({ todaySummary: data, loading: false }); } catch { set({ loading: false }); diff --git a/apps/miniprogram/src/utils/secure-storage.ts b/apps/miniprogram/src/utils/secure-storage.ts index 4c7bc58..1702c3d 100644 --- a/apps/miniprogram/src/utils/secure-storage.ts +++ b/apps/miniprogram/src/utils/secure-storage.ts @@ -18,7 +18,7 @@ function encrypt(plaintext: string): string { return CryptoJS.AES.encrypt(plaintext, ENCRYPTION_KEY).toString(); } -function decrypt(ciphertext: string): string { +function decrypt(ciphertext: string): string | null { if (!ENCRYPTION_KEY) { if (process.env.NODE_ENV === 'production') { throw new Error('[secure-storage] TARO_APP_ENCRYPTION_KEY 未设置,生产环境禁止明文读取'); @@ -27,9 +27,11 @@ function decrypt(ciphertext: string): string { } try { const bytes = CryptoJS.AES.decrypt(ciphertext, ENCRYPTION_KEY); - return bytes.toString(CryptoJS.enc.Utf8); + const result = bytes.toString(CryptoJS.enc.Utf8); + if (!result) return null; + return result; } catch { - return ''; + return null; } } @@ -41,7 +43,8 @@ export function secureSet(key: string, value: string): void { export function secureGet(key: string): string { const raw = Taro.getStorageSync(key); if (!raw || typeof raw !== 'string') return ''; - return decrypt(raw); + const result = decrypt(raw); + return result ?? ''; } export function secureRemove(key: string): void { diff --git a/crates/erp-health/src/handler/health_data_handler.rs b/crates/erp-health/src/handler/health_data_handler.rs index a092d3b..beb7884 100644 --- a/crates/erp-health/src/handler/health_data_handler.rs +++ b/crates/erp-health/src/handler/health_data_handler.rs @@ -388,6 +388,7 @@ where pub async fn get_mini_today( State(state): State, Extension(ctx): Extension, + Query(params): Query, ) -> Result>, AppError> where HealthState: FromRef, @@ -395,12 +396,19 @@ where { require_permission(&ctx, "health.health-data.list")?; let result = trend_service::get_mini_today( - &state, ctx.tenant_id, ctx.user_id, + &state, ctx.tenant_id, ctx.user_id, params.patient_id, ) .await?; Ok(Json(ApiResponse::ok(result))) } +/// 小程序今日体征请求参数 +#[derive(Debug, serde::Deserialize, utoipa::IntoParams)] +pub struct MiniTodayParams { + /// 可选:直接指定患者 ID(小程序传入当前选中患者) + pub patient_id: Option, +} + // --------------------------------------------------------------------------- // 带版本号的更新请求包装 // --------------------------------------------------------------------------- diff --git a/crates/erp-health/src/service/trend_service.rs b/crates/erp-health/src/service/trend_service.rs index 4c064ca..ccc9a0e 100644 --- a/crates/erp-health/src/service/trend_service.rs +++ b/crates/erp-health/src/service/trend_service.rs @@ -414,8 +414,14 @@ pub async fn get_mini_today( state: &HealthState, tenant_id: Uuid, user_id: Uuid, + explicit_patient_id: Option, ) -> HealthResult { - let patient_id = find_patient_by_user_id(state, tenant_id, user_id).await?; + // 优先使用显式传入的 patient_id(小程序端传入当前选中患者) + let patient_id = if let Some(pid) = explicit_patient_id { + Some(pid) + } else { + find_patient_by_user_id(state, tenant_id, user_id).await? + }; let Some(patient_id) = patient_id else { return Ok(MiniTodayResp { diff --git a/wiki/miniprogram.md b/wiki/miniprogram.md index da92743..61cfdca 100644 --- a/wiki/miniprogram.md +++ b/wiki/miniprogram.md @@ -1,6 +1,6 @@ --- title: 微信小程序(患者端) -updated: 2026-04-25 +updated: 2026-04-26 status: active tags: [miniprogram, taro, wechat, patient] --- @@ -55,25 +55,59 @@ POST /auth/wechat/login { code } 后端解密手机号 → 创建/关联用户 → 返回 JWT → 跳转首页 ``` -### 页面结构(20 个页面,10 个目录) +### 页面结构(40 个页面,15 个目录) + +#### 患者端页面 | 页面路径 | 说明 | |----------|------| | `pages/login/index` | 登录页(微信登录 + 协议勾选) | | `pages/index/index` | 首页(今日健康、快捷服务) | -| `pages/health/trend/index` | 健康趋势(体征数据折线图) | +| `pages/health/index` | 健康上报(Tab 页) | | `pages/health/input/index` | 健康数据录入(Zod 验证) | +| `pages/health/trend/index` | 健康趋势(体征数据折线图) | +| `pages/health/daily-monitoring/index` | 日常监测数据 | +| `pages/appointment/index` | 预约列表 | | `pages/appointment/create/index` | 预约挂号 | | `pages/appointment/detail/index` | 预约详情 | -| `pages/article/index` | 健康资讯 | -| `pages/profile/index` | 个人中心 | -| `pages/profile/family/index` | 家庭成员管理 | +| `pages/article/index` | 健康资讯列表 | +| `pages/article/detail/index` | 文章详情 | +| `pages/report/detail/index` | 健康报告详情 | +| `pages/ai-report/list/index` | AI 分析报告列表 | +| `pages/ai-report/detail/index` | AI 分析报告详情 | | `pages/followup/detail/index` | 随访详情 | -| `pages/report/index` | 健康报告查看 | +| `pages/consultation/index` | 咨询列表(Tab 页) | +| `pages/consultation/detail/index` | 咨询详情 | +| `pages/mall/index` | 积分商城(Tab 页) | +| `pages/mall/detail/index` | 商品详情 | +| `pages/mall/exchange/index` | 积分兑换 | +| `pages/mall/orders/index` | 积分订单 | +| `pages/events/index` | 线下活动 | +| `pages/profile/index` | 个人中心(Tab 页) | +| `pages/profile/family/index` | 家庭成员管理 | +| `pages/profile/family-add/index` | 添加家庭成员 | +| `pages/profile/reports/index` | 我的报告 | +| `pages/profile/followups/index` | 我的随访 | +| `pages/profile/medication/index` | 用药记录 | +| `pages/profile/settings/index` | 设置 | | `pages/legal/user-agreement` | 用户服务协议 | | `pages/legal/privacy-policy` | 隐私政策 | -### 服务层(10 个文件) +#### 医护端页面(8 个) + +| 页面路径 | 说明 | +|----------|------| +| `pages/doctor/index` | 医护首页 | +| `pages/doctor/patients/index` | 患者列表 | +| `pages/doctor/patients/detail/index` | 患者详情 | +| `pages/doctor/consultation/index` | 咨询管理 | +| `pages/doctor/consultation/detail/index` | 咨询详情 | +| `pages/doctor/followup/index` | 随访管理 | +| `pages/doctor/followup/detail/index` | 随访详情 | +| `pages/doctor/report/index` | 报告管理 | +| `pages/doctor/report/detail/index` | 报告详情 | + +### 服务层(10+ 个文件) | 文件 | 覆盖 | |------|------| @@ -201,5 +235,6 @@ secret = "<通过环境变量 ERP__WECHAT__SECRET 设置>" | 日期 | 变更 | |------|------| +| 2026-04-26 | 全面更新:40 页面(含 9 个医护端页面)、15 目录、5 个 Tab 页、积分商城、线下活动 | | 2026-04-25 | 全面更新:20 页面、10 服务、9 组件、Zod 验证、加密密钥外部化说明 | | 2026-04-24 | 创建小程序 wiki 页面,记录登录流程、环境配置、历史陷阱 |