fix(mp): 安全修复 + 健康Tab重构为总览

Phase 0 安全修复:
- 移除 secure-storage-aes.ts 硬编码 'hms-default-key' fallback
- production 模式空密钥时拒绝加解密(返回空/不加密)
- dev 模式保留明文兼容(warn 日志提醒)
- .env/.env.h5 注入随机加密密钥
- secureGet 明文 fallback 按环境分级处理
- 新增 8 个测试覆盖空密钥 dev/production 行为

Phase 1 健康Tab重构:
- health/index.tsx 从体征录入页改为健康总览Dashboard
- 新增今日体征摘要卡片(2x2 网格 + 状态标签)
- 新增快捷入口(录入体征/趋势/报告/用药)
- 新增告警提示卡片(待处理告警数量)
- 体征录入移至 pkg-health/input/index(已有页面)
- useHealthData → useHealthOverview(新增 alertCount)

首页增强:
- useHomeData 新增告警计数查询(listPatientAlerts)
- 首页新增告警提示卡片入口
- "记录体征"按钮改为跳转录入页而非健康Tab
This commit is contained in:
iven
2026-05-22 11:48:57 +08:00
parent 490ae075b7
commit d24aefe750
9 changed files with 905 additions and 383 deletions

View File

@@ -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 失败返回 nullhasEncryptionKey=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('');
});
});
});

View File

@@ -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;
}
/* ─── 趋势图 ─── */

View File

@@ -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<VitalType, string> {
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 <GuestGuard title='请先登录' desc='登录后即可记录和查看健康数据' />;
return <GuestGuard title='请先登录' desc='登录后即可查看健康数据' />;
}
if (error) {
return (
<PageShell padding="md" safeBottom={false} scroll={false} className={`health-page ${modeClass}`}>
<View className='health-header'>
<Text className='health-title'></Text>
<Text className='health-title'></Text>
</View>
<ErrorState onRetry={fetchData} />
<Loading />
</PageShell>
);
}
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 (
<PageShell padding="md" safeBottom={false} scroll={false} className={`health-page ${modeClass}`}>
<PageShell padding="md" safeBottom={false} scroll className={`health-page ${modeClass}`}>
<View className='health-header'>
<Text className='health-title'></Text>
<Text className='health-title'></Text>
</View>
{/* 今日体征摘要 */}
<ContentCard variant="elevated" className='vitals-grid'>
{loading ? <Loading /> : (
<View className='vitals-row'>
{vitals.map((v) => (
<View className={`vital-cell ${statusClass(v.status)}`} key={v.label}>
<Text className='vital-value'>{v.value}</Text>
<Text className='vital-unit'>{v.unit}</Text>
<Text className='vital-label'>{v.label}</Text>
</View>
))}
</View>
)}
</ContentCard>
{/* 快捷入口 */}
<View className='quick-entries'>
{QUICK_ENTRIES.map((e) => (
<View
key={e.label}
className='quick-entry'
onClick={() => safeNavigateTo(e.path)}
>
<View className='quick-icon'>
<Text className='quick-icon-text'>{e.icon}</Text>
</View>
<Text className='quick-label'>{e.label}</Text>
</View>
))}
</View>
{/* 告警提示 */}
{alertCount > 0 && (
<ContentCard
variant="elevated"
className='alert-hint'
onPress={() => safeNavigateTo('/pages/pkg-health/alerts/index')}
>
<View className='alert-dot' />
<Text className='alert-text'>{alertCount} </Text>
<Text className='alert-arrow'></Text>
</ContentCard>
)}
{/* AI 建议 */}
{aiSuggestions.length > 0 && (
<View className='ai-suggestion-card'>
<View className='ai-card-header'>
<Text className='ai-card-title'>AI </Text>
<Text className='ai-card-count'>{aiSuggestions.length} </Text>
<Text className='ai-card-count'>{aiSuggestions.length} </Text>
</View>
{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<string, unknown> | null;
const reason = (params?.reason as string) || (params?.message as string) || typeLabel;
const reason = (params?.reason as string) || (params?.message as string) || '健康建议';
return (
<View key={s.id} className='ai-suggestion-item'>
<View className='ai-suggestion-main' onClick={() => {
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');
}}>
<View className={`ai-risk-dot ${riskCls}`} />
<Text className='ai-suggestion-text'>{reason.slice(0, 40)}</Text>
<Text className='ai-suggestion-text'>{reason.slice(0, 50)}</Text>
</View>
<View className='ai-feedback-row'>
<View className='ai-feedback-btn ai-feedback-adopt' onClick={async () => {
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' }); }
}}>
<Text className='ai-feedback-btn-text'></Text>
</View>
<View className='ai-feedback-btn ai-feedback-ignore' onClick={async () => {
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' }); }
}}>
<Text className='ai-feedback-btn-text'></Text>
</View>
<View className='ai-feedback-btn ai-feedback-consult' onClick={async () => {
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' }); }
}}>
<Text className='ai-feedback-btn-text'></Text>
</View>
@@ -240,115 +174,32 @@ export default function Health() {
</View>
)}
<SegmentTabs tabs={VITAL_TABS} activeKey={activeTab} onChange={handleTabChange} variant="pill" />
<ContentCard variant="elevated">
{activeTab === 'blood_pressure' && (
<View className='input-group'>
<Text className='input-label'></Text>
<Input
className='input-field'
type='number'
placeholder='如 130'
value={systolic}
onInput={(e) => setSystolic(e.detail.value)}
/>
<Text className='input-label input-label--secondary'></Text>
<Input
className='input-field'
type='number'
placeholder='如 85'
value={diastolic}
onInput={(e) => setDiastolic(e.detail.value)}
/>
<Text className='input-ref'>{refRanges.blood_pressure}</Text>
</View>
)}
{activeTab === 'heart_rate' && (
<View className='input-group'>
<Text className='input-label'></Text>
<Input
className='input-field'
type='digit'
placeholder='如 72'
value={heartRateVal}
onInput={(e) => setHeartRateVal(e.detail.value)}
/>
<Text className='input-ref'>{refRanges.heart_rate}</Text>
</View>
)}
{activeTab === 'blood_sugar' && (
<View className='input-group'>
<Text className='input-label'></Text>
<Input
className='input-field'
type='digit'
placeholder='如 5.6'
value={sugarVal}
onInput={(e) => setSugarVal(e.detail.value)}
/>
<View className='period-group'>
<View
className={`period-btn ${sugarPeriod === 'fasting' ? 'period-active' : ''}`}
onClick={() => setSugarPeriod('fasting')}
>
<Text className='period-btn-text'></Text>
</View>
<View
className={`period-btn ${sugarPeriod === 'postprandial' ? 'period-active' : ''}`}
onClick={() => setSugarPeriod('postprandial')}
>
<Text className='period-btn-text'> 2h</Text>
</View>
</View>
<Text className='input-ref'>{refRanges.blood_sugar}</Text>
</View>
)}
{activeTab === 'weight' && (
<View className='input-group'>
<Text className='input-label'> (kg)</Text>
<Input
className='input-field'
type='digit'
placeholder='如 65.5'
value={weightVal}
onInput={(e) => setWeightVal(e.detail.value)}
/>
<Text className='input-ref'>{refRanges.weight}</Text>
</View>
)}
<View className='save-btn' onClick={handleSave}>
<Text className='save-btn-text'>{saving ? '保存中...' : '保存'}</Text>
</View>
</ContentCard>
{/* 7天趋势 */}
<View className='trend-section'>
<Text className='section-title'> 7 </Text>
{trendLoading ? (
<Loading />
) : trendData.length === 0 ? (
<SegmentTabs tabs={VITAL_TABS} activeKey={activeTab} onChange={handleTabChange} variant="pill" />
{trendLoading ? <Loading /> : trendData.length === 0 ? (
<ContentCard padding="md">
<Text className='trend-empty-text'></Text>
</ContentCard>
) : (
<ContentCard padding="md">
<View className='trend-bars'>
{getThresholdValue(activeTab, thresholds) && (() => {
const tv = getThresholdValue(activeTab, thresholds)!;
const pct = Math.min(95, (tv / maxTrendValue) * 100);
return (
<View className='trend-threshold-line' style={`bottom:${((12 + pct * 1.08) / 120 * 100).toFixed(1)}%;`}>
<Text className='trend-threshold-label'>{tv}</Text>
</View>
);
{(() => {
const tv = getThresholdValue(activeTab);
if (tv) {
const pct = Math.min(95, (tv / maxTrendValue) * 100);
return (
<View className='trend-threshold-line' style={`bottom:${((12 + pct * 1.08) / 120 * 100).toFixed(1)}%;`}>
<Text className='trend-threshold-label'>{tv}</Text>
</View>
);
}
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() {
)}
</View>
<ContentCard
onPress={() => safeNavigateTo('/pages/article/index')}
>
{/* 健康资讯入口 */}
<ContentCard onPress={() => safeNavigateTo('/pages/article/index')}>
<Text className='article-entry-text'> </Text>
</ContentCard>
</PageShell>

View File

@@ -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<VitalType>('blood_pressure');
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
const [trendLoading, setTrendLoading] = useState(false);
const [aiSuggestions, setAiSuggestions] = useState<AiSuggestionItem[]>([]);
const [thresholds, setThresholds] = useState<HealthThreshold[]>(DEFAULT_THRESHOLDS);
const [alertCount, setAlertCount] = useState(0);
const loadTrend = async (type: VitalType) => {
setTrendLoading(true);
try {
const indicatorMap: Record<VitalType, string> = {
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,
};
}

View File

@@ -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;
}

View File

@@ -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 }) {
)}
<View className='action-section'>
<View className='action-btn action-primary' onClick={() => Taro.switchTab({ url: '/pages/health/index' })}>
<Text className='action-btn-text'></Text>
<View className='action-btn action-primary' onClick={() => safeNavigateTo('/pages/pkg-health/input/index')}>
<Text className='action-btn-text'></Text>
</View>
<View className='action-btn action-outline' onClick={() => safeNavigateTo('/pages/appointment/create/index')}>
<Text className='action-btn-text'></Text>
</View>
</View>
{alertCount > 0 && (
<ContentCard
variant="elevated"
className='home-alert-card'
onPress={() => safeNavigateTo('/pages/pkg-health/alerts/index')}
>
<View className='home-alert-dot' />
<Text className='home-alert-text'>{alertCount} </Text>
<Text className='home-alert-arrow'></Text>
</ContentCard>
)}
<SOSButton />
</PageShell>
);

View File

@@ -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<ReminderItem[]>([]);
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,

View File

@@ -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;
}

View File

@@ -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 页面覆盖患者端+医护端,在综合健康管理定位下并不过度。** 真正的问题是后端已经建好的能力(化验报告、告警、护理计划、用药提醒)在前端没有对应的消费页面,形成"后端能力孤岛"。
**建议执行 P03.5天)→ P112天→ P26.5天)→ P38天的渐进式改进路线总投入约 30 天。**
优先消费已有后端能力而非开发新功能,每一步都有明确的问题陈述和代码证据。