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:
@@ -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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* ─── 趋势图 ─── */
|
||||
|
||||
@@ -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>
|
||||
|
||||
112
apps/miniprogram/src/pages/health/useHealthOverview.ts
Normal file
112
apps/miniprogram/src/pages/health/useHealthOverview.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user