feat(mp+health): 小程序分包迁移 + 积分商城后台列表 API
- 小程序页面迁移到 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:
@@ -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;
|
||||
}
|
||||
487
apps/miniprogram/src/pages/pkg-health/daily-monitoring/index.tsx
Normal file
487
apps/miniprogram/src/pages/pkg-health/daily-monitoring/index.tsx
Normal 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'}`}>▸</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'}`}>▸</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'}`}>▸</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>
|
||||
);
|
||||
}
|
||||
170
apps/miniprogram/src/pages/pkg-health/trend/index.scss
Normal file
170
apps/miniprogram/src/pages/pkg-health/trend/index.scss
Normal 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;
|
||||
}
|
||||
111
apps/miniprogram/src/pages/pkg-health/trend/index.tsx
Normal file
111
apps/miniprogram/src/pages/pkg-health/trend/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user