feat(miniprogram): Phase 5 UI/UX 优化 — 8 项改进
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

- 首页: 健康资讯推荐 + 空状态引导 + 快捷服务字符图标优化
- 健康 Hub: sparkline bar + 参考范围 + 打卡合并到快捷操作
- 日常监测: 3 分组折叠(晨间/晚间/其他) + 异常值高亮 + 提交前确认
- 预约: 已满时段 pointer-events:none + opacity 优化
- 咨询聊天: 消息日期分组(今天/昨天) + 图片预览
- 积分商城: 确认已有余额大字+签到+库存提示
- 医护工作台: 异常体征横幅 + 患者搜索入口 + 快捷操作扩展
- 趋势图表: 骨架屏加载状态 + ECharts 异常标记已有
This commit is contained in:
iven
2026-04-28 08:51:27 +08:00
parent 852a429ef3
commit 0e45778fc3
13 changed files with 693 additions and 286 deletions

View File

@@ -102,8 +102,9 @@
border-color: $wrn;
}
&.slot-full {
opacity: 0.5;
opacity: 0.4;
background: $bd-l;
pointer-events: none;
}
&.slot-selected {
border-color: $pri;

View File

@@ -73,6 +73,26 @@
}
}
.msg-date-divider {
display: flex;
justify-content: center;
padding: 16px 0 12px;
&__text {
font-size: 22px;
color: #94A3B8;
background: #F1F5F9;
padding: 4px 16px;
border-radius: 8px;
}
}
.msg-image {
width: 320px;
border-radius: 12px;
margin-top: 4px;
}
.msg-time {
font-size: 20px;
color: $tx3;

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react';
import { View, Text, Input, ScrollView } from '@tarojs/components';
import { View, Text, Input, Image, ScrollView } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import {
getSession,
@@ -116,6 +116,24 @@ export default function ConsultationDetail() {
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
};
const getDateLabel = (dateStr: string): string => {
const d = new Date(dateStr);
const today = new Date();
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const dStr = d.toDateString();
if (dStr === today.toDateString()) return '今天';
if (dStr === yesterday.toDateString()) return '昨天';
return d.toLocaleDateString('zh-CN', { month: 'long', day: 'numeric' });
};
const isDifferentDay = (a: string, b: string): boolean => {
return new Date(a).toDateString() !== new Date(b).toDateString();
};
const isImageUrl = (url: string) => /\.(jpg|jpeg|png|gif|webp)(\?.*)?$/i.test(url);
if (loading) return <Loading />;
const isOpen = session?.status !== 'closed';
@@ -137,11 +155,28 @@ export default function ConsultationDetail() {
>
{messages.map((msg, idx) => {
const isSelf = msg.sender_role === 'patient';
const showDateDivider = idx === 0 || isDifferentDay(msg.created_at, messages[idx - 1].created_at);
return (
<View key={msg.id} id={`msg-${idx + 1}`} className={`msg-row ${isSelf ? 'msg-row--self' : ''}`}>
<View className={`msg-bubble ${isSelf ? 'msg-bubble--self' : 'msg-bubble--other'}`}>
<Text className='msg-text'>{msg.content}</Text>
<Text className='msg-time'>{formatTime(msg.created_at)}</Text>
<View key={msg.id}>
{showDateDivider && (
<View className='msg-date-divider'>
<Text className='msg-date-divider__text'>{getDateLabel(msg.created_at)}</Text>
</View>
)}
<View id={`msg-${idx + 1}`} className={`msg-row ${isSelf ? 'msg-row--self' : ''}`}>
<View className={`msg-bubble ${isSelf ? 'msg-bubble--self' : 'msg-bubble--other'}`}>
{isImageUrl(msg.content) ? (
<Image
className='msg-image'
src={msg.content}
mode='widthFix'
onClick={() => Taro.previewImage({ urls: [msg.content], current: msg.content })}
/>
) : (
<Text className='msg-text'>{msg.content}</Text>
)}
<Text className='msg-time'>{formatTime(msg.created_at)}</Text>
</View>
</View>
</View>
);

View File

@@ -29,6 +29,54 @@
color: $tx3;
}
&__alert {
display: flex;
align-items: center;
margin: 16px 24px;
padding: 16px 20px;
background: #FEF2F2;
border-radius: $r;
border-left: 4px solid #EF4444;
}
&__alert-icon {
width: 36px;
height: 36px;
border-radius: 50%;
background: #EF4444;
color: #fff;
text-align: center;
line-height: 36px;
font-weight: bold;
font-size: 22px;
margin-right: 12px;
flex-shrink: 0;
}
&__alert-text {
flex: 1;
font-size: 26px;
color: #991B1B;
}
&__alert-link {
font-size: 24px;
color: #EF4444;
flex-shrink: 0;
}
&__search {
margin: 0 24px 16px;
}
&__search-input {
background: #F1F5F9;
border-radius: $r;
padding: 16px 20px;
font-size: 26px;
color: #94A3B8;
}
&__section {
margin-bottom: 40px;
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
import { View, Text, Input, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useAuthStore } from '@/stores/auth';
import * as doctorApi from '@/services/doctor';
@@ -29,11 +29,14 @@ const HEALTH_CARDS: CardConfig[] = [
const QUICK_ACTIONS = [
{ label: '化验审核', initial: '审', route: '/pages/doctor/report/index' },
{ label: '患者查询', initial: '查', route: '/pages/doctor/patients/index' },
{ label: '随访记录', initial: '随', route: '/pages/doctor/followup/index' },
{ label: '排班查看', initial: '排', route: '/pages/doctor/patients/index' },
];
export default function DoctorHome() {
const { user, logout } = useAuthStore();
const [dashboard, setDashboard] = useState<doctorApi.DoctorDashboard | null>(null);
const [alertCount, setAlertCount] = useState(0);
const [loading, setLoading] = useState(true);
useEffect(() => {
@@ -44,6 +47,9 @@ export default function DoctorHome() {
try {
const data = await doctorApi.getDashboard();
setDashboard(data);
// 从仪表盘数据提取异常体征患者数
const count = (data as Record<string, unknown>)?.abnormal_vital_count;
setAlertCount(typeof count === 'number' ? count : 0);
} catch {
// 静默失败,显示占位
} finally {
@@ -78,6 +84,22 @@ export default function DoctorHome() {
</Text>
</View>
{alertCount > 0 && (
<View className='doctor-home__alert'>
<Text className='doctor-home__alert-icon'>!</Text>
<Text className='doctor-home__alert-text'>{alertCount} </Text>
<Text className='doctor-home__alert-link' onClick={() => Taro.navigateTo({ url: '/pages/doctor/patients/index' })}> </Text>
</View>
)}
<View className='doctor-home__search'>
<Input
className='doctor-home__search-input'
placeholder='搜索患者姓名...'
onFocus={() => Taro.navigateTo({ url: '/pages/doctor/patients/index' })}
/>
</View>
<View className='doctor-home__section'>
<Text className='doctor-home__section-title'></Text>
<View className='doctor-home__grid'>

View File

@@ -42,7 +42,7 @@
color: $tx3;
}
/* ── card ── */
/* ── card (standalone, used for date picker) ── */
.dm-card {
background: $card;
border-radius: $r;
@@ -58,22 +58,6 @@
margin-bottom: 20px;
}
.dm-card-serial {
@include flex-center;
width: 40px;
height: 40px;
border-radius: $r-sm;
background: $pri-l;
flex-shrink: 0;
}
.dm-card-serial-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 22px;
font-weight: bold;
color: $pri;
}
.dm-card-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
@@ -112,6 +96,61 @@
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;
@@ -192,6 +231,23 @@
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;

View File

@@ -19,6 +19,41 @@ function formatDate(date: Date): string {
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();
@@ -46,6 +81,17 @@ export default function DailyMonitoring() {
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: '日常监测上报' });
});
@@ -62,6 +108,28 @@ export default function DailyMonitoring() {
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' });
@@ -120,6 +188,18 @@ export default function DailyMonitoring() {
}
}
// ── 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({
@@ -164,6 +244,13 @@ export default function DailyMonitoring() {
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'>
{/* 页面标题 */}
@@ -175,12 +262,9 @@ export default function DailyMonitoring() {
<Text className='dm-hero-sub'></Text>
</View>
{/* 日期选择 */}
{/* 日期选择 (standalone card) */}
<View className='dm-card'>
<View className='dm-card-header'>
<View className='dm-card-serial'>
<Text className='dm-card-serial-text'>1</Text>
</View>
<Text className='dm-card-title'></Text>
{isToday && (
<Text className='dm-card-badge'></Text>
@@ -199,176 +283,185 @@ export default function DailyMonitoring() {
</Picker>
</View>
{/* 晨起血压 */}
<View className='dm-card'>
<View className='dm-card-header'>
<View className='dm-card-serial'>
<Text className='dm-card-serial-text'>2</Text>
</View>
<Text className='dm-card-title'></Text>
{/* ── 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-bp-group'>
<View className='dm-bp-field'>
<Text className='dm-field-label'></Text>
<Input
type='digit'
className='dm-input-box'
placeholder='如 120'
value={morningSystolic}
onInput={(e) => setMorningSystolic(e.detail.value)}
/>
<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>
<View className='dm-bp-divider'>
<View className='dm-bp-line' />
<Text className='dm-bp-slash'>/</Text>
<View className='dm-bp-line' />
<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>
<View className='dm-bp-field'>
<Text className='dm-field-label'></Text>
<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
type='digit'
className='dm-input-box'
placeholder='如 80'
value={morningDiastolic}
onInput={(e) => setMorningDiastolic(e.detail.value)}
className='dm-input-box dm-input-full'
placeholder='如:头晕、乏力等(可选)'
value={notes}
onInput={(e) => setNotes(e.detail.value)}
/>
</View>
</View>
<Text className='dm-field-unit'>mmHg</Text>
</View>
{/* 晚间血压 */}
<View className='dm-card'>
<View className='dm-card-header'>
<View className='dm-card-serial'>
<Text className='dm-card-serial-text'>3</Text>
</View>
<Text className='dm-card-title'></Text>
</View>
<View className='dm-bp-group'>
<View className='dm-bp-field'>
<Text className='dm-field-label'></Text>
<Input
type='digit'
className='dm-input-box'
placeholder='如 120'
value={eveningSystolic}
onInput={(e) => setEveningSystolic(e.detail.value)}
/>
</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'
placeholder='如 80'
value={eveningDiastolic}
onInput={(e) => setEveningDiastolic(e.detail.value)}
/>
</View>
</View>
<Text className='dm-field-unit'>mmHg</Text>
</View>
{/* 体重 */}
<View className='dm-card'>
<View className='dm-card-header'>
<View className='dm-card-serial'>
<Text className='dm-card-serial-text'>4</Text>
</View>
<Text className='dm-card-title'></Text>
</View>
<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-card'>
<View className='dm-card-header'>
<View className='dm-card-serial'>
<Text className='dm-card-serial-text'>5</Text>
</View>
<Text className='dm-card-title'></Text>
</View>
<View className='dm-single-row'>
<Input
type='digit'
className='dm-input-box dm-input-flex'
placeholder='如 5.6'
value={bloodSugar}
onInput={(e) => setBloodSugar(e.detail.value)}
/>
<Text className='dm-unit-inline'>mmol/L</Text>
</View>
</View>
{/* 饮水量 */}
<View className='dm-card'>
<View className='dm-card-header'>
<View className='dm-card-serial'>
<Text className='dm-card-serial-text'>6</Text>
</View>
<Text className='dm-card-title'></Text>
</View>
<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-card'>
<View className='dm-card-header'>
<View className='dm-card-serial'>
<Text className='dm-card-serial-text'>7</Text>
</View>
<Text className='dm-card-title'>尿</Text>
</View>
<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-card'>
<View className='dm-card-header'>
<View className='dm-card-serial'>
<Text className='dm-card-serial-text'>8</Text>
</View>
<Text className='dm-card-title'></Text>
</View>
<Input
className='dm-input-box dm-input-full'
placeholder='如:头晕、乏力等(可选)'
value={notes}
onInput={(e) => setNotes(e.detail.value)}
/>
</View>
{/* 提交 */}

View File

@@ -88,57 +88,6 @@
font-weight: 500;
}
/* ─── 打卡卡片 ─── */
.checkin-card {
display: flex;
align-items: center;
justify-content: space-between;
background: $card;
border-radius: $r;
padding: 24px 28px;
margin: 0 24px 24px;
box-shadow: $shadow-sm;
}
.checkin-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.checkin-done {
font-size: 28px;
color: $acc;
font-weight: 600;
}
.checkin-streak {
font-size: 22px;
color: $tx3;
}
.checkin-pending {
font-size: 28px;
color: $tx2;
font-weight: 500;
}
.checkin-go {
background: $pri;
border-radius: $r-sm;
padding: 12px 28px;
&:active {
opacity: 0.85;
}
}
.checkin-go-text {
font-size: 24px;
color: #fff;
font-weight: 600;
}
/* ─── 通用 section ─── */
.health-section {
margin: 0 24px 28px;
@@ -206,6 +155,23 @@
display: block;
}
.vital-bar-track {
height: 6px;
background: $bd-l;
border-radius: 3px;
margin-top: 12px;
overflow: hidden;
}
.vital-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
&.bar-green { background: $acc; }
&.bar-orange { background: $wrn; }
}
/* ─── 趋势入口 ─── */
.trend-row {
background: $card;

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { View, Text } from '@tarojs/components';
import { View, Text, ScrollView } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro';
import { useHealthStore } from '../../stores/health';
import { listDailyMonitoring, DailyMonitoring } from '../../services/health';
@@ -16,6 +16,27 @@ function getStatusTag(status?: string) {
return null;
}
/** 根据 status 计算 sparkline bar 的颜色 */
function getBarColor(status?: string): string {
if (status === 'normal') return 'bar-green';
if (status === 'high' || status === 'low') return 'bar-orange';
return 'bar-green';
}
/** 计算数值在参考范围中的位置百分比 (0-100) */
function getBarPercent(value: number | undefined, ref?: string): number {
if (!value || !ref) return 50;
const match = ref.match(/([\d.]+)\s*[-]\s*([\d.]+)/);
if (!match) return 50;
const low = parseFloat(match[1]);
const high = parseFloat(match[2]);
if (high <= low) return 50;
// 将值映射到 0-100 范围,参考范围占据中间 70%15%-85%
const range = high - low;
const normalized = (value - low + range * 0.3) / (range * 1.6);
return Math.max(5, Math.min(95, normalized * 100));
}
export default function Health() {
const { todaySummary, loading, refreshToday } = useHealthStore();
const { currentPatient } = useAuthStore();
@@ -63,10 +84,10 @@ export default function Health() {
const summary = todaySummary || {};
const items = [
{ label: '血压', value: summary.blood_pressure ? `${summary.blood_pressure.systolic}/${summary.blood_pressure.diastolic}` : '--/--', unit: 'mmHg', indicator: 'blood_pressure_systolic', status: summary.blood_pressure?.status, ref: summary.blood_pressure?.reference_range },
{ label: '心率', value: summary.heart_rate ? `${summary.heart_rate.value}` : '--', unit: 'bpm', indicator: 'heart_rate', status: summary.heart_rate?.status, ref: summary.heart_rate?.reference_range },
{ label: '血糖', value: summary.blood_sugar ? `${summary.blood_sugar.value}` : '--', unit: 'mmol/L', indicator: 'blood_sugar_fasting', status: summary.blood_sugar?.status, ref: summary.blood_sugar?.reference_range },
{ label: '体重', value: summary.weight ? `${summary.weight.value}` : '--', unit: 'kg', indicator: 'weight', status: summary.weight?.status, ref: summary.weight?.reference_range },
{ label: '血压', value: summary.blood_pressure ? `${summary.blood_pressure.systolic}/${summary.blood_pressure.diastolic}` : '--/--', unit: 'mmHg', indicator: 'blood_pressure_systolic', status: summary.blood_pressure?.status, ref: summary.blood_pressure?.reference_range, numValue: summary.blood_pressure?.systolic },
{ label: '心率', value: summary.heart_rate ? `${summary.heart_rate.value}` : '--', unit: 'bpm', indicator: 'heart_rate', status: summary.heart_rate?.status, ref: summary.heart_rate?.reference_range, numValue: summary.heart_rate?.value },
{ label: '血糖', value: summary.blood_sugar ? `${summary.blood_sugar.value}` : '--', unit: 'mmol/L', indicator: 'blood_sugar_fasting', status: summary.blood_sugar?.status, ref: summary.blood_sugar?.reference_range, numValue: summary.blood_sugar?.value },
{ label: '体重', value: summary.weight ? `${summary.weight.value}` : '--', unit: 'kg', indicator: 'weight', status: summary.weight?.status, ref: summary.weight?.reference_range, numValue: summary.weight?.value },
];
const quickActions = [
@@ -102,7 +123,7 @@ export default function Health() {
</View>
</View>
{/* 快捷操作 */}
{/* 快捷操作 + 打卡状态紧凑合并 */}
<View className='health-actions-row'>
{quickActions.map((a) => (
<View className='action-item' key={a.label} onClick={a.action}>
@@ -112,30 +133,22 @@ export default function Health() {
<Text className='action-label'>{a.label}</Text>
</View>
))}
</View>
{/* 打卡状态 */}
{checkinStatus && (
<View className='checkin-card'>
<View className='checkin-info'>
{checkinStatus.checked_in_today ? (
<>
<Text className='checkin-done'></Text>
{checkinStatus.consecutive_days > 0 && (
<Text className='checkin-streak'> {checkinStatus.consecutive_days} </Text>
)}
</>
) : (
<Text className='checkin-pending'></Text>
)}
</View>
{!checkinStatus.checked_in_today && (
<View className='checkin-go' onClick={goToMall}>
<Text className='checkin-go-text'></Text>
{checkinStatus && (
<View
className='action-item checkin-badge'
onClick={!checkinStatus.checked_in_today ? goToMall : undefined}
>
<View className={`action-icon ${checkinStatus.checked_in_today ? 'icon-accent' : 'icon-warn'}`}>
<Text className='action-char'></Text>
</View>
)}
</View>
)}
<Text className='action-label'>
{checkinStatus.checked_in_today
? (checkinStatus.consecutive_days > 0 ? `已打卡${checkinStatus.consecutive_days}` : '已打卡')
: '去打卡'}
</Text>
</View>
)}
</View>
{/* 今日体征概览 */}
<View className='health-section'>
@@ -146,6 +159,8 @@ export default function Health() {
<View className='vitals-grid'>
{items.map((item) => {
const tag = getStatusTag(item.status);
const barColor = getBarColor(item.status);
const barPercent = getBarPercent(item.numValue, item.ref);
return (
<View className='vital-card' key={item.label} onClick={() => goToTrend(item.indicator)}>
<Text className='vital-label'>{item.label}</Text>
@@ -154,6 +169,12 @@ export default function Health() {
<Text className='vital-unit'>{item.unit}</Text>
{tag && <Text className={`vital-tag ${tag.cls}`}>{tag.label}</Text>}
</View>
{/* Sparkline bar */}
{item.ref && item.numValue != null && (
<View className='vital-bar-track'>
<View className={`vital-bar-fill ${barColor}`} style={`width: ${barPercent}%`} />
</View>
)}
{item.ref && <Text className='vital-ref'> {item.ref}</Text>}
</View>
);
@@ -162,20 +183,20 @@ export default function Health() {
)}
</View>
{/* 趋势快捷入口 */}
{/* 趋势快捷入口 — 水平滚动卡片 */}
<View className='health-section'>
<Text className='section-title'></Text>
<View className='trend-row'>
<ScrollView className='trend-scroll' scrollX>
{trendLinks.map((t) => (
<View className='trend-item' key={t.label} onClick={() => goToTrend(t.indicator)}>
<View className='trend-icon'>
<Text className='trend-char'>{t.char}</Text>
<View className='trend-card' key={t.label} onClick={() => goToTrend(t.indicator)}>
<View className='trend-card-icon'>
<Text className='trend-card-char'>{t.char}</Text>
</View>
<Text className='trend-label'>{t.label}</Text>
<Text className='trend-arrow'></Text>
<Text className='trend-card-label'>{t.label}</Text>
<Text className='trend-card-arrow'> </Text>
</View>
))}
</View>
</ScrollView>
</View>
{/* 最近监测记录 */}

View File

@@ -261,3 +261,69 @@
color: $tx3;
flex-shrink: 0;
}
/* ─── 健康空状态 ─── */
.health-empty {
background: $bg;
border-radius: $r-sm;
padding: 40px 24px;
text-align: center;
}
.health-empty-text {
display: block;
font-size: 28px;
color: $tx2;
margin-bottom: 8px;
}
.health-empty-action {
display: flex;
justify-content: center;
padding: 24px 0 0;
}
.health-empty-btn {
background: $pri;
border-radius: $r;
padding: 16px 40px;
}
.health-empty-btn-text {
color: #fff;
font-size: 26px;
font-weight: 500;
}
/* ─── 健康资讯 ─── */
.articles-section {
margin: 0 24px 24px;
}
.article-card {
background: $card;
border-radius: $r;
padding: 24px;
margin-bottom: 16px;
box-shadow: $shadow-sm;
&:active {
opacity: 0.7;
}
}
.article-card-title {
font-size: 28px;
color: $tx;
display: block;
font-weight: 500;
margin-bottom: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.article-card-meta {
font-size: 22px;
color: $tx3;
}

View File

@@ -8,6 +8,7 @@ import Loading from '../../components/Loading';
import { trackPageView } from '@/services/analytics';
import * as appointmentApi from '@/services/appointment';
import * as followupApi from '@/services/followup';
import * as articleApi from '../../services/article';
import './index.scss';
interface UpcomingItem {
@@ -24,14 +25,25 @@ export default function Index() {
const { todaySummary, loading, refreshToday } = useHealthStore();
const [upcomingItems, setUpcomingItems] = useState<UpcomingItem[]>([]);
const [upcomingLoading, setUpcomingLoading] = useState(false);
const [articles, setArticles] = useState<articleApi.Article[]>([]);
useDidShow(() => {
restoreAuth();
refreshToday();
loadUpcoming();
loadArticles();
trackPageView('home');
});
const loadArticles = async () => {
try {
const res = await articleApi.listArticles({ page: 1, page_size: 2 });
setArticles(res.data || []);
} catch {
// 文章接口可能不可用
}
};
const loadUpcoming = async () => {
const patientId = useAuthStore.getState().currentPatient?.id;
if (!patientId) return;
@@ -81,11 +93,11 @@ export default function Index() {
const displayName = user?.display_name || currentPatient?.name || '访客';
const quickServices = [
{ label: '预约挂号', path: '/pages/appointment/create/index' },
{ label: '健康录入', path: '/pages/health/input/index' },
{ label: '健康趋势', path: '/pages/health/trend/index' },
{ label: '资讯文章', path: '/pages/article/index' },
{ label: 'AI 报告', path: '/pages/ai-report/list/index' },
{ label: '预约挂号', char: '约', path: '/pages/appointment/create/index' },
{ label: '健康录入', char: '录', path: '/pages/health/input/index' },
{ label: '健康趋势', char: '势', path: '/pages/health/trend/index' },
{ label: '资讯文章', char: '文', path: '/pages/article/index' },
{ label: 'AI 报告', char: 'AI', path: '/pages/ai-report/list/index' },
];
const handleServiceClick = (path: string) => {
@@ -121,6 +133,15 @@ export default function Index() {
<Text className='section-title'></Text>
{loading && !todaySummary ? (
<Loading />
) : !todaySummary || (!todaySummary.blood_pressure && !todaySummary.heart_rate && !todaySummary.blood_sugar && !todaySummary.weight) ? (
<View className='health-empty'>
<Text className='health-empty-text'></Text>
<View className='health-empty-action'>
<View className='health-empty-btn' onClick={() => Taro.navigateTo({ url: '/pages/health/input/index' })}>
<Text className='health-empty-btn-text'></Text>
</View>
</View>
</View>
) : (
<View className='health-grid'>
{healthItems.map((item) => {
@@ -147,7 +168,7 @@ export default function Index() {
{quickServices.map((svc) => (
<View className='service-btn' key={svc.label} onClick={() => handleServiceClick(svc.path)}>
<View className='service-icon-wrap'>
<Text className='service-icon-text'>{svc.label[0]}</Text>
<Text className='service-icon-text'>{svc.char}</Text>
</View>
<Text className='service-label'>{svc.label}</Text>
</View>
@@ -190,6 +211,25 @@ export default function Index() {
</View>
)}
</View>
{/* 健康资讯 */}
{articles.length > 0 && (
<View className='articles-section'>
<Text className='section-title'></Text>
{articles.map((article) => (
<View
className='article-card'
key={article.id}
onClick={() => Taro.navigateTo({ url: `/pages/article/detail/index?id=${article.id}` })}
>
<Text className='article-card-title'>{article.title}</Text>
<Text className='article-card-meta'>
{article.category_name || '健康'}{article.published_at ? ` · ${article.published_at.slice(0, 10)}` : ''}
</Text>
</View>
))}
</View>
)}
</View>
);
}