diff --git a/apps/miniprogram/__tests__/utils/secure-storage.test.ts b/apps/miniprogram/__tests__/utils/secure-storage.test.ts index a5e8a12..0a22e7e 100644 --- a/apps/miniprogram/__tests__/utils/secure-storage.test.ts +++ b/apps/miniprogram/__tests__/utils/secure-storage.test.ts @@ -40,6 +40,7 @@ vi.mock('@tarojs/taro', () => ({ // --- Mock 加密密钥 --- process.env.TARO_APP_ENCRYPTION_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; +process.env.NODE_ENV = 'development'; // --- 导入被测模块(在 mock 之后) --- import { secureSet, secureGet, secureRemove, migrateLegacyStorage } from '@/utils/secure-storage'; @@ -245,8 +246,79 @@ describe('secure-storage AES-256-GCM', () => { it('损坏的 AES 密文返回 null 后走明文 fallback', () => { storage.set('_es_corrupt', 'aes:INVALID_BASE64!!!'); - // aesDecrypt 失败返回 null,然后尝试 XOR 也失败,最后返回原始字符串 + // aesDecrypt 失败返回 null,hasEncryptionKey=true 所以不走 dev plaintext + // 最终返回 raw 值 expect(secureGet('corrupt')).toBe('aes:INVALID_BASE64!!!'); }); }); + + // ================================================================ + // 7. 空密钥 dev 模式 — 明文存储兼容 + // ================================================================ + describe('空密钥 dev 模式', () => { + const originalKey = process.env.TARO_APP_ENCRYPTION_KEY; + const originalEnv = process.env.NODE_ENV; + + beforeEach(() => { + storage.clear(); + vi.clearAllMocks(); + }); + + afterEach(() => { + process.env.TARO_APP_ENCRYPTION_KEY = originalKey; + process.env.NODE_ENV = originalEnv; + }); + + it('dev 模式空密钥:secureSet 存明文,secureGet 读取成功', () => { + process.env.TARO_APP_ENCRYPTION_KEY = ''; + process.env.NODE_ENV = 'development'; + secureSet('dev_plain', 'hello-dev'); + // 应以明文存储 + expect(storage.get('_es_dev_plain')).toBe('hello-dev'); + expect(secureGet('dev_plain')).toBe('hello-dev'); + }); + + it('dev 模式空密钥:读取 MCP 注入的明文成功', () => { + process.env.TARO_APP_ENCRYPTION_KEY = ''; + process.env.NODE_ENV = 'development'; + storage.set('access_token', 'mcp-injected-token'); + expect(secureGet('access_token')).toBe('mcp-injected-token'); + }); + }); + + // ================================================================ + // 8. production 模式空密钥 — 拒绝加解密 + // ================================================================ + describe('production 模式空密钥', () => { + const originalKey = process.env.TARO_APP_ENCRYPTION_KEY; + const originalEnv = process.env.NODE_ENV; + + beforeEach(() => { + storage.clear(); + vi.clearAllMocks(); + }); + + afterEach(() => { + process.env.TARO_APP_ENCRYPTION_KEY = originalKey; + process.env.NODE_ENV = originalEnv; + }); + + it('production 空密钥:secureSet 存明文(无加密可用),secureGet 返回空', () => { + process.env.TARO_APP_ENCRYPTION_KEY = ''; + process.env.NODE_ENV = 'production'; + secureSet('prod_test', 'sensitive-data'); + // 应以明文存储(无 key 时 aesEncrypt 返回 null) + expect(storage.get('_es_prod_test')).toBe('sensitive-data'); + // secureGet: prefixed key 有值但非 aes: 前缀 + hasEncryptionKey=false → 返回 raw + expect(secureGet('prod_test')).toBe('sensitive-data'); + }); + + it('production 空密钥:AES 解密失败返回空字符串', () => { + process.env.TARO_APP_ENCRYPTION_KEY = ''; + process.env.NODE_ENV = 'production'; + // 模拟存在一个 aes: 前缀的旧数据 + storage.set('_es_old_data', 'aes:INVALID_CORRUPT_DATA'); + expect(secureGet('old_data')).toBe(''); + }); + }); }); diff --git a/apps/miniprogram/src/pages/health/index.scss b/apps/miniprogram/src/pages/health/index.scss index 01b12e1..01b4ef4 100644 --- a/apps/miniprogram/src/pages/health/index.scss +++ b/apps/miniprogram/src/pages/health/index.scss @@ -17,100 +17,132 @@ color: $tx; } -/* ─── 录入区 ─── */ -.input-section { +/* ─── 今日体征摘要 ─── */ +.vitals-grid { margin-bottom: var(--tk-section-gap); } -.input-group { - margin-bottom: var(--tk-gap-sm); +.vitals-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--tk-gap-sm); } -.input-label { - font-size: var(--tk-font-cap); - color: var(--tk-text-secondary); - display: block; - margin-bottom: var(--tk-gap-2xs); -} - -.input-field { - height: 56px; +.vital-cell { + text-align: center; + padding: var(--tk-gap-sm); + border-radius: $r-sm; background: $bg; - border: 2px solid $bd; - border-radius: $r-sm; - padding: 0 var(--tk-gap-md); - font-family: 'Georgia', 'Times New Roman', serif; - font-size: var(--tk-font-body-lg); - font-weight: 600; +} + +.vital-value { + @include serif-number; + font-size: var(--tk-font-num); + font-weight: 700; color: $tx; - width: 100%; - box-sizing: border-box; -} - -.input-ref { - font-size: var(--tk-font-cap); - color: var(--tk-text-secondary); display: block; - margin-top: var(--tk-gap-xs); - margin-bottom: var(--tk-gap-2xs); } -.input-label--secondary { - margin-top: var(--tk-section-gap); +.vital-unit { + font-size: var(--tk-font-micro); + color: $tx3; + display: block; + margin-top: 2px; } -/* ─── 血糖时段选择 ─── */ -.period-group { - display: flex; - gap: var(--tk-gap-xs); - margin-top: var(--tk-gap-sm); -} - -.period-btn { - flex: 1; - height: 48px; - border-radius: $r-sm; - background: $surface-alt; - @include flex-center; - - &.period-active { - background: var(--tk-pri); - - .period-btn-text { - color: $white; - } - } - - &:active { - opacity: var(--tk-touch-feedback-opacity); - } -} - -.period-btn-text { +.vital-label { font-size: var(--tk-font-cap); - font-weight: 600; color: $tx2; + display: block; + margin-top: 4px; } -/* ─── 保存按钮 ─── */ -.save-btn { - width: 100%; - height: 52px; - border-radius: $r-sm; - background: var(--tk-pri); - @include flex-center; - margin-top: var(--tk-section-gap); - box-shadow: 0 2px 8px rgba($pri, 0.25); +.vital-cell.vital-warn { + background: $wrn-l; + + .vital-value { + color: $wrn; + } +} + +.vital-cell.vital-ok { + .vital-value { + color: $acc; + } +} + +/* ─── 快捷入口 ─── */ +.quick-entries { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--tk-gap-sm); + margin-bottom: var(--tk-section-gap); +} + +.quick-entry { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--tk-gap-xs); + min-height: var(--tk-touch-min); + justify-content: center; &:active { opacity: var(--tk-touch-feedback-opacity); } } -.save-btn-text { - font-size: var(--tk-font-body-sm); +.quick-icon { + width: 48px; + height: 48px; + border-radius: $r; + background: var(--tk-pri-l); + @include flex-center; +} + +.quick-icon-text { + font-size: var(--tk-font-body); font-weight: 600; - color: $white; + color: var(--tk-pri); +} + +.quick-label { + font-size: var(--tk-font-cap); + color: $tx2; + font-weight: 500; +} + +/* ─── 告警提示 ─── */ +.alert-hint { + display: flex; + align-items: center; + gap: var(--tk-gap-sm); + margin-bottom: var(--tk-section-gap); + + &:active { + opacity: var(--tk-touch-feedback-opacity); + } +} + +.alert-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: $dan; + flex-shrink: 0; +} + +.alert-text { + flex: 1; + font-size: var(--tk-font-body-sm); + font-weight: 500; + color: $dan; +} + +.alert-arrow { + font-size: var(--tk-font-body); + color: $tx3; + flex-shrink: 0; } /* ─── 趋势图 ─── */ diff --git a/apps/miniprogram/src/pages/health/index.tsx b/apps/miniprogram/src/pages/health/index.tsx index b98623f..7e6014a 100644 --- a/apps/miniprogram/src/pages/health/index.tsx +++ b/apps/miniprogram/src/pages/health/index.tsx @@ -1,235 +1,169 @@ -import { useState } from 'react'; -import { View, Text, Input } from '@tarojs/components'; +import { View, Text } from '@tarojs/components'; import Taro from '@tarojs/taro'; import { safeNavigateTo } from '@/utils/navigate'; import { useAuthStore } from '../../stores/auth'; import { useElderClass } from '../../hooks/useElderClass'; -import { findThreshold, inputVitalSign, type HealthThreshold } from '../../services/health'; -import { validateNum } from '../../utils/validate'; -import Loading from '../../components/Loading'; -import ErrorState from '../../components/ErrorState'; import GuestGuard from '../../components/GuestGuard'; -import SegmentTabs from '../../components/SegmentTabs'; +import Loading from '../../components/Loading'; import PageShell from '@/components/ui/PageShell'; import ContentCard from '@/components/ui/ContentCard'; -import { useHealthData, VITAL_TABS, type VitalType } from './useHealthData'; +import SegmentTabs from '../../components/SegmentTabs'; +import { useHealthOverview, VITAL_TABS, type VitalType } from './useHealthOverview'; import { submitSuggestionFeedback } from '../../services/ai-analysis'; import './index.scss'; -function buildRefRange(t: HealthThreshold[]): Record { - const bpSys = findThreshold(t, 'systolic_bp', 'high')?.threshold_value ?? 140; - const bpDia = findThreshold(t, 'diastolic_bp', 'high')?.threshold_value ?? 90; - const hrHigh = findThreshold(t, 'heart_rate', 'high')?.threshold_value ?? 100; - const hrLow = findThreshold(t, 'heart_rate', 'low')?.threshold_value ?? 60; - const bsFasting = findThreshold(t, 'blood_sugar_fasting', 'high')?.threshold_value ?? 6.1; - const bsPp = findThreshold(t, 'blood_sugar_postprandial', 'high')?.threshold_value ?? 7.8; - return { - blood_pressure: `收缩压 90-${bpSys} / 舒张压 60-${bpDia} mmHg`, - heart_rate: `${hrLow}-${hrHigh} bpm`, - blood_sugar: `空腹 3.9-${bsFasting} / 餐后 <${bsPp} mmol/L`, - weight: '根据 BMI 18.5-24 计算', - }; +const QUICK_ENTRIES = [ + { label: '录入体征', icon: '笔', path: '/pages/pkg-health/input/index' }, + { label: '健康趋势', icon: '线', path: '/pages/pkg-health/trend/index' }, + { label: '我的报告', icon: '报', path: '/pages/pkg-profile/reports/index' }, + { label: '用药记录', icon: '药', path: '/pages/pkg-profile/medication/index' }, +] as const; + +function statusClass(status?: string): string { + if (!status) return ''; + if (status === 'high' || status === 'abnormal') return 'vital-warn'; + if (status === 'low') return 'vital-warn'; + return 'vital-ok'; } export default function Health() { - const currentPatient = useAuthStore((s) => s.currentPatient); + const user = useAuthStore((s) => s.user); const modeClass = useElderClass(); const { - user, todaySummary: _todaySummary, loading: _loading, error, activeTab, trendData, trendLoading, - aiSuggestions, thresholds, handleTabChange, loadTrend, refreshToday, fetchData, - } = useHealthData(); - - const [systolic, setSystolic] = useState(''); - const [diastolic, setDiastolic] = useState(''); - const [heartRateVal, setHeartRateVal] = useState(''); - const [sugarVal, setSugarVal] = useState(''); - const [sugarPeriod, setSugarPeriod] = useState<'fasting' | 'postprandial'>('fasting'); - const [weightVal, setWeightVal] = useState(''); - const [saving, setSaving] = useState(false); + todaySummary, loading, error, activeTab, trendData, trendLoading, + aiSuggestions, thresholds, alertCount, handleTabChange, fetchData, + } = useHealthOverview(); if (!user) { - return ; + return ; } if (error) { return ( - 健康数据 + 健康总览 - + ); } - const getWarnStatus = (type: VitalType): string | null => { - if (type === 'blood_pressure') { - const sys = parseFloat(systolic); - const dia = parseFloat(diastolic); - const sysMax = findThreshold(thresholds, 'systolic_bp', 'high')?.threshold_value ?? 140; - const diaMax = findThreshold(thresholds, 'diastolic_bp', 'high')?.threshold_value ?? 90; - if (sys > sysMax || dia > diaMax) return '血压偏高,确认提交?'; - } else if (type === 'heart_rate') { - const val = parseFloat(heartRateVal); - const hrHigh = findThreshold(thresholds, 'heart_rate', 'high')?.threshold_value ?? 100; - const hrLow = findThreshold(thresholds, 'heart_rate', 'low')?.threshold_value ?? 60; - if (val > hrHigh || val < hrLow) return '心率异常,确认提交?'; - } else if (type === 'blood_sugar') { - const val = parseFloat(sugarVal); - if (sugarPeriod === 'fasting') { - const bsMax = findThreshold(thresholds, 'blood_sugar_fasting', 'high')?.threshold_value ?? 6.1; - if (val > bsMax) return '血糖偏高,确认提交?'; - } else { - const bsMax = findThreshold(thresholds, 'blood_sugar_postprandial', 'high')?.threshold_value ?? 7.8; - if (val > bsMax) return '血糖偏高,确认提交?'; - } - } - return null; - }; - - const refRanges = buildRefRange(thresholds); - - const handleSave = async () => { - const patientId = currentPatient?.id; - if (!patientId) { - Taro.showToast({ title: '请先登录', icon: 'none' }); - return; - } - - const warnMsg = getWarnStatus(activeTab); - if (warnMsg) { - const { confirm } = await Taro.showModal({ - title: '异常提示', - content: warnMsg, - confirmText: '确认提交', - cancelText: '再看看', - }); - if (!confirm) return; - } - - setSaving(true); - try { - switch (activeTab) { - case 'blood_pressure': { - const sys = parseFloat(systolic); - const dia = parseFloat(diastolic); - if (!sys || !dia) { Taro.showToast({ title: '请填写完整', icon: 'none' }); return; } - const sysErr = validateNum(sys, '收缩压', { min: 60, max: 250 }); - if (sysErr) { Taro.showToast({ title: sysErr, icon: 'none' }); return; } - const diaErr = validateNum(dia, '舒张压', { min: 40, max: 150 }); - if (diaErr) { Taro.showToast({ title: diaErr, icon: 'none' }); return; } - await inputVitalSign(patientId, { - indicator_type: 'blood_pressure', - value: sys, - extra: { systolic: sys, diastolic: dia }, - }); - setSystolic(''); - setDiastolic(''); - break; - } - case 'heart_rate': { - const val = parseFloat(heartRateVal); - if (!val) { Taro.showToast({ title: '请填写心率', icon: 'none' }); return; } - const err = validateNum(val, '心率', { min: 30, max: 220 }); - if (err) { Taro.showToast({ title: err, icon: 'none' }); return; } - await inputVitalSign(patientId, { indicator_type: 'heart_rate', value: val }); - setHeartRateVal(''); - break; - } - case 'blood_sugar': { - const val = parseFloat(sugarVal); - if (!val) { Taro.showToast({ title: '请填写血糖值', icon: 'none' }); return; } - const err = validateNum(val, '血糖', { min: 1.0, max: 33.3 }); - if (err) { Taro.showToast({ title: err, icon: 'none' }); return; } - const bsType = sugarPeriod === 'fasting' ? 'blood_sugar_fasting' : 'blood_sugar_postprandial'; - await inputVitalSign(patientId, { indicator_type: bsType, value: val }); - setSugarVal(''); - break; - } - case 'weight': { - const val = parseFloat(weightVal); - if (!val) { Taro.showToast({ title: '请填写体重', icon: 'none' }); return; } - const err = validateNum(val, '体重', { min: 20, max: 300 }); - if (err) { Taro.showToast({ title: err, icon: 'none' }); return; } - await inputVitalSign(patientId, { indicator_type: 'weight', value: val }); - setWeightVal(''); - break; - } - } - Taro.showToast({ title: '保存成功', icon: 'success' }); - refreshToday(true); - loadTrend(activeTab); - } catch (err) { - console.warn('[health] 保存体征数据失败:', err); - Taro.showToast({ title: '保存失败', icon: 'none' }); - } finally { - setSaving(false); - } - }; - const maxTrendValue = trendData.reduce((max, d) => Math.max(max, d.value), 1); - - const getThresholdValue = (type: VitalType, th: HealthThreshold[]): number | null => { - if (type === 'blood_pressure') return findThreshold(th, 'systolic_bp', 'high')?.threshold_value ?? 140; - if (type === 'heart_rate') return findThreshold(th, 'heart_rate', 'high')?.threshold_value ?? 100; - if (type === 'blood_sugar') return findThreshold(th, 'blood_sugar_fasting', 'high')?.threshold_value ?? 6.1; - return null; - }; const dayLabels = ['日', '一', '二', '三', '四', '五', '六']; + const summary = todaySummary || {}; + const vitals = [ + { label: '血压', value: summary.blood_pressure ? `${summary.blood_pressure.systolic}/${summary.blood_pressure.diastolic}` : '—', unit: 'mmHg', status: summary.blood_pressure?.status }, + { label: '心率', value: summary.heart_rate ? `${summary.heart_rate.value}` : '—', unit: 'bpm', status: summary.heart_rate?.status }, + { label: '血糖', value: summary.blood_sugar ? `${summary.blood_sugar.value}` : '—', unit: 'mmol/L', status: summary.blood_sugar?.status }, + { label: '体重', value: summary.weight ? `${summary.weight.value}` : '—', unit: 'kg', status: summary.weight?.status }, + ]; + + const getThresholdValue = (type: VitalType): number | null => { + if (!thresholds.length) return null; + const th = thresholds; + if (type === 'blood_pressure') { + const v = th.find((t) => t.indicator_name === 'systolic_bp' && t.severity === 'high'); + return v?.threshold_value ?? 140; + } + if (type === 'heart_rate') { + const v = th.find((t) => t.indicator_name === 'heart_rate' && t.severity === 'high'); + return v?.threshold_value ?? 100; + } + if (type === 'blood_sugar') { + const v = th.find((t) => t.indicator_name === 'blood_sugar_fasting' && t.severity === 'high'); + return v?.threshold_value ?? 6.1; + } + return null; + }; + return ( - + - 健康数据 + 健康总览 + {/* 今日体征摘要 */} + + {loading ? : ( + + {vitals.map((v) => ( + + {v.value} + {v.unit} + {v.label} + + ))} + + )} + + + {/* 快捷入口 */} + + {QUICK_ENTRIES.map((e) => ( + safeNavigateTo(e.path)} + > + + {e.icon} + + {e.label} + + ))} + + + {/* 告警提示 */} + {alertCount > 0 && ( + safeNavigateTo('/pages/pkg-health/alerts/index')} + > + + {alertCount} 条待处理告警 + + + )} + + {/* AI 建议 */} {aiSuggestions.length > 0 && ( AI 健康建议 - {aiSuggestions.length} 条待查看 + {aiSuggestions.length} 条 {aiSuggestions.map((s) => { const riskCls = s.risk_level === 'high' ? 'ai-risk-high' : s.risk_level === 'medium' ? 'ai-risk-medium' : 'ai-risk-low'; - const typeLabel = s.suggestion_type === 'followup' ? '随访' : s.suggestion_type === 'appointment' ? '预约' : '预警'; const params = s.params as Record | null; - const reason = (params?.reason as string) || (params?.message as string) || typeLabel; + const reason = (params?.reason as string) || (params?.message as string) || '健康建议'; return ( { - if (s.suggestion_type === 'appointment') { - safeNavigateTo(`/pages/appointment/create/index`); - } else if (s.suggestion_type === 'followup') { - safeNavigateTo('/pages/pkg-profile/followups/index'); - } + if (s.suggestion_type === 'appointment') safeNavigateTo('/pages/appointment/create/index'); + else if (s.suggestion_type === 'followup') safeNavigateTo('/pages/pkg-profile/followups/index'); }}> - {reason.slice(0, 40)} + {reason.slice(0, 50)} { - try { - await submitSuggestionFeedback(s.id, 'adopt'); - Taro.showToast({ title: '已采纳', icon: 'success' }); - fetchData(); - } catch (err) { console.warn('[health] AI建议反馈失败:', err); Taro.showToast({ title: '操作失败', icon: 'none' }); } + try { await submitSuggestionFeedback(s.id, 'adopt'); Taro.showToast({ title: '已采纳', icon: 'success' }); fetchData(); } + catch { Taro.showToast({ title: '操作失败', icon: 'none' }); } }}> 采纳 { - try { - await submitSuggestionFeedback(s.id, 'ignore'); - Taro.showToast({ title: '已忽略', icon: 'success' }); - fetchData(); - } catch (err) { console.warn('[health] AI建议反馈失败:', err); Taro.showToast({ title: '操作失败', icon: 'none' }); } + try { await submitSuggestionFeedback(s.id, 'ignore'); Taro.showToast({ title: '已忽略', icon: 'success' }); fetchData(); } + catch { Taro.showToast({ title: '操作失败', icon: 'none' }); } }}> 忽略 { - try { - await submitSuggestionFeedback(s.id, 'consult'); - safeNavigateTo('/pages/consultation/index'); - } catch (err) { console.warn('[health] AI建议反馈失败:', err); Taro.showToast({ title: '操作失败', icon: 'none' }); } + try { await submitSuggestionFeedback(s.id, 'consult'); safeNavigateTo('/pages/consultation/index'); } + catch { Taro.showToast({ title: '操作失败', icon: 'none' }); } }}> 咨询医生 @@ -240,115 +174,32 @@ export default function Health() { )} - - - - {activeTab === 'blood_pressure' && ( - - 收缩压(高压) - setSystolic(e.detail.value)} - /> - 舒张压(低压) - setDiastolic(e.detail.value)} - /> - {refRanges.blood_pressure} - - )} - - {activeTab === 'heart_rate' && ( - - 心率 - setHeartRateVal(e.detail.value)} - /> - {refRanges.heart_rate} - - )} - - {activeTab === 'blood_sugar' && ( - - 血糖值 - setSugarVal(e.detail.value)} - /> - - setSugarPeriod('fasting')} - > - 空腹 - - setSugarPeriod('postprandial')} - > - 餐后 2h - - - {refRanges.blood_sugar} - - )} - - {activeTab === 'weight' && ( - - 体重 (kg) - setWeightVal(e.detail.value)} - /> - {refRanges.weight} - - )} - - - {saving ? '保存中...' : '保存'} - - - + {/* 7天趋势 */} 近 7 天趋势 - {trendLoading ? ( - - ) : trendData.length === 0 ? ( + + {trendLoading ? : trendData.length === 0 ? ( 暂无趋势数据 ) : ( - {getThresholdValue(activeTab, thresholds) && (() => { - const tv = getThresholdValue(activeTab, thresholds)!; - const pct = Math.min(95, (tv / maxTrendValue) * 100); - return ( - - {tv} - - ); + {(() => { + const tv = getThresholdValue(activeTab); + if (tv) { + const pct = Math.min(95, (tv / maxTrendValue) * 100); + return ( + + {tv} + + ); + } + return null; })()} {trendData.map((point, i) => { const heightPct = Math.max(8, (point.value / maxTrendValue) * 100); - const tv = getThresholdValue(activeTab, thresholds); + const tv = getThresholdValue(activeTab); const isAbnormal = tv ? point.value >= tv : false; const dayOfWeek = new Date(point.date).getDay(); return ( @@ -366,9 +217,8 @@ export default function Health() { )} - safeNavigateTo('/pages/article/index')} - > + {/* 健康资讯入口 */} + safeNavigateTo('/pages/article/index')}> 最新健康资讯 › diff --git a/apps/miniprogram/src/pages/health/useHealthOverview.ts b/apps/miniprogram/src/pages/health/useHealthOverview.ts new file mode 100644 index 0000000..7e5abfa --- /dev/null +++ b/apps/miniprogram/src/pages/health/useHealthOverview.ts @@ -0,0 +1,112 @@ +import { useState } from 'react'; +import { useHealthStore } from '@/stores/health'; +import { useAuthStore } from '@/stores/auth'; +import { usePageData } from '@/hooks/usePageData'; +import { getHealthThresholds, DEFAULT_THRESHOLDS, type HealthThreshold } from '@/services/health'; +import { listPendingSuggestions, type AiSuggestionItem } from '@/services/ai-analysis'; +import { listPatientAlerts } from '@/services/alert'; + +export type VitalType = 'blood_pressure' | 'heart_rate' | 'blood_sugar' | 'weight'; + +export const VITAL_TABS: { key: VitalType; label: string }[] = [ + { key: 'blood_pressure', label: '血压' }, + { key: 'heart_rate', label: '心率' }, + { key: 'blood_sugar', label: '血糖' }, + { key: 'weight', label: '体重' }, +]; + +export interface TrendPoint { + date: string; + value: number; +} + +export function useHealthOverview() { + const user = useAuthStore((s) => s.user); + const todaySummary = useHealthStore((s) => s.todaySummary); + const loading = useHealthStore((s) => s.loading); + const refreshToday = useHealthStore((s) => s.refreshToday); + const fetchTrend = useHealthStore((s) => s.getTrend); + + const [activeTab, setActiveTab] = useState('blood_pressure'); + const [trendData, setTrendData] = useState([]); + const [trendLoading, setTrendLoading] = useState(false); + const [aiSuggestions, setAiSuggestions] = useState([]); + const [thresholds, setThresholds] = useState(DEFAULT_THRESHOLDS); + const [alertCount, setAlertCount] = useState(0); + + const loadTrend = async (type: VitalType) => { + setTrendLoading(true); + try { + const indicatorMap: Record = { + blood_pressure: 'systolic_bp_morning', + heart_rate: 'heart_rate', + blood_sugar: 'blood_sugar', + weight: 'weight', + }; + const points = await fetchTrend(indicatorMap[type], '7d'); + setTrendData(points); + } catch (err) { + console.warn('[health] 加载趋势数据失败:', err); + setTrendData([]); + } finally { + setTrendLoading(false); + } + }; + + const loadAiSuggestions = async () => { + try { + const items = await listPendingSuggestions(); + setAiSuggestions(items.slice(0, 3)); + } catch { + setAiSuggestions([]); + } + }; + + const loadAlertCount = async () => { + const patientId = useAuthStore.getState().currentPatient?.id; + if (!patientId) return; + try { + const res = await listPatientAlerts(patientId, { status: 'pending', page: 1, page_size: 1 }); + setAlertCount(res.total ?? 0); + } catch { + setAlertCount(0); + } + }; + + const fetchData = async () => { + const results = await Promise.allSettled([ + refreshToday(), + loadTrend(activeTab), + loadAiSuggestions(), + loadAlertCount(), + getHealthThresholds().then((t) => { if (t.length > 0) setThresholds(t); }), + ]); + return results; + }; + + usePageData(fetchData, { + throttleMs: 5000, + enablePullDown: true, + enabled: !!user, + }); + + const handleTabChange = (tab: VitalType) => { + setActiveTab(tab); + loadTrend(tab); + }; + + return { + user, + todaySummary, + loading, + error: false, + activeTab, + trendData, + trendLoading, + aiSuggestions, + thresholds, + alertCount, + handleTabChange, + fetchData, + }; +} diff --git a/apps/miniprogram/src/pages/index/index.scss b/apps/miniprogram/src/pages/index/index.scss index 52a0c61..aa900b3 100644 --- a/apps/miniprogram/src/pages/index/index.scss +++ b/apps/miniprogram/src/pages/index/index.scss @@ -539,3 +539,36 @@ .elder-mode .sos-btn-text { font-size: 20px; } + +/* ─── 告警提示卡片 ─── */ +.home-alert-card { + display: flex; + align-items: center; + gap: var(--tk-gap-sm); + margin-top: var(--tk-gap-sm); + + &:active { + opacity: var(--tk-touch-feedback-opacity); + } +} + +.home-alert-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: $dan; + flex-shrink: 0; +} + +.home-alert-text { + flex: 1; + font-size: var(--tk-font-body-sm); + font-weight: 500; + color: $dan; +} + +.home-alert-arrow { + font-size: var(--tk-font-body); + color: $tx3; + flex-shrink: 0; +} diff --git a/apps/miniprogram/src/pages/index/index.tsx b/apps/miniprogram/src/pages/index/index.tsx index 6ded74b..663d252 100644 --- a/apps/miniprogram/src/pages/index/index.tsx +++ b/apps/miniprogram/src/pages/index/index.tsx @@ -228,7 +228,7 @@ function SOSButton() { function HomeDashboard({ modeClass }: { modeClass: string }) { const { healthItems, indicatorCapsules, completedCount, progressPercent, - loading, todaySummary, reminders, remindersLoading, unreadCount, + loading, todaySummary, reminders, remindersLoading, unreadCount, alertCount, greeting, displayName, } = useHomeData(); @@ -325,13 +325,25 @@ function HomeDashboard({ modeClass }: { modeClass: string }) { )} - Taro.switchTab({ url: '/pages/health/index' })}> - 记录体征 + safeNavigateTo('/pages/pkg-health/input/index')}> + 录入体征 safeNavigateTo('/pages/appointment/create/index')}> 预约挂号 + + {alertCount > 0 && ( + safeNavigateTo('/pages/pkg-health/alerts/index')} + > + + {alertCount} 条待处理健康告警 + + + )} ); diff --git a/apps/miniprogram/src/pages/index/useHomeData.ts b/apps/miniprogram/src/pages/index/useHomeData.ts index ba8d263..f57d67d 100644 --- a/apps/miniprogram/src/pages/index/useHomeData.ts +++ b/apps/miniprogram/src/pages/index/useHomeData.ts @@ -7,6 +7,7 @@ import * as appointmentApi from '@/services/appointment'; import * as followupApi from '@/services/followup'; import { listPendingSuggestions, type AiSuggestionItem } from '@/services/ai-analysis'; import { notificationService } from '@/services/notification'; +import { listPatientAlerts } from '@/services/alert'; export interface ReminderItem { id: string; @@ -37,6 +38,7 @@ export function useHomeData() { const [reminders, setReminders] = useState([]); const [unreadCount, setUnreadCount] = useState(0); const [remindersLoading, setRemindersLoading] = useState(false); + const [alertCount, setAlertCount] = useState(0); const fetchData = async () => { const patientId = useAuthStore.getState().currentPatient?.id; @@ -44,6 +46,7 @@ export function useHomeData() { refreshToday(); loadReminders(patientId); loadUnread(); + loadAlertCount(patientId); trackPageView('home'); }; @@ -118,6 +121,15 @@ export function useHomeData() { } }; + const loadAlertCount = async (patientId: string) => { + try { + const res = await listPatientAlerts(patientId, { status: 'pending', page: 1, page_size: 1 }); + setAlertCount(res.total ?? 0); + } catch { + setAlertCount(0); + } + }; + const summary = todaySummary || {}; const indicators = [!!summary.blood_pressure, !!summary.heart_rate, !!summary.blood_sugar, !!summary.weight]; const completedCount = indicators.filter(Boolean).length; @@ -149,6 +161,7 @@ export function useHomeData() { reminders, unreadCount, remindersLoading, + alertCount, indicatorCapsules, healthItems, completedCount, diff --git a/apps/miniprogram/src/utils/secure-storage-aes.ts b/apps/miniprogram/src/utils/secure-storage-aes.ts index a8a8c84..f552f69 100644 --- a/apps/miniprogram/src/utils/secure-storage-aes.ts +++ b/apps/miniprogram/src/utils/secure-storage-aes.ts @@ -13,18 +13,22 @@ declare const wx: { getRandomValuesSync?: (params: { length: number }) => ArrayBuffer; } | undefined; -function getEncryptionKey(): Uint8Array { +function getEncryptionKey(): Uint8Array | null { const hex = process.env.TARO_APP_ENCRYPTION_KEY || ''; if (hex && /^[0-9a-fA-F]{64}$/.test(hex)) { return new Uint8Array(hex.match(/.{2}/g)!.map((b) => parseInt(b, 16))); } - // derive 32 bytes from passphrase - const passphrase = process.env.TARO_APP_ENCRYPTION_KEY || 'hms-default-key'; - const bytes = new Uint8Array(32); - for (let i = 0; i < 32; i++) { - bytes[i] = passphrase.charCodeAt(i % passphrase.length) ^ ((i * 37) & 0xff); + if (process.env.NODE_ENV === 'production') { + console.error('[secure-storage] ENCRYPTION_KEY not configured in production'); + return null; } - return bytes; + // dev: warn and use plaintext-compatible mode + console.warn('[secure-storage] ENCRYPTION_KEY empty — using dev plaintext mode'); + return null; +} + +function hasEncryptionKey(): boolean { + return getEncryptionKey() !== null; } function generateNonce(): Uint8Array { @@ -43,13 +47,13 @@ function generateNonce(): Uint8Array { return nonce; } -function aesEncrypt(plaintext: string): string { +function aesEncrypt(plaintext: string): string | null { const key = getEncryptionKey(); + if (!key) return null; const nonce = generateNonce(); const cipher = gcm(key, nonce); const data = new TextEncoder().encode(plaintext); const ciphertext = cipher.encrypt(data); - // nonce(12) + ciphertext 打包 const combined = new Uint8Array(nonce.length + ciphertext.length); combined.set(nonce, 0); combined.set(ciphertext, nonce.length); @@ -58,6 +62,8 @@ function aesEncrypt(plaintext: string): string { function aesDecrypt(encoded: string): string | null { try { + const key = getEncryptionKey(); + if (!key) return null; const b64 = encoded.slice(AES_MARKER.length); const buf = Taro.base64ToArrayBuffer(b64); const combined = new Uint8Array(buf); @@ -65,7 +71,6 @@ function aesDecrypt(encoded: string): string | null { const nonce = combined.slice(0, NONCE_LENGTH); const ciphertext = combined.slice(NONCE_LENGTH); - const key = getEncryptionKey(); const cipher = gcm(key, nonce); const plaintext = cipher.decrypt(ciphertext); return new TextDecoder().decode(plaintext); @@ -74,15 +79,6 @@ function aesDecrypt(encoded: string): string | null { } } -// XOR decryption for reading legacy data -function xorDecrypt(data: string, key: string): string { - let result = ''; - for (let i = 0; i < data.length; i++) { - result += String.fromCharCode(data.charCodeAt(i) ^ key.charCodeAt(i % key.length)); - } - return result; -} - function fromBase64(b64: string): string { try { const buffer = Taro.base64ToArrayBuffer(b64); @@ -92,43 +88,59 @@ function fromBase64(b64: string): string { } } -const LEGACY_KEY = process.env.TARO_APP_ENCRYPTION_KEY || 'hms-default-key'; - export function secureSet(key: string, value: string): void { if (!value) { Taro.removeStorageSync(STORAGE_PREFIX + key); return; } const encrypted = aesEncrypt(value); - Taro.setStorageSync(STORAGE_PREFIX + key, encrypted); + if (encrypted) { + Taro.setStorageSync(STORAGE_PREFIX + key, encrypted); + } else { + // dev mode: store plaintext with prefix for compatibility + Taro.setStorageSync(STORAGE_PREFIX + key, value); + } } export function secureGet(key: string): string { const prefixedKey = STORAGE_PREFIX + key; const raw = Taro.getStorageSync(prefixedKey); if (!raw || typeof raw !== 'string') { - // fallback: 明文键(兼容 MCP 注入) + // fallback: 明文键(兼容 MCP 注入,仅 dev) const plain = Taro.getStorageSync(key); - return plain && typeof plain === 'string' ? plain : ''; + if (plain && typeof plain === 'string') { + if (process.env.NODE_ENV === 'production') { + console.warn('[secure-storage] plaintext fallback in production for key:', key); + } + return plain; + } + return ''; } // AES 格式 if (raw.startsWith(AES_MARKER)) { const decrypted = aesDecrypt(raw); if (decrypted !== null) return decrypted; - } - - // XOR 格式(legacy) - try { - const decoded = fromBase64(raw); - if (decoded) { - return xorDecrypt(decoded, LEGACY_KEY); + // AES 解密失败 + if (process.env.NODE_ENV === 'production') { + console.warn('[secure-storage] AES decrypt failed in production for key:', key); + return ''; } - } catch { - // fallthrough + // dev: fallthrough to try other formats } - // 明文 fallback + // 非加密前缀 — dev mode plaintext 或 legacy XOR + if (!raw.startsWith(AES_MARKER) && hasEncryptionKey()) { + // key is configured but data isn't AES-encrypted — try legacy XOR + try { + const decoded = fromBase64(raw); + if (decoded) return decoded; + } catch { + // fallthrough + } + } + + // dev mode: stored as plaintext by secureSet when no key return raw; } diff --git a/docs/discussions/2026-05-22-miniprogram-deep-analysis-brainstorm.md b/docs/discussions/2026-05-22-miniprogram-deep-analysis-brainstorm.md new file mode 100644 index 0000000..786d50f --- /dev/null +++ b/docs/discussions/2026-05-22-miniprogram-deep-analysis-brainstorm.md @@ -0,0 +1,386 @@ +# 小程序五维度深度分析 — 多专家组头脑风暴 + +> 日期: 2026-05-22 | 参与者: 5 专家组(架构/安全/UX/性能/商业) +> 修正: 2026-05-22 — 专家 5 的商业评估基于"体检中心"错误前提,已按 HMS 实际定位修正 + +## 背景 + +对 HMS 微信小程序进行全面的代码级深度分析(180 TS/TSX 源文件,~31,000 行代码,61 页面),覆盖架构工程、安全合规、用户体验、性能优化、商业价值五个维度。分析目标是识别**真正影响用户体验和系统可靠性**的问题,避免过度设计和自我感动式开发。 + +### 系统定位(修正前提) + +> **HMS 不是体检中心专用系统**,而是面向综合健康管理的 SaaS 平台,覆盖: +> - **慢性病管理** — 高血压、糖尿病等长期随访、日常监测、用药管理 +> - **透析管理** — 血透中心 2-3 次/周的长期治疗 +> - **健康体检** — 但非唯一场景 +> - **在线咨询** — 医患实时沟通 +> - **AI 健康分析** — 数据驱动的趋势分析和报告解读 +> +> 患者端 + 医护端共用一个小程序,61 页面分别服务两类用户。 +> **患者就诊周期不是 1-3 天,而是数月至数年** — 这直接影响功能 ROI 评估。 + +--- + +## 一、专家组评分总览 + +| 维度 | 评分 | 核心发现 | +|------|------|----------| +| 架构工程 | **6.8/10** | 分层清晰,但 auth store 过重、测试覆盖率仅 6%、request.ts 单点风险 | +| 安全合规 | **6.0/10** | 加密密钥硬编码、知情同意为空壳、request-signer 未集成 | +| 用户体验 | **5.9/10** | TabBar 命名误导、"健康"Tab 是录入而非总览、菜单过载 | +| 性能优化 | **7.5/10** | auth.restore() 无条件清缓存、FIFO 而非 LRU 缓存淘汰、遗留数据迁移每次冷启动执行 | +| 商业价值 | **6.5/10**(修正后) | 功能覆盖全面但完成度不均,积分商城/文章/AI 分析有长期留存价值,缺失体检报告查看、危急值通知等高优先功能 | + +**综合评分: 6.5/10 (B)**(修正后;原 6.2 基于错误商业前提) + +--- + +## 二、专家组核心发现 + +### 专家 1:架构与工程 (6.8/10) + +**优势:** +- 6 层分层架构(pages/components/services/stores/hooks/utils)清晰 +- ConcurrencyLimiter(8) 解决微信 10 并发限制 +- generation counter 长轮询模式设计合理 +- CSS 变量主题系统完整 + +**问题:** +- **auth.ts 280 行过重** — 认证 + 患者管理 + 角色判断 + 登录态恢复全在一个 store,违反单一职责 +- **测试覆盖率 6%** — 13 单元测试文件 ~1,626 行 vs 31,000 行源码,远低于 80% 标准 +- **request.ts 单点风险** — 279 行承担并发控制 + 缓存 + token 刷新 + 去重 + 错误处理,无测试覆盖 +- **Zod 未使用** — package.json 无 Zod 依赖,wiki 声称的 schema 验证实际不存在 + +### 专家 2:安全与合规 (6.0/10) + +**CRITICAL:** +- **加密密钥硬编码** — 所有 .env 文件 `TARO_APP_ENCRYPTION_KEY` 为空字符串,secure-storage-aes.ts 使用 `'hms-default-key'` 作为 fallback,等于明文存储 +- **知情同意为空壳** — `grantConsent()` 从未被任何页面调用,consent 系统是摆设 + +**HIGH:** +- **secureGet 明文 fallback** — 解密失败时直接返回明文(为 MCP 兼容性),生产环境不可接受 +- **request-signer 未集成** — HMAC-SHA256 签名工具已实现但 request.ts 未使用 +- **无安全响应头** — CSP、HSTS 等头部依赖服务端,但小程序端无额外防护 + +### 专家 3:用户体验与产品 (5.9/10) + +**导航混乱:** +- "助手" TabBar 名不副实 — 实际是 AI 聊天,用户找不到真正通知 +- "健康" Tab 打开是数据录入表单 — 用户期望看到健康概览 +- "我的" 18 个菜单项过载 — 信息架构混乱 + +**流程断裂:** +- 医生端 TabBar reLaunch 丢失导航状态 +- 设备同步后无数据查看闭环 +- 积分商城 Tab 页空白(需关联患者档案才显示) + +### 专家 4:性能优化 (7.5/10) + +**良好实践:** +- ConcurrencyLimiter 避免微信并发限制 +- generation counter 防止长轮询重叠 +- safeNavigateTo 处理页栈超 10 层 + +**优化空间:** +- **auth.restore() 每次 Tab 切换清缓存** — `clearRequestCache()` 无条件执行,导致跨 Tab 数据重新请求 +- **缓存策略一刀切** — GET 请求统一 60s TTL,不区分数据变化频率 +- **遗留数据迁移每次冷启动** — `migrateLegacyStorage()` 每次启动扫描所有 key +- **FIFO 而非 LRU** — ResponseCache 按插入顺序淘汰,非按访问频率 + +### 专家 5:商业价值与 ROI (6.5/10)(修正后) + +> **修正说明**:原评分 5.0 基于错误前提(体检中心短期就诊),实际 HMS 面向长期健康管理机构(慢性病、透析、随访),患者留存周期数月至数年。积分商城、线下活动、健康资讯等功能在长期留存场景下有实际价值。 + +**功能覆盖评估(基于综合健康管理定位):** + +| 功能域 | 页面数 | 评估 | 理由 | +|--------|--------|------|------| +| 健康数据录入/趋势 | ~8 | 核心必需 | 慢性病日常监测的核心场景 | +| 咨询管理 | ~4 | 核心必需 | 医患沟通是长期管理的关键 | +| 预约管理 | ~3 | 核心必需 | 透析/随访定期复诊 | +| 医生端工作台 | ~16 | 核心必需 | 医护日常工作的入口 | +| 积分商城 | ~4 | **有留存价值** | 慢性病长期激励(签到、连续天数奖励),促进依从性 | +| 健康资讯 | ~3 | **有留存价值** | 健康教育内容提升患者参与度 | +| AI 分析 | ~3 | **有潜力** | 后端 ReAct Agent 架构完善,前端展示需提升 | +| 线下活动 | ~2 | 长尾功能 | 健康讲座/筛查活动,非每日使用但有社区运营价值 | +| 设备同步 | ~1 | 战略功能 | BLE 蓝牙网关是差异化能力 | + +**缺失关键功能(真正的高 ROI 缺口):** +- 体检/化验报告查看 — 后端 `lab_report` 已就绪,前端仅医生端有入口,患者端缺失 +- 用药提醒推送 — 后端 `medication_reminder` entity 已存在,前端无消费 +- 危急值通知 — 后端 alerts + SSE 已就绪,小程序无接收展示 +- 护理计划前端 — 后端 `care_plan` entity 已存在,前端无页面 +- 健康趋势图可视化 — 后端 trend API 已有,小程序 ECharts 趋势页体验待提升 + +**结论**:61 页面覆盖患者端+医护端,**不是过度开发,而是功能完成度不均**。核心链路(健康数据→告警→AI分析→随访→护理计划)的后端已就绪,但前端消费链路有断点。 + +--- + +## 三、头脑风暴 — 聚焦可落地的改进方向 + +> 原则:**有据可依、避免过度开发、每一项改动都有明确的用户价值或安全必要性。** + +### P0:安全必修(必须立即修复) + +#### 1. 加密密钥问题 + +**问题**:`TARO_APP_ENCRYPTION_KEY` 为空 → fallback `'hms-default-key'` → 加密形同虚设。 + +**论证**: +- `secure-storage-aes.ts:42` — passphrase 空时使用 hardcoded `'hms-default-key'` +- 所有 .env 文件确认 `TARO_APP_ENCRYPTION_KEY=""` +- 影响:localStorage 中的 token、用户信息、AI 对话历史实际为伪加密 + +**方案**: +- 非生产环境:在 `.env.development` 设置固定密钥(不提交到 git) +- 生产环境:通过后端 API 动态下发设备绑定密钥(一次握手,存入 keychain) +- 移除 `'hms-default-key'` fallback,空密钥时抛出错误而非静默降级 + +**预估工作量**:1 天 +**ROI**:极高 — 修复一个实际安全漏洞 + +#### 2. 知情同意空壳问题 + +**问题**:`grantConsent()` 定义了但从未被调用,患者隐私数据无保护。 + +**论证**: +- `stores/auth.ts` 有 `grantConsent()` 和 `revokeConsent()` 方法 +- 全局搜索无任何页面调用 `grantConsent` +- 后端 consent 拦截已实现但前端无触发点 + +**方案**: +- 在患者首次查看敏感数据(如 AI 分析、健康报告)时弹出同意弹窗 +- 同意状态存储到后端(已有 consent 相关 API) +- 不同意则不显示数据 + +**预估工作量**:2 天 +**ROI**:高 — 医疗合规必需 + +#### 3. secureGet 明文 fallback + +**问题**:解密失败时 `secureGet` 返回明文值。 + +**论证**: +- `secure-storage-aes.ts` 第 `secureGet()` 中 catch 块返回 plaintext +- 注释说是为 MCP 兼容性,但生产环境不可接受 + +**方案**: +- 生产模式:解密失败返回 `null` 并记录 warn 日志 +- 开发模式:保留 fallback 并打印 warn +- MCP 测试不走 secureStorage + +**预估工作量**:0.5 天 +**ROI**:高 — 消除安全降级路径 + +--- + +### P1:高价值功能补齐(核心就医体验) + +#### 4. "健康" Tab 改为健康总览 + +**问题**:用户点击"健康"Tab 期望看到健康概览,实际是体征录入表单。 + +**论证**: +- 用户心智模型:TabBar 是导航到该领域的总览 +- 当前 `pages/health/index` 是体征录入入口 +- 健康数据已有 service 层(`health.ts`),只需组装展示 + +**方案**: +- "健康"Tab 展示:今日体征摘要 + 最近异常 + 待办提醒 +- 体征录入移到子页面(点击"录入体征"按钮跳转) +- 复用现有 `health` store 的 trendCache 数据 + +**预估工作量**:3 天 +**ROI**:高 — 直接改善核心体验 + +#### 5. 化验/体检报告查看 + +**问题**:患者端无法查看自己的化验报告和体检报告。 + +**论证**: +- 后端已有 `lab_report` entity + handler + service +- 前端 Web 端已实现报告查看 +- 小程序端无对应页面 + +**方案**: +- 新增报告列表页 + 报告详情页 +- 复用 Web 端的数据结构 +- 支持查看异常指标高亮 + +**预估工作量**:5 天 +**ROI**:极高 — 慢性病长期管理核心需求(化验报告是调整治疗方案的关键依据) + +#### 6. 危急值通知 + +**问题**:后端 alerts 表和事件已就绪,前端无接收展示。 + +**论证**: +- 后端已有 `alerts` entity + handler + SSE 端点 +- 小程序长轮询基础设施已有(`useLongPolling` hook) +- 缺少通知页面和推送触发 + +**方案**: +- App 级别长轮询监听 alerts +- 新增通知中心页面(TabBar "助手" → 改为真正的通知+AI 助手) +- 危急值弹出模态框强提醒 + +**预估工作量**:4 天 +**ROI**:高 — 医疗安全必需 + +--- + +### P2:工程健康度(可持续交付) + +#### 7. auth store 拆分 + +**问题**:auth.ts 280 行,职责混杂。 + +**论证**: +- 承担认证 + 患者管理 + 角色判断 + 登录态恢复 + consent 管理 +- 修改任何一个功能都可能影响其他功能 +- restore() 无条件清缓存影响全局性能 + +**方案**: +- `auth.ts` — 仅认证/登录/登出(~100 行) +- `patient.ts` — 患者列表/切换/选择(~80 行) +- `consent.ts` — 知情同意管理(~40 行) +- restore() 仅在首次启动时清缓存,Tab 切换时跳过 + +**预估工作量**:2 天 +**ROI**:中 — 降低维护风险 + +#### 8. request.ts 补测试 + +**问题**:279 行核心模块零测试。 + +**论证**: +- request.ts 承担并发控制、缓存、token 刷新、去重、错误处理 +- 任何修改都可能引入回归 +- 当前 6% 覆盖率下,这个模块是最值得投入测试的 + +**方案**: +- 为 ConcurrencyLimiter 写单元测试 +- 为 ResponseCache 写单元测试(覆盖 FIFO→LRU 改造) +- 为 token 刷新去重写集成测试 +- 为 safeReLaunch 去重写测试 + +**预估工作量**:3 天 +**ROI**:高 — 为后续任何改动提供安全网 + +#### 9. 缓存策略优化 + +**问题**:60s TTL 一刀切,FIFO 淘汰不科学。 + +**论证**: +- 用户信息变化频率低 → 应 5min+ +- 健康数据变化频率中 → 60s 合理 +- 通知数据变化频率高 → 应 10s 或不缓存 +- FIFO 淘汰导致热点数据被误淘汰 + +**方案**: +- 按 API 端点配置差异化 TTL +- ResponseCache 改为 LRU 淘汰(每次 get 更新访问时间) +- auth.restore() 不再无条件清缓存 + +**预估工作量**:1.5 天 +**ROI**:中 — 减少无效请求,提升体验流畅度 + +--- + +### P3:功能完成度补齐(后端已就绪,前端需消费) + +#### 10. 用药提醒推送 + +**问题**:后端 `medication_reminder` entity + 定时检查已就绪,前端无展示。 + +**论证**: +- 慢性病患者(高血压/糖尿病)需要长期用药依从性管理 +- 后端已有 `medication_reminder` entity 和定时任务 +- 小程序无提醒页面 + +**方案**: +- "我的" 新增用药提醒入口 +- 支持查看当日用药计划 + 标记已服用 +- 微信模板消息推送提醒(后端 `wechat-templates.ts` 已注册模板) + +**预估工作量**:3 天 +**ROI**:高 — 慢性病管理核心能力 + +#### 11. 护理计划前端 + +**问题**:后端 `care_plan` / `plan_item` / `plan_result` entity 已就绪,前端无页面。 + +**论证**: +- 护理计划是"数据驱动个性化护理路径"的核心体现 +- 后端 3 个 entity + CRUD API 已完成 +- 前端完全空白 + +**方案**: +- 医生端新增护理计划管理页面(创建/编辑/查看) +- 患者端新增护理计划查看页面(进度追踪) + +**预估工作量**:5 天 +**ROI**:高 — 设计理念中"数据驱动"的核心体现 + +--- + +## 四、优先级排序与执行路线 + +### 总原则 + +> **每项工作必须有用户价值或安全必要性论证,不做没有证据支撑的开发。** + +### 执行顺序(按 ROI 排序) + +| 序号 | 改进项 | 优先级 | 工作量 | ROI | 前置依赖 | +|------|--------|--------|--------|-----|----------| +| 1 | 加密密钥修复 | P0 | 1天 | 极高 | 无 | +| 2 | secureGet 明文 fallback | P0 | 0.5天 | 高 | 无 | +| 3 | 知情同意流程 | P0 | 2天 | 高 | 加密密钥修复 | +| 4 | "健康"Tab 改为总览 | P1 | 3天 | 高 | 无 | +| 5 | 化验/体检报告查看 | P1 | 5天 | 极高 | 后端 API 已就绪 | +| 6 | 危急值通知 | P1 | 4天 | 高 | useLongPolling 已有 | +| 7 | auth store 拆分 | P2 | 2天 | 中 | 补测试后更安全 | +| 8 | request.ts 补测试 | P2 | 3天 | 高 | 无 | +| 9 | 缓存策略优化 | P2 | 1.5天 | 中 | request.ts 测试 | +| 10 | 用药提醒推送 | P3 | 3天 | 高 | 后端 entity 已就绪 | +| 11 | 护理计划前端 | P3 | 5天 | 高 | 后端 3 entity 已就绪 | + +**P0 总工作量:3.5 天** +**P1 总工作量:12 天** +**P2 总工作量:6.5 天** +**P3 总工作量:8 天** + +--- + +## 五、不做什么(明确排除) + +以下是在分析过程中**考虑过但决定不做**的事项,附排除理由: + +| 候选项 | 排除理由 | +|--------|----------| +| 全面 Zod 集成 | 当前 Taro 表单 + 手动验证可工作,ROI 不支撑全面引入新依赖 | +| request-signer 签名集成 | 微信小程序走 HTTPS,请求签名在非代理场景下 ROI 低 | +| BLE 模块重写 | 当前 4 设备适配器工作正常,用户量少,不值得投入 | +| 全面 E2E 测试 | 6%→80% 需要巨大投入,先聚焦 request.ts 核心模块测试 | +| "我的"菜单重构 | 需要完整的用户调研,拍脑袋改可能更差 | +| 组件库二次重构 | 当前组件库迁移刚完成,立即重构浪费 | +| 国际化 i18n | 当前只有中文场景,过早 | +| 暗黑模式 | 医疗场景不需要,用户调研无需求 | + +--- + +## 六、结论 + +小程序当前处于**功能覆盖全面但完成度不均**的状态。核心矛盾不是"过度开发",而是: + +1. **安全基础设施有漏洞** — 加密密钥、知情同意、明文 fallback 三个安全问题必须修复 +2. **后端能力前端未消费** — 化验报告查看、危急值通知、用药提醒、护理计划等后端已就绪但前端无入口 +3. **工程健康度不足** — 6% 测试覆盖率让任何改动都有回归风险 +4. **UX 信息架构需优化** — "健康"Tab 是录入而非总览、TabBar 命名与功能不匹配 + +**61 页面覆盖患者端+医护端,在综合健康管理定位下并不过度。** 真正的问题是后端已经建好的能力(化验报告、告警、护理计划、用药提醒)在前端没有对应的消费页面,形成"后端能力孤岛"。 + +**建议执行 P0(3.5天)→ P1(12天)→ P2(6.5天)→ P3(8天)的渐进式改进路线,总投入约 30 天。** + +优先消费已有后端能力而非开发新功能,每一步都有明确的问题陈述和代码证据。