feat(mp+health): 小程序分包迁移 + 积分商城后台列表 API
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- 小程序页面迁移到 pkg-health/pkg-mall/pkg-profile 分包目录
- 删除旧 pages/health/input、pages/mall/detail 等旧路径
- 导航路径更新为分包路径(/pages/pkg-mall/exchange/index 等)
- TrendChart 组件优化
- 后台添加 admin_list_products API(支持查看已下架商品)
- config/index.ts 添加 defineConstants 环境变量
- mp e2e check-readiness 路径修正
This commit is contained in:
iven
2026-04-29 07:29:49 +08:00
parent 9015a2b85e
commit cb6f5cc651
32 changed files with 229 additions and 516 deletions

View File

@@ -0,0 +1,288 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
.dm-page {
min-height: 100vh;
background: $bg;
padding: 0 0 60px;
}
/* ── hero ── */
.dm-hero {
padding: 48px 32px 36px;
display: flex;
flex-direction: column;
align-items: center;
}
.dm-hero-icon {
@include flex-center;
width: 88px;
height: 88px;
border-radius: $r-lg;
background: $pri-l;
margin-bottom: 20px;
}
.dm-hero-icon-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 40px;
font-weight: bold;
color: $pri;
}
.dm-hero-title {
@include section-title;
font-size: 36px;
margin-bottom: 8px;
}
.dm-hero-sub {
font-size: 24px;
color: $tx3;
}
/* ── card (standalone, used for date picker) ── */
.dm-card {
background: $card;
border-radius: $r;
box-shadow: $shadow-md;
padding: 28px;
margin: 0 24px 20px;
}
.dm-card-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 20px;
}
.dm-card-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-weight: bold;
color: $tx;
}
.dm-card-badge {
@include tag($acc-l, $acc);
font-size: 20px;
margin-left: auto;
}
/* ── date picker ── */
.dm-date-row {
display: flex;
justify-content: space-between;
align-items: center;
background: $bg;
border-radius: $r-sm;
padding: 22px 24px;
}
.dm-date-value {
font-size: 28px;
color: $pri;
@include serif-number;
font-weight: bold;
}
.dm-date-arrow {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 22px;
color: $tx3;
transform: rotate(180deg);
display: inline-block;
}
/* ── collapsible group ── */
.dm-group {
background: $card;
border-radius: $r;
box-shadow: $shadow-md;
margin: 0 24px 20px;
overflow: hidden;
}
.dm-group-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 28px;
&:active {
background: $bd-l;
}
}
.dm-group-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-weight: 600;
color: $tx;
}
.dm-group-arrow {
font-size: 24px;
color: $tx3;
transition: transform 0.2s ease;
display: inline-block;
}
.dm-group-arrow-open {
transform: rotate(90deg);
}
.dm-group-body {
padding: 0 28px 28px;
}
.dm-group-collapsed .dm-group-body {
display: none;
}
/* ── inner field spacing (within groups) ── */
.dm-inner-field {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
}
/* ── blood pressure group ── */
.dm-bp-group {
display: flex;
align-items: flex-end;
gap: 12px;
}
.dm-bp-field {
flex: 1;
}
.dm-field-label {
font-size: 22px;
color: $tx2;
display: block;
margin-bottom: 8px;
}
.dm-bp-divider {
display: flex;
flex-direction: column;
align-items: center;
padding-bottom: 20px;
gap: 6px;
}
.dm-bp-line {
width: 16px;
height: 1px;
background: $bd;
}
.dm-bp-slash {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 36px;
color: $tx3;
font-weight: 300;
}
.dm-field-unit {
font-size: 22px;
color: $tx3;
display: block;
margin-top: 10px;
font-style: italic;
}
/* ── single row with unit ── */
.dm-single-row {
display: flex;
align-items: center;
gap: 16px;
}
.dm-input-flex {
flex: 1;
}
.dm-unit-inline {
font-size: 26px;
color: $tx3;
white-space: nowrap;
flex-shrink: 0;
}
/* ── input field ── */
.dm-input-box {
background: $bg;
border-radius: $r-sm;
padding: 20px 24px;
font-size: 28px;
color: $tx;
@include serif-number;
box-sizing: border-box;
}
.dm-input-full {
width: 100%;
}
/* ── abnormal value highlighting ── */
.dm-input-abnormal {
border: 2px solid $wrn;
background: $wrn-l;
}
.dm-field-warning {
font-size: 22px;
color: $wrn;
margin-top: 8px;
display: block;
}
.dm-field-warning-low {
color: #0284C7;
}
/* ── submit ── */
.dm-submit {
background: $pri;
border-radius: $r;
padding: 26px;
text-align: center;
margin: 40px 24px 0;
box-shadow: $shadow-md;
transition: opacity 0.2s;
&:active {
opacity: 0.85;
}
}
.dm-submit-disabled {
opacity: 0.5;
box-shadow: none;
}
.dm-submit-text {
font-size: 32px;
color: white;
font-weight: bold;
letter-spacing: 2px;
}
/* ── reset ── */
.dm-reset {
text-align: center;
padding: 24px;
margin-top: 12px;
}
.dm-reset-text {
font-size: 26px;
color: $tx3;
}

View File

@@ -0,0 +1,487 @@
import { useState } from 'react';
import { View, Text, Input, Picker } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro';
import { z } from 'zod';
import { createDailyMonitoring } from '@/services/health';
import { useAuthStore } from '@/stores/auth';
import { useHealthStore } from '@/stores/health';
import { usePointsStore } from '@/stores/points';
import { clearRequestCache } from '@/services/request';
import { trackEvent } from '@/services/analytics';
import './index.scss';
const bpSchema = z.number().min(30, '血压值不能低于30').max(300, '血压值不能高于300').optional();
const weightSchema = z.number().min(1, '体重不能低于1kg').max(500, '体重不能高于500kg').optional();
const bloodSugarSchema = z.number().min(0.1, '血糖值不能低于0.1').max(50, '血糖值不能高于50').optional();
const volumeSchema = z.number().min(0, '数值不能为负').max(10000, '数值超出合理范围').optional();
function formatDate(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
// ── Abnormal value detection ──
const REFERENCE_RANGES: Record<string, { min: number; max: number } | null> = {
systolic: { min: 90, max: 140 },
diastolic: { min: 60, max: 90 },
bloodSugar: { min: 3.9, max: 6.1 },
weight: null,
fluidIntake: null,
urineOutput: null,
};
type AbnormalResult = { abnormal: boolean; direction: 'high' | 'low' | null };
const checkAbnormal = (value: string, field: string): AbnormalResult => {
const ref = REFERENCE_RANGES[field];
if (!value || !ref) return { abnormal: false, direction: null };
const num = parseFloat(value);
if (isNaN(num)) return { abnormal: false, direction: null };
if (num > ref.max) return { abnormal: true, direction: 'high' };
if (num < ref.min) return { abnormal: true, direction: 'low' };
return { abnormal: false, direction: null };
};
// ── Section state type ──
type SectionKey = 'morning' | 'evening' | 'other';
const FIELD_LABELS: Record<string, string> = {
morningSystolic: '晨间收缩压',
morningDiastolic: '晨间舒张压',
eveningSystolic: '晚间收缩压',
eveningDiastolic: '晚间舒张压',
bloodSugar: '血糖',
};
export default function DailyMonitoring() {
const { currentPatient } = useAuthStore();
const today = formatDate(new Date());
const [dateIdx, setDateIdx] = useState(0);
const [dateList] = useState(() => {
const list: string[] = [];
for (let i = 0; i < 30; i++) {
const d = new Date();
d.setDate(d.getDate() - i);
list.push(formatDate(d));
}
return list;
});
const recordDate = dateList[dateIdx];
const [morningSystolic, setMorningSystolic] = useState('');
const [morningDiastolic, setMorningDiastolic] = useState('');
const [eveningSystolic, setEveningSystolic] = useState('');
const [eveningDiastolic, setEveningDiastolic] = useState('');
const [weight, setWeight] = useState('');
const [bloodSugar, setBloodSugar] = useState('');
const [fluidIntake, setFluidIntake] = useState('');
const [urineOutput, setUrineOutput] = useState('');
const [notes, setNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
// ── Collapsible sections ──
const [collapsed, setCollapsed] = useState<Record<SectionKey, boolean>>({
morning: false,
evening: false,
other: true,
});
const toggleSection = (key: SectionKey) => {
setCollapsed(prev => ({ ...prev, [key]: !prev[key] }));
};
useDidShow(() => {
Taro.setNavigationBarTitle({ title: '日常监测上报' });
});
const resetForm = () => {
setMorningSystolic('');
setMorningDiastolic('');
setEveningSystolic('');
setEveningDiastolic('');
setWeight('');
setBloodSugar('');
setFluidIntake('');
setUrineOutput('');
setNotes('');
};
// ── Abnormal field gathering for submit confirmation ──
const gatherAbnormalFields = (): string[] => {
const abnormalFields: string[] = [];
const checks: Array<[string, string]> = [
['morningSystolic', morningSystolic],
['morningDiastolic', morningDiastolic],
['eveningSystolic', eveningSystolic],
['eveningDiastolic', eveningDiastolic],
['bloodSugar', bloodSugar],
];
for (const [field, value] of checks) {
const result = checkAbnormal(value, field);
if (result.abnormal) {
abnormalFields.push(FIELD_LABELS[field]);
}
}
return abnormalFields;
};
const handleSubmit = async () => {
if (!currentPatient) {
Taro.showToast({ title: '请先选择就诊人', icon: 'none' });
return;
}
const hasData =
morningSystolic || morningDiastolic ||
eveningSystolic || eveningDiastolic ||
weight || bloodSugar || fluidIntake || urineOutput;
if (!hasData) {
Taro.showToast({ title: '请至少填写一项数据', icon: 'none' });
return;
}
if ((morningSystolic && !morningDiastolic) || (!morningSystolic && morningDiastolic)) {
Taro.showToast({ title: '晨起血压请同时填写收缩压和舒张压', icon: 'none' });
return;
}
if ((eveningSystolic && !eveningDiastolic) || (!eveningSystolic && eveningDiastolic)) {
Taro.showToast({ title: '晚间血压请同时填写收缩压和舒张压', icon: 'none' });
return;
}
const parseNum = (v: string) => v ? parseFloat(v) : undefined;
const fields = {
morningSystolic: parseNum(morningSystolic),
morningDiastolic: parseNum(morningDiastolic),
eveningSystolic: parseNum(eveningSystolic),
eveningDiastolic: parseNum(eveningDiastolic),
weight: parseNum(weight),
bloodSugar: parseNum(bloodSugar),
fluidIntake: parseNum(fluidIntake),
urineOutput: parseNum(urineOutput),
};
const validations: Array<[z.ZodTypeAny, number | undefined, string]> = [
[bpSchema, fields.morningSystolic, '晨起收缩压'],
[bpSchema, fields.morningDiastolic, '晨起舒张压'],
[bpSchema, fields.eveningSystolic, '晚间收缩压'],
[bpSchema, fields.eveningDiastolic, '晚间舒张压'],
[weightSchema, fields.weight, '体重'],
[bloodSugarSchema, fields.bloodSugar, '血糖'],
[volumeSchema, fields.fluidIntake, '饮水量'],
[volumeSchema, fields.urineOutput, '尿量'],
];
for (const [schema, value, label] of validations) {
if (value !== undefined) {
const result = schema.safeParse(value);
if (!result.success) {
Taro.showToast({ title: `${label}: ${result.error.errors[0].message}`, icon: 'none' });
return;
}
}
}
// ── Pre-submit abnormal confirmation ──
const abnormalFields = gatherAbnormalFields();
if (abnormalFields.length > 0) {
const confirmed = await Taro.showModal({
title: '数值异常提醒',
content: `以下指标超出正常范围:${abnormalFields.join('、')}。确认提交?`,
confirmText: '确认提交',
cancelText: '返回修改',
});
if (!confirmed.confirm) return;
}
setSubmitting(true);
try {
await createDailyMonitoring({
patient_id: currentPatient.id,
record_date: recordDate,
morning_bp_systolic: morningSystolic ? parseFloat(morningSystolic) : undefined,
morning_bp_diastolic: morningDiastolic ? parseFloat(morningDiastolic) : undefined,
evening_bp_systolic: eveningSystolic ? parseFloat(eveningSystolic) : undefined,
evening_bp_diastolic: eveningDiastolic ? parseFloat(eveningDiastolic) : undefined,
weight: weight ? parseFloat(weight) : undefined,
blood_sugar: bloodSugar ? parseFloat(bloodSugar) : undefined,
fluid_intake: fluidIntake ? parseFloat(fluidIntake) : undefined,
urine_output: urineOutput ? parseFloat(urineOutput) : undefined,
notes: notes || undefined,
});
trackEvent('daily_monitoring_submit', { date: recordDate });
useHealthStore.getState().clearCache();
clearRequestCache('/health/');
usePointsStore.getState().invalidate();
Taro.showToast({ title: '上报成功', icon: 'success' });
setTimeout(() => {
Taro.showToast({ title: '+10 健康积分', icon: 'none', duration: 1500 });
}, 1600);
setTimeout(() => {
Taro.navigateBack();
}, 3200);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '上报失败';
if (msg.includes('已有记录') || msg.includes('already exists')) {
Taro.showModal({
title: '提示',
content: '该日期已有监测记录,请选择其他日期',
showCancel: false,
});
} else {
Taro.showToast({ title: msg, icon: 'none' });
}
} finally {
setSubmitting(false);
}
};
const isToday = recordDate === today;
// ── Abnormal state helpers for rendering ──
const morningSysAbnormal = checkAbnormal(morningSystolic, 'systolic');
const morningDiaAbnormal = checkAbnormal(morningDiastolic, 'diastolic');
const eveningSysAbnormal = checkAbnormal(eveningSystolic, 'systolic');
const eveningDiaAbnormal = checkAbnormal(eveningDiastolic, 'diastolic');
const bloodSugarAbnormal = checkAbnormal(bloodSugar, 'bloodSugar');
return (
<View className='dm-page'>
{/* 页面标题 */}
<View className='dm-hero'>
<View className='dm-hero-icon'>
<Text className='dm-hero-icon-text'></Text>
</View>
<Text className='dm-hero-title'></Text>
<Text className='dm-hero-sub'></Text>
</View>
{/* 日期选择 (standalone card) */}
<View className='dm-card'>
<View className='dm-card-header'>
<Text className='dm-card-title'></Text>
{isToday && (
<Text className='dm-card-badge'></Text>
)}
</View>
<Picker
mode='selector'
range={dateList}
value={dateIdx}
onChange={(e) => setDateIdx(Number(e.detail.value))}
>
<View className='dm-date-row'>
<Text className='dm-date-value'>{recordDate}</Text>
<Text className='dm-date-arrow'>V</Text>
</View>
</Picker>
</View>
{/* ── Group 1: 晨间体征 (default open) ── */}
<View className={`dm-group${collapsed.morning ? ' dm-group-collapsed' : ''}`}>
<View className='dm-group-header' onClick={() => toggleSection('morning')}>
<Text className='dm-group-title'></Text>
<Text className={`dm-group-arrow${collapsed.morning ? '' : ' dm-group-arrow-open'}`}>&#9656;</Text>
</View>
<View className='dm-group-body'>
<View className='dm-bp-group'>
<View className='dm-bp-field'>
<Text className='dm-field-label'></Text>
<Input
type='digit'
className={`dm-input-box${morningSysAbnormal.abnormal ? ' dm-input-abnormal' : ''}`}
placeholder='如 120'
value={morningSystolic}
onInput={(e) => setMorningSystolic(e.detail.value)}
/>
{morningSysAbnormal.abnormal && (
<Text className={`dm-field-warning${morningSysAbnormal.direction === 'low' ? ' dm-field-warning-low' : ''}`}>
{morningSysAbnormal.direction === 'high' ? '偏高' : '偏低'}
</Text>
)}
</View>
<View className='dm-bp-divider'>
<View className='dm-bp-line' />
<Text className='dm-bp-slash'>/</Text>
<View className='dm-bp-line' />
</View>
<View className='dm-bp-field'>
<Text className='dm-field-label'></Text>
<Input
type='digit'
className={`dm-input-box${morningDiaAbnormal.abnormal ? ' dm-input-abnormal' : ''}`}
placeholder='如 80'
value={morningDiastolic}
onInput={(e) => setMorningDiastolic(e.detail.value)}
/>
{morningDiaAbnormal.abnormal && (
<Text className={`dm-field-warning${morningDiaAbnormal.direction === 'low' ? ' dm-field-warning-low' : ''}`}>
{morningDiaAbnormal.direction === 'high' ? '偏高' : '偏低'}
</Text>
)}
</View>
</View>
<Text className='dm-field-unit'>mmHg</Text>
</View>
</View>
{/* ── Group 2: 晚间体征 (default open) ── */}
<View className={`dm-group${collapsed.evening ? ' dm-group-collapsed' : ''}`}>
<View className='dm-group-header' onClick={() => toggleSection('evening')}>
<Text className='dm-group-title'></Text>
<Text className={`dm-group-arrow${collapsed.evening ? '' : ' dm-group-arrow-open'}`}>&#9656;</Text>
</View>
<View className='dm-group-body'>
<View className='dm-bp-group'>
<View className='dm-bp-field'>
<Text className='dm-field-label'></Text>
<Input
type='digit'
className={`dm-input-box${eveningSysAbnormal.abnormal ? ' dm-input-abnormal' : ''}`}
placeholder='如 120'
value={eveningSystolic}
onInput={(e) => setEveningSystolic(e.detail.value)}
/>
{eveningSysAbnormal.abnormal && (
<Text className={`dm-field-warning${eveningSysAbnormal.direction === 'low' ? ' dm-field-warning-low' : ''}`}>
{eveningSysAbnormal.direction === 'high' ? '偏高' : '偏低'}
</Text>
)}
</View>
<View className='dm-bp-divider'>
<View className='dm-bp-line' />
<Text className='dm-bp-slash'>/</Text>
<View className='dm-bp-line' />
</View>
<View className='dm-bp-field'>
<Text className='dm-field-label'></Text>
<Input
type='digit'
className={`dm-input-box${eveningDiaAbnormal.abnormal ? ' dm-input-abnormal' : ''}`}
placeholder='如 80'
value={eveningDiastolic}
onInput={(e) => setEveningDiastolic(e.detail.value)}
/>
{eveningDiaAbnormal.abnormal && (
<Text className={`dm-field-warning${eveningDiaAbnormal.direction === 'low' ? ' dm-field-warning-low' : ''}`}>
{eveningDiaAbnormal.direction === 'high' ? '偏高' : '偏低'}
</Text>
)}
</View>
</View>
<Text className='dm-field-unit'>mmHg</Text>
</View>
</View>
{/* ── Group 3: 其他指标 (default collapsed) ── */}
<View className={`dm-group${collapsed.other ? ' dm-group-collapsed' : ''}`}>
<View className='dm-group-header' onClick={() => toggleSection('other')}>
<Text className='dm-group-title'></Text>
<Text className={`dm-group-arrow${collapsed.other ? '' : ' dm-group-arrow-open'}`}>&#9656;</Text>
</View>
<View className='dm-group-body'>
{/* 体重 */}
<View className='dm-inner-field'>
<Text className='dm-field-label'></Text>
<View className='dm-single-row'>
<Input
type='digit'
className='dm-input-box dm-input-flex'
placeholder='如 65.0'
value={weight}
onInput={(e) => setWeight(e.detail.value)}
/>
<Text className='dm-unit-inline'>kg</Text>
</View>
</View>
{/* 血糖 */}
<View className='dm-inner-field'>
<Text className='dm-field-label'></Text>
<View className='dm-single-row'>
<Input
type='digit'
className={`dm-input-box dm-input-flex${bloodSugarAbnormal.abnormal ? ' dm-input-abnormal' : ''}`}
placeholder='如 5.6'
value={bloodSugar}
onInput={(e) => setBloodSugar(e.detail.value)}
/>
<Text className='dm-unit-inline'>mmol/L</Text>
</View>
{bloodSugarAbnormal.abnormal && (
<Text className={`dm-field-warning${bloodSugarAbnormal.direction === 'low' ? ' dm-field-warning-low' : ''}`}>
{bloodSugarAbnormal.direction === 'high' ? '偏高' : '偏低'}
</Text>
)}
</View>
{/* 饮水量 */}
<View className='dm-inner-field'>
<Text className='dm-field-label'></Text>
<View className='dm-single-row'>
<Input
type='digit'
className='dm-input-box dm-input-flex'
placeholder='如 2000'
value={fluidIntake}
onInput={(e) => setFluidIntake(e.detail.value)}
/>
<Text className='dm-unit-inline'>ml</Text>
</View>
</View>
{/* 尿量 */}
<View className='dm-inner-field'>
<Text className='dm-field-label'>尿</Text>
<View className='dm-single-row'>
<Input
type='digit'
className='dm-input-box dm-input-flex'
placeholder='如 1500'
value={urineOutput}
onInput={(e) => setUrineOutput(e.detail.value)}
/>
<Text className='dm-unit-inline'>ml</Text>
</View>
</View>
{/* 备注 */}
<View className='dm-inner-field'>
<Text className='dm-field-label'></Text>
<Input
className='dm-input-box dm-input-full'
placeholder='如:头晕、乏力等(可选)'
value={notes}
onInput={(e) => setNotes(e.detail.value)}
/>
</View>
</View>
</View>
{/* 提交 */}
<View
className={`dm-submit ${submitting ? 'dm-submit-disabled' : ''}`}
onClick={submitting ? undefined : handleSubmit}
>
<Text className='dm-submit-text'>{submitting ? '提交中...' : '提交上报'}</Text>
</View>
{/* 重置 */}
<View className='dm-reset' onClick={resetForm}>
<Text className='dm-reset-text'></Text>
</View>
</View>
);
}

View File

@@ -0,0 +1,170 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
.trend-page {
min-height: 100vh;
background: $bg;
padding-bottom: 60px;
}
/* ── hero ── */
.trend-hero {
padding: 48px 32px 28px;
display: flex;
flex-direction: column;
align-items: center;
}
.trend-hero-icon {
@include flex-center;
width: 88px;
height: 88px;
border-radius: $r-lg;
background: $pri-l;
margin-bottom: 20px;
}
.trend-hero-icon-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 40px;
font-weight: bold;
color: $pri;
}
.trend-hero-title {
@include section-title;
font-size: 36px;
margin-bottom: 0;
}
/* ── range tabs ── */
.trange-wrap {
display: flex;
justify-content: center;
gap: 16px;
padding: 0 32px 28px;
}
.trange-tab {
padding: 12px 32px;
border-radius: $r-pill;
background: $card;
box-shadow: $shadow-sm;
transition: all 0.2s;
}
.trange-tab-active {
background: $pri;
box-shadow: $shadow-md;
}
.trange-tab-text {
font-size: 24px;
color: $tx2;
font-weight: 500;
}
.trange-tab-text-active {
color: white;
}
/* ── chart card ── */
.trend-chart-card {
margin: 0 24px 20px;
background: $card;
border-radius: $r;
box-shadow: $shadow-md;
padding: 24px;
min-height: 300px;
overflow: hidden;
}
/* ── reference card ── */
.trend-ref-card {
margin: 0 24px 20px;
background: $acc-l;
border-radius: $r;
padding: 20px 24px;
display: flex;
align-items: center;
gap: 16px;
}
.trend-ref-label {
font-size: 24px;
color: $acc;
font-weight: bold;
}
.trend-ref-value {
font-size: 26px;
color: $acc;
@include serif-number;
font-weight: 500;
}
/* ── list ── */
.trend-list {
margin: 0 24px;
}
.trend-list-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-weight: bold;
color: $tx;
display: block;
margin-bottom: 16px;
}
.trend-item {
display: flex;
justify-content: space-between;
align-items: center;
background: $card;
padding: 22px 28px;
border-bottom: 1px solid $bd-l;
&:first-child {
border-radius: $r $r 0 0;
}
&:last-child {
border-radius: 0 0 $r $r;
border-bottom: none;
}
}
.trend-item-warn {
background: $wrn-l;
}
.trend-item-left {
display: flex;
align-items: center;
gap: 12px;
}
.trend-item-date {
font-size: 26px;
color: $tx2;
}
.trend-item-tag {
@include tag($wrn-l, $wrn);
}
.trend-item-warn .trend-item-tag {
@include tag($dan-l, $dan);
}
.trend-item-value {
font-size: 28px;
color: $pri;
@include serif-number;
font-weight: bold;
}
.trend-item-value-warn {
color: $dan;
}

View File

@@ -0,0 +1,111 @@
import { useState, useEffect } from 'react';
import { View, Text } from '@tarojs/components';
import { useRouter } from '@tarojs/taro';
import { useHealthStore } from '@/stores/health';
import TrendChart from '@/components/TrendChart';
import './index.scss';
const RANGE_OPTIONS = [
{ value: '7d', label: '7天' },
{ value: '30d', label: '30天' },
{ value: '90d', label: '90天' },
];
const INDICATOR_META: Record<string, { label: string; unit: string; refMin?: number; refMax?: number }> = {
blood_pressure_systolic: { label: '收缩压', unit: 'mmHg', refMin: 90, refMax: 140 },
blood_pressure_diastolic: { label: '舒张压', unit: 'mmHg', refMin: 60, refMax: 90 },
heart_rate: { label: '心率', unit: 'bpm', refMin: 60, refMax: 100 },
blood_sugar_fasting: { label: '空腹血糖', unit: 'mmol/L', refMin: 3.9, refMax: 6.1 },
blood_sugar_postprandial: { label: '餐后血糖', unit: 'mmol/L', refMin: 3.9, refMax: 7.8 },
weight: { label: '体重', unit: 'kg' },
};
export default function Trend() {
const router = useRouter();
const indicator = router.params.indicator || 'heart_rate';
const [range, setRange] = useState('7d');
const [points, setPoints] = useState<{ date: string; value: number }[]>([]);
const { getTrend } = useHealthStore();
useEffect(() => {
getTrend(indicator, range).then(setPoints);
}, [indicator, range]);
const meta = INDICATOR_META[indicator] || { label: indicator, unit: '' };
const isOutOfRange = (val: number) => {
if (meta.refMin !== undefined && val < meta.refMin) return true;
if (meta.refMax !== undefined && val > meta.refMax) return true;
return false;
};
return (
<View className='trend-page'>
{/* 页面标题 */}
<View className='trend-hero'>
<View className='trend-hero-icon'>
<Text className='trend-hero-icon-text'>T</Text>
</View>
<Text className='trend-hero-title'>{meta.label}</Text>
</View>
{/* 时间范围切换 */}
<View className='trange-wrap'>
{RANGE_OPTIONS.map((opt) => (
<View
key={opt.value}
className={`trange-tab ${range === opt.value ? 'trange-tab-active' : ''}`}
onClick={() => setRange(opt.value)}
>
<Text className={`trange-tab-text ${range === opt.value ? 'trange-tab-text-active' : ''}`}>
{opt.label}
</Text>
</View>
))}
</View>
{/* ECharts 折线图 */}
<View className='trend-chart-card'>
<TrendChart
data={points}
referenceMin={meta.refMin}
referenceMax={meta.refMax}
unit={meta.unit}
/>
</View>
{/* 参考区间 */}
{meta.refMin !== undefined && meta.refMax !== undefined && (
<View className='trend-ref-card'>
<Text className='trend-ref-label'></Text>
<Text className='trend-ref-value'>
{meta.refMin} ~ {meta.refMax} {meta.unit}
</Text>
</View>
)}
{/* 数据列表 */}
{points.length > 0 && (
<View className='trend-list'>
<Text className='trend-list-title'></Text>
{points.slice().reverse().map((p, i) => {
const abnormal = isOutOfRange(p.value);
return (
<View className={`trend-item ${abnormal ? 'trend-item-warn' : ''}`} key={i}>
<View className='trend-item-left'>
<Text className='trend-item-date'>{p.date}</Text>
{abnormal && (
<Text className='trend-item-tag'></Text>
)}
</View>
<Text className={`trend-item-value ${abnormal ? 'trend-item-value-warn' : ''}`}>
{p.value}{meta.unit ? ` ${meta.unit}` : ''}
</Text>
</View>
);
})}
</View>
)}
</View>
);
}