feat(miniprogram): 老年友好版本全面重设计 — 5→4 Tab + 首页/健康/消息/我的重写
- TabBar 从 5 Tab 调整为 4 Tab(首页/健康/消息/我的) - 首页重写为 5 区域布局:问候+进度环+体征2x2+待办+快捷操作 - 健康页重写:体征录入大输入框+趋势柱状图+BLE设备卡片 - 新建消息页:咨询对话+系统通知双 Tab - 我的页调整:菜单高度64px+新增积分商城入口 - 设计系统更新:色彩对比度提升(WCAG AA)+触控参数+老年友好 mixin - 新增 ProgressRing 组件(CSS conic-gradient 实现) - 修复 diagnoses 页面 $suc-l 未定义变量
This commit is contained in:
BIN
apps/miniprogram/src/assets/tabbar/message-active.png
Normal file
BIN
apps/miniprogram/src/assets/tabbar/message-active.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 333 B |
BIN
apps/miniprogram/src/assets/tabbar/message.png
Normal file
BIN
apps/miniprogram/src/assets/tabbar/message.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 334 B |
29
apps/miniprogram/src/components/ProgressRing.scss
Normal file
29
apps/miniprogram/src/components/ProgressRing.scss
Normal file
@@ -0,0 +1,29 @@
|
||||
@import '../styles/variables.scss';
|
||||
|
||||
.progress-ring {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.progress-ring-inner {
|
||||
background: $card;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.progress-ring-percent {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.progress-ring-unit {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
40
apps/miniprogram/src/components/ProgressRing.tsx
Normal file
40
apps/miniprogram/src/components/ProgressRing.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import './ProgressRing.scss';
|
||||
|
||||
interface ProgressRingProps {
|
||||
percent: number;
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
color?: string;
|
||||
trackColor?: string;
|
||||
}
|
||||
|
||||
export default function ProgressRing({
|
||||
percent,
|
||||
size = 72,
|
||||
strokeWidth = 7,
|
||||
color = '#C4623A',
|
||||
trackColor = '#E8E2DC',
|
||||
}: ProgressRingProps) {
|
||||
const clamped = Math.max(0, Math.min(100, percent));
|
||||
const innerSize = size - strokeWidth * 2;
|
||||
|
||||
return (
|
||||
<View
|
||||
className='progress-ring'
|
||||
style={`width:${size}px;height:${size}px;background:conic-gradient(${color} ${clamped}%, ${trackColor} ${clamped}%);border-radius:50%;padding:${strokeWidth}px;`}
|
||||
>
|
||||
<View
|
||||
className='progress-ring-inner'
|
||||
style={`width:${innerSize}px;height:${innerSize}px;`}
|
||||
>
|
||||
<Text className='progress-ring-percent' style={`color:${color};`}>
|
||||
{clamped}
|
||||
</Text>
|
||||
<Text className='progress-ring-unit' style={`color:${color};`}>
|
||||
%
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -9,9 +9,6 @@
|
||||
|
||||
/* ─── 页头 ─── */
|
||||
.health-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24px 32px 8px;
|
||||
}
|
||||
|
||||
@@ -22,248 +19,272 @@
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.health-add-btn {
|
||||
background: $pri;
|
||||
padding: 10px 28px;
|
||||
/* ─── 类型 Tab ─── */
|
||||
.vital-tabs {
|
||||
display: flex;
|
||||
padding: 12px 24px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vital-tab {
|
||||
flex: 1;
|
||||
height: $tab-h;
|
||||
border-radius: $r;
|
||||
background: $surface-alt;
|
||||
@include flex-center;
|
||||
position: relative;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&.vital-tab-active {
|
||||
background: $pri;
|
||||
|
||||
.vital-tab-text {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.vital-tab-text {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.vital-tab-dot {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: $wrn;
|
||||
}
|
||||
|
||||
/* ─── 录入区 ─── */
|
||||
.input-section {
|
||||
margin: 0 24px 24px;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
font-size: 26px;
|
||||
color: $tx;
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
height: 56px;
|
||||
background: $bg;
|
||||
border: 2px solid $bd;
|
||||
border-radius: $r-sm;
|
||||
padding: 0 20px;
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 28px;
|
||||
color: $tx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.input-ref {
|
||||
font-size: 22px;
|
||||
color: $tx2;
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ─── 血糖时段选择 ─── */
|
||||
.period-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.period-btn {
|
||||
flex: 1;
|
||||
height: $btn-primary-h;
|
||||
border-radius: $r-sm;
|
||||
background: $surface-alt;
|
||||
@include flex-center;
|
||||
|
||||
&.period-active {
|
||||
background: $pri;
|
||||
|
||||
.period-btn-text {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.health-add-text {
|
||||
.period-btn-text {
|
||||
font-size: 26px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
/* ─── 快捷操作 ─── */
|
||||
.health-actions-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 16px 24px 24px;
|
||||
/* ─── 保存按钮 ─── */
|
||||
.save-btn {
|
||||
@include btn-primary;
|
||||
margin-top: 24px;
|
||||
border-radius: $r;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
.save-btn-text {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ─── 趋势图 ─── */
|
||||
.trend-section {
|
||||
margin: 0 24px 24px;
|
||||
}
|
||||
|
||||
.trend-empty {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 12px;
|
||||
padding: 36px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.trend-empty-text {
|
||||
font-size: 24px;
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.trend-chart {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.trend-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
height: 200px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.trend-bar-col {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-shadow: $shadow-sm;
|
||||
height: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
.trend-bar {
|
||||
width: 100%;
|
||||
max-width: 40px;
|
||||
border-radius: 6px 6px 0 0;
|
||||
min-height: 16px;
|
||||
|
||||
&.trend-bar-normal {
|
||||
background: $pri;
|
||||
}
|
||||
|
||||
&.trend-bar-warn {
|
||||
background: $wrn;
|
||||
}
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
@include flex-center;
|
||||
|
||||
&.icon-primary { background: $pri-l; }
|
||||
&.icon-accent { background: $acc-l; }
|
||||
&.icon-warn { background: $wrn-l; }
|
||||
.trend-bar-label {
|
||||
font-size: 22px;
|
||||
color: $tx2;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.action-char {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: $pri;
|
||||
|
||||
.icon-accent & { color: $acc; }
|
||||
.icon-warn & { color: $wrn; }
|
||||
/* ─── BLE 设备卡片 ─── */
|
||||
.device-section {
|
||||
margin: 0 24px 16px;
|
||||
}
|
||||
|
||||
.action-label {
|
||||
font-size: 24px;
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ─── 通用 section ─── */
|
||||
.health-section {
|
||||
margin: 0 24px 28px;
|
||||
}
|
||||
|
||||
/* ─── 体征概览 ─── */
|
||||
.vitals-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.vital-card {
|
||||
.device-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px 20px;
|
||||
padding: 24px;
|
||||
box-shadow: $shadow-sm;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.vital-label {
|
||||
.device-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: $r-sm;
|
||||
background: $pri-l;
|
||||
@include flex-center;
|
||||
margin-right: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-icon-text {
|
||||
font-size: 26px;
|
||||
font-weight: bold;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
.device-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.device-desc {
|
||||
font-size: 22px;
|
||||
color: $tx2;
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.vital-value {
|
||||
@include serif-number;
|
||||
font-size: 44px;
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.vital-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vital-unit {
|
||||
font-size: 20px;
|
||||
.device-arrow {
|
||||
font-size: 32px;
|
||||
color: $tx3;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vital-tag {
|
||||
@include tag($acc-l, $acc);
|
||||
|
||||
&.tag-warn {
|
||||
@include tag($wrn-l, $wrn);
|
||||
}
|
||||
}
|
||||
|
||||
.vital-ref {
|
||||
font-size: 20px;
|
||||
color: $tx3;
|
||||
margin-top: 8px;
|
||||
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; }
|
||||
&.bar-red { background: $dan; }
|
||||
}
|
||||
|
||||
/* ─── 趋势入口 — 水平滚动卡片 ─── */
|
||||
.trend-scroll {
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.trend-card {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 200px;
|
||||
/* ─── 健康资讯入口 ─── */
|
||||
.article-entry {
|
||||
margin: 0 24px 24px;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px 16px;
|
||||
margin-right: 16px;
|
||||
padding: 24px;
|
||||
box-shadow: $shadow-sm;
|
||||
vertical-align: top;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.trend-card-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: $r;
|
||||
background: $pri-l;
|
||||
@include flex-center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.trend-card-char {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
.trend-card-label {
|
||||
.article-entry-text {
|
||||
font-size: 26px;
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trend-card-arrow {
|
||||
font-size: 22px;
|
||||
color: $tx3;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ─── 最近监测 ─── */
|
||||
.record-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.record-date {
|
||||
font-size: 24px;
|
||||
color: $pri;
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.record-data {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.record-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.record-item-label {
|
||||
font-size: 22px;
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.record-item-value {
|
||||
@include serif-number;
|
||||
font-size: 26px;
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -1,235 +1,330 @@
|
||||
import { useState } from 'react';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import { View, Text, Input } from '@tarojs/components';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import { useHealthStore } from '../../stores/health';
|
||||
import { listDailyMonitoring, DailyMonitoring } from '../../services/health';
|
||||
import { usePointsStore } from '../../stores/points';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { trackEvent } from '../../services/analytics';
|
||||
import { inputVitalSign, getTrend } from '../../services/health';
|
||||
import Loading from '../../components/Loading';
|
||||
import './index.scss';
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
{ label: '日常上报', char: '日', bg: 'icon-primary' },
|
||||
{ label: '体征录入', char: '录', bg: 'icon-accent' },
|
||||
{ label: '查看趋势', char: '势', bg: 'icon-warn' },
|
||||
type VitalType = 'blood_pressure' | 'heart_rate' | 'blood_sugar' | 'weight';
|
||||
|
||||
const VITAL_TABS: { key: VitalType; label: string }[] = [
|
||||
{ key: 'blood_pressure', label: '血压' },
|
||||
{ key: 'heart_rate', label: '心率' },
|
||||
{ key: 'blood_sugar', label: '血糖' },
|
||||
{ key: 'weight', label: '体重' },
|
||||
];
|
||||
|
||||
const TREND_LINKS = [
|
||||
{ label: '血压趋势', indicator: 'blood_pressure_systolic', char: '压' },
|
||||
{ label: '心率趋势', indicator: 'heart_rate', char: '率' },
|
||||
{ label: '血糖趋势', indicator: 'blood_sugar_fasting', char: '糖' },
|
||||
];
|
||||
const REF_RANGES: Record<VitalType, { range: string; warn: string }> = {
|
||||
blood_pressure: { range: '收缩压 90-140 / 舒张压 60-90 mmHg', warn: '血压偏高,确认提交?' },
|
||||
heart_rate: { range: '60-100 bpm', warn: '心率异常,确认提交?' },
|
||||
blood_sugar: { range: '空腹 3.9-6.1 / 餐后 <7.8 mmol/L', warn: '血糖偏高,确认提交?' },
|
||||
weight: { range: '根据 BMI 18.5-24 计算', warn: '' },
|
||||
};
|
||||
|
||||
function getStatusTag(status?: string) {
|
||||
if (status === 'high') return { label: '偏高', cls: 'tag-warn' };
|
||||
if (status === 'low') return { label: '偏低', cls: 'tag-warn' };
|
||||
if (status === 'normal') return { label: '正常', cls: 'tag-ok' };
|
||||
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));
|
||||
interface TrendPoint {
|
||||
date: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export default function Health() {
|
||||
const { todaySummary, loading, refreshToday } = useHealthStore();
|
||||
const { checkinStatus, refresh: refreshPoints } = usePointsStore();
|
||||
const { todaySummary, loading, refreshToday, getTrend: fetchTrend } = useHealthStore();
|
||||
const { currentPatient } = useAuthStore();
|
||||
const [recentRecords, setRecentRecords] = useState<DailyMonitoring[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<VitalType>('blood_pressure');
|
||||
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);
|
||||
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
||||
const [trendLoading, setTrendLoading] = useState(false);
|
||||
|
||||
useDidShow(() => {
|
||||
refreshToday();
|
||||
refreshPoints();
|
||||
loadRecentRecords();
|
||||
loadTrend(activeTab);
|
||||
});
|
||||
|
||||
const loadRecentRecords = async () => {
|
||||
if (currentPatient) {
|
||||
try {
|
||||
const resp = await listDailyMonitoring(currentPatient.id, { page: 1, page_size: 3 });
|
||||
setRecentRecords(resp.data || []);
|
||||
} catch {
|
||||
// daily monitoring API 可能不可用
|
||||
const loadTrend = async (type: VitalType) => {
|
||||
setTrendLoading(true);
|
||||
try {
|
||||
const indicatorMap: Record<VitalType, string> = {
|
||||
blood_pressure: 'blood_pressure_systolic',
|
||||
heart_rate: 'heart_rate',
|
||||
blood_sugar: 'blood_sugar_fasting',
|
||||
weight: 'weight',
|
||||
};
|
||||
const points = await fetchTrend(indicatorMap[type], '7d');
|
||||
setTrendData(points);
|
||||
} catch {
|
||||
setTrendData([]);
|
||||
} finally {
|
||||
setTrendLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = (tab: VitalType) => {
|
||||
setActiveTab(tab);
|
||||
loadTrend(tab);
|
||||
};
|
||||
|
||||
const getWarnStatus = (type: VitalType): string | null => {
|
||||
if (type === 'blood_pressure') {
|
||||
const sys = parseFloat(systolic);
|
||||
const dia = parseFloat(diastolic);
|
||||
if (sys > 140 || dia > 90) return REF_RANGES.blood_pressure.warn;
|
||||
} else if (type === 'heart_rate') {
|
||||
const val = parseFloat(heartRateVal);
|
||||
if (val > 100 || val < 60) return REF_RANGES.heart_rate.warn;
|
||||
} else if (type === 'blood_sugar') {
|
||||
const val = parseFloat(sugarVal);
|
||||
if (sugarPeriod === 'fasting' && val > 6.1) return REF_RANGES.blood_sugar.warn;
|
||||
if (sugarPeriod === 'postprandial' && val > 7.8) return REF_RANGES.blood_sugar.warn;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
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; }
|
||||
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; }
|
||||
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; }
|
||||
await inputVitalSign(patientId, { indicator_type: 'blood_sugar', value: val });
|
||||
setSugarVal('');
|
||||
break;
|
||||
}
|
||||
case 'weight': {
|
||||
const val = parseFloat(weightVal);
|
||||
if (!val) { Taro.showToast({ title: '请填写体重', icon: 'none' }); return; }
|
||||
await inputVitalSign(patientId, { indicator_type: 'weight', value: val });
|
||||
setWeightVal('');
|
||||
break;
|
||||
}
|
||||
}
|
||||
Taro.showToast({ title: '保存成功', icon: 'success' });
|
||||
refreshToday(true);
|
||||
loadTrend(activeTab);
|
||||
} catch {
|
||||
Taro.showToast({ title: '保存失败', icon: 'none' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const goToInput = () => {
|
||||
Taro.navigateTo({ url: '/pages/pkg-health/input/index' });
|
||||
};
|
||||
|
||||
const goToDailyMonitoring = () => {
|
||||
Taro.navigateTo({ url: '/pages/pkg-health/daily-monitoring/index' });
|
||||
};
|
||||
|
||||
const goToTrend = (indicator: string) => {
|
||||
Taro.navigateTo({ url: `/pages/pkg-health/trend/index?indicator=${indicator}` });
|
||||
};
|
||||
|
||||
const goToMall = () => {
|
||||
Taro.switchTab({ url: '/pages/mall/index' });
|
||||
};
|
||||
|
||||
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, 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 = [
|
||||
{ ...QUICK_ACTIONS[0], action: goToDailyMonitoring },
|
||||
{ ...QUICK_ACTIONS[1], action: goToInput },
|
||||
{ ...QUICK_ACTIONS[2], action: () => goToTrend('blood_pressure_systolic') },
|
||||
];
|
||||
|
||||
const trendLinks = TREND_LINKS;
|
||||
|
||||
const formatBp = (record: DailyMonitoring) => {
|
||||
const parts: string[] = [];
|
||||
if (record.morning_bp_systolic && record.morning_bp_diastolic) {
|
||||
parts.push(`晨 ${record.morning_bp_systolic}/${record.morning_bp_diastolic}`);
|
||||
}
|
||||
if (record.evening_bp_systolic && record.evening_bp_diastolic) {
|
||||
parts.push(`晚 ${record.evening_bp_systolic}/${record.evening_bp_diastolic}`);
|
||||
}
|
||||
return parts.length > 0 ? parts.join(' ') : '--';
|
||||
};
|
||||
const maxTrendValue = Math.max(...trendData.map((d) => d.value), 1);
|
||||
const dayLabels = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
|
||||
return (
|
||||
<View className='health-page'>
|
||||
{/* 页头 */}
|
||||
<View className='health-header'>
|
||||
<Text className='health-title'>健康数据</Text>
|
||||
<View className='health-add-btn' onClick={goToInput}>
|
||||
<Text className='health-add-text'>录入</Text>
|
||||
<Text className='health-title'>健康</Text>
|
||||
</View>
|
||||
|
||||
{/* 类型 Tab */}
|
||||
<View className='vital-tabs'>
|
||||
{VITAL_TABS.map((tab) => {
|
||||
const hasData = tab.key === 'blood_pressure' ? !!todaySummary?.blood_pressure
|
||||
: tab.key === 'heart_rate' ? !!todaySummary?.heart_rate
|
||||
: tab.key === 'blood_sugar' ? !!todaySummary?.blood_sugar
|
||||
: !!todaySummary?.weight;
|
||||
return (
|
||||
<View
|
||||
key={tab.key}
|
||||
className={`vital-tab ${activeTab === tab.key ? 'vital-tab-active' : ''}`}
|
||||
onClick={() => handleTabChange(tab.key)}
|
||||
>
|
||||
<Text className='vital-tab-text'>{tab.label}</Text>
|
||||
{!hasData && <View className='vital-tab-dot' />}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* 录入区 */}
|
||||
<View className='input-section'>
|
||||
{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' style='margin-top:20px;'>舒张压(低压)</Text>
|
||||
<Input
|
||||
className='input-field'
|
||||
type='number'
|
||||
placeholder='如 85'
|
||||
value={diastolic}
|
||||
onInput={(e) => setDiastolic(e.detail.value)}
|
||||
/>
|
||||
<Text className='input-ref'>{REF_RANGES.blood_pressure.range}</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'>{REF_RANGES.heart_rate.range}</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'>{REF_RANGES.blood_sugar.range}</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'>{REF_RANGES.weight.range}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className='save-btn' onClick={handleSave}>
|
||||
<Text className='save-btn-text'>{saving ? '保存中...' : '保存'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 快捷操作 + 打卡状态紧凑合并 */}
|
||||
<View className='health-actions-row'>
|
||||
{quickActions.map((a) => (
|
||||
<View className='action-item' key={a.label} onClick={a.action}>
|
||||
<View className={`action-icon ${a.bg}`}>
|
||||
<Text className='action-char'>{a.char}</Text>
|
||||
</View>
|
||||
<Text className='action-label'>{a.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
{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>
|
||||
<Text className='action-label'>
|
||||
{checkinStatus.checked_in_today
|
||||
? (checkinStatus.consecutive_days > 0 ? `已打卡${checkinStatus.consecutive_days}天` : '已打卡')
|
||||
: '去打卡'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 今日体征概览 */}
|
||||
<View className='health-section'>
|
||||
<Text className='section-title'>今日体征</Text>
|
||||
{loading && !todaySummary ? (
|
||||
{/* 趋势图 */}
|
||||
<View className='trend-section'>
|
||||
<Text className='section-title'>近 7 天趋势</Text>
|
||||
{trendLoading ? (
|
||||
<Loading />
|
||||
) : trendData.length === 0 ? (
|
||||
<View className='trend-empty'>
|
||||
<Text className='trend-empty-text'>暂无趋势数据</Text>
|
||||
</View>
|
||||
) : (
|
||||
<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>
|
||||
<Text className='vital-value'>{item.value}</Text>
|
||||
<View className='vital-bottom'>
|
||||
<Text className='vital-unit'>{item.unit}</Text>
|
||||
{tag && <Text className={`vital-tag ${tag.cls}`}>{tag.label}</Text>}
|
||||
<View className='trend-chart'>
|
||||
<View className='trend-bars'>
|
||||
{trendData.map((point, i) => {
|
||||
const heightPct = Math.max(8, (point.value / maxTrendValue) * 100);
|
||||
const isAbnormal = activeTab === 'blood_pressure' ? point.value > 140
|
||||
: activeTab === 'heart_rate' ? (point.value > 100 || point.value < 60)
|
||||
: activeTab === 'blood_sugar' ? point.value > 6.1
|
||||
: false;
|
||||
const dayOfWeek = new Date(point.date).getDay();
|
||||
return (
|
||||
<View className='trend-bar-col' key={i}>
|
||||
<View
|
||||
className={`trend-bar ${isAbnormal ? 'trend-bar-warn' : 'trend-bar-normal'}`}
|
||||
style={`height:${heightPct}%;`}
|
||||
/>
|
||||
<Text className='trend-bar-label'>{dayLabels[dayOfWeek]}</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>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 趋势快捷入口 — 水平滚动卡片 */}
|
||||
<View className='health-section'>
|
||||
<Text className='section-title'>健康趋势</Text>
|
||||
<ScrollView className='trend-scroll' scrollX>
|
||||
{trendLinks.map((t) => (
|
||||
<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-card-label'>{t.label}</Text>
|
||||
<Text className='trend-card-arrow'>查看 ›</Text>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
{/* BLE 设备卡片 */}
|
||||
<View className='device-section'>
|
||||
<View
|
||||
className='device-card'
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/device-sync/index' })}
|
||||
>
|
||||
<View className='device-icon'>
|
||||
<Text className='device-icon-text'>设</Text>
|
||||
</View>
|
||||
<View className='device-info'>
|
||||
<Text className='device-name'>蓝牙设备</Text>
|
||||
<Text className='device-desc'>连接设备自动同步数据</Text>
|
||||
</View>
|
||||
<Text className='device-arrow'>›</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 最近监测记录 */}
|
||||
{recentRecords.length > 0 && (
|
||||
<View className='health-section'>
|
||||
<Text className='section-title'>最近监测</Text>
|
||||
{recentRecords.map((record) => (
|
||||
<View className='record-card' key={record.id}>
|
||||
<Text className='record-date'>{record.record_date}</Text>
|
||||
<View className='record-data'>
|
||||
<View className='record-item'>
|
||||
<Text className='record-item-label'>血压</Text>
|
||||
<Text className='record-item-value'>{formatBp(record)}</Text>
|
||||
</View>
|
||||
{record.weight != null && (
|
||||
<View className='record-item'>
|
||||
<Text className='record-item-label'>体重</Text>
|
||||
<Text className='record-item-value'>{record.weight} kg</Text>
|
||||
</View>
|
||||
)}
|
||||
{record.blood_sugar != null && (
|
||||
<View className='record-item'>
|
||||
<Text className='record-item-label'>血糖</Text>
|
||||
<Text className='record-item-value'>{record.blood_sugar} mmol/L</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
{/* 健康资讯入口 */}
|
||||
<View
|
||||
className='article-entry'
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/article/index' })}
|
||||
>
|
||||
<Text className='article-entry-text'>最新健康资讯 ›</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,101 +7,160 @@
|
||||
padding-bottom: calc(120px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* ─── 问候区 ─── */
|
||||
/* ─── 区域 1:问候 + 日期 + 消息 ─── */
|
||||
.greeting-section {
|
||||
background: linear-gradient(135deg, $pri 0%, $pri-d 100%);
|
||||
padding: 48px 32px 72px;
|
||||
padding: 48px 32px 60px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.greeting-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.greeting-time {
|
||||
font-size: 26px;
|
||||
opacity: 0.85;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.greeting-name {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 44px;
|
||||
.greeting-text {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.greeting-date {
|
||||
font-size: 24px;
|
||||
opacity: 0.7;
|
||||
margin-top: 8px;
|
||||
font-size: 22px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* ─── 今日健康 ─── */
|
||||
.health-section {
|
||||
.greeting-msg {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
@include flex-center;
|
||||
|
||||
&:active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.greeting-msg-icon {
|
||||
font-size: 22px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ─── 区域 2:今日体征完成度 ─── */
|
||||
.checkin-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
box-shadow: $shadow-md;
|
||||
margin: -36px 24px 24px;
|
||||
padding: 28px;
|
||||
margin: -28px 24px 24px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
|
||||
&:active {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@include section-title;
|
||||
.checkin-left {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.health-grid {
|
||||
.checkin-right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.checkin-title {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.checkin-capsules {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.capsule {
|
||||
font-size: 22px;
|
||||
padding: 4px 12px;
|
||||
border-radius: $r-pill;
|
||||
font-weight: 500;
|
||||
|
||||
&.capsule-done {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
&.capsule-pending {
|
||||
background: $surface-alt;
|
||||
color: $tx2;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 区域 3:今日体征 2x2 ─── */
|
||||
.vitals-section {
|
||||
margin: 0 24px 24px;
|
||||
}
|
||||
|
||||
.vitals-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.health-cell {
|
||||
background: $bg;
|
||||
border-radius: $r-sm;
|
||||
padding: 20px 16px;
|
||||
.vital-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px;
|
||||
box-shadow: $shadow-sm;
|
||||
text-align: center;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.health-cell-label {
|
||||
font-size: 22px;
|
||||
.vital-label {
|
||||
font-size: 24px;
|
||||
color: $tx2;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.health-cell-value {
|
||||
.vital-value {
|
||||
@include serif-number;
|
||||
font-size: 44px;
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
display: block;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.health-cell-bottom {
|
||||
.vital-bottom {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.health-cell-unit {
|
||||
font-size: 20px;
|
||||
color: $tx3;
|
||||
.vital-unit {
|
||||
font-size: 22px;
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.health-cell-tag {
|
||||
font-size: 18px;
|
||||
.vital-tag {
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
padding: 2px 10px;
|
||||
border-radius: $r-sm;
|
||||
@@ -116,89 +175,42 @@
|
||||
background: $wrn-l;
|
||||
color: $wrn;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 快捷服务 ─── */
|
||||
.services-section {
|
||||
margin: 0 24px 24px;
|
||||
}
|
||||
|
||||
.services-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.service-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
&.tag-empty {
|
||||
background: $surface-alt;
|
||||
color: $tx2;
|
||||
}
|
||||
}
|
||||
|
||||
.service-icon-wrap {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: $r;
|
||||
background: $pri-l;
|
||||
@include flex-center;
|
||||
/* ─── 区域 4:今日待办 ─── */
|
||||
.todo-section {
|
||||
margin: 0 24px 24px;
|
||||
}
|
||||
|
||||
.service-icon-text {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
.service-label {
|
||||
font-size: 22px;
|
||||
color: $tx2;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ─── 待办事项 ─── */
|
||||
.upcoming-section {
|
||||
margin: 0 24px;
|
||||
}
|
||||
|
||||
.upcoming-empty {
|
||||
.todo-empty {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 48px 24px;
|
||||
padding: 36px 24px;
|
||||
text-align: center;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.upcoming-empty-text {
|
||||
display: block;
|
||||
font-size: 28px;
|
||||
color: $tx2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.upcoming-empty-hint {
|
||||
display: block;
|
||||
.todo-empty-text {
|
||||
font-size: 24px;
|
||||
color: $tx3;
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.upcoming-list {
|
||||
.todo-list {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
overflow: hidden;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.upcoming-item {
|
||||
.todo-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24px 24px;
|
||||
padding: 24px;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child {
|
||||
@@ -210,20 +222,36 @@
|
||||
}
|
||||
}
|
||||
|
||||
.upcoming-item-main {
|
||||
.todo-icon-wrap {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: $r-sm;
|
||||
background: $pri-l;
|
||||
@include flex-center;
|
||||
margin-right: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.todo-icon-char {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
.todo-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.upcoming-item-title {
|
||||
.todo-title {
|
||||
font-size: 28px;
|
||||
color: $tx;
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.upcoming-item-sub {
|
||||
.todo-sub {
|
||||
font-size: 22px;
|
||||
color: $tx2;
|
||||
display: block;
|
||||
@@ -232,158 +260,44 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.upcoming-item-tag {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
padding: 4px 14px;
|
||||
border-radius: $r-sm;
|
||||
flex-shrink: 0;
|
||||
margin-right: 12px;
|
||||
|
||||
&.tag-ok {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
&.tag-warn {
|
||||
background: $wrn-l;
|
||||
color: $wrn;
|
||||
}
|
||||
|
||||
&.tag-default {
|
||||
background: $bd-l;
|
||||
color: $tx2;
|
||||
}
|
||||
}
|
||||
|
||||
.upcoming-item-arrow {
|
||||
.todo-arrow {
|
||||
font-size: 32px;
|
||||
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 {
|
||||
/* ─── 区域 5:快捷操作 ─── */
|
||||
.action-section {
|
||||
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 {
|
||||
gap: 16px;
|
||||
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;
|
||||
}
|
||||
|
||||
/* ─── 设备快捷入口 ─── */
|
||||
.device-section {
|
||||
margin: 0 24px 24px;
|
||||
}
|
||||
|
||||
.device-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.device-entry-icon-wrap {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: $r;
|
||||
background: $pri-l;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-entry-icon-text {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.device-entry-info {
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-entry-name {
|
||||
@include touch-target;
|
||||
height: $btn-primary-h;
|
||||
border-radius: $r;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.device-entry-desc {
|
||||
font-size: 22px;
|
||||
color: $tx3;
|
||||
display: block;
|
||||
.action-primary {
|
||||
background: $pri;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.device-entry-arrow {
|
||||
font-size: 32px;
|
||||
color: $tx3;
|
||||
flex-shrink: 0;
|
||||
.action-outline {
|
||||
background: transparent;
|
||||
color: $pri;
|
||||
border: 2px solid $pri;
|
||||
}
|
||||
|
||||
.action-btn-text {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -3,29 +3,19 @@ import { useState } from 'react';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { useHealthStore } from '../../stores/health';
|
||||
import ProgressRing from '../../components/ProgressRing';
|
||||
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';
|
||||
|
||||
const QUICK_SERVICES = [
|
||||
{ label: '预约挂号', char: '约', path: '/pages/appointment/create/index' },
|
||||
{ label: '健康录入', char: '录', path: '/pages/pkg-health/input/index' },
|
||||
{ label: '健康趋势', char: '势', path: '/pages/pkg-health/trend/index' },
|
||||
{ label: '健康告警', char: '警', path: '/pages/pkg-health/alerts/index' },
|
||||
{ label: '资讯文章', char: '文', path: '/pages/article/index' },
|
||||
{ label: 'AI 报告', char: 'AI', path: '/pages/ai-report/list/index' },
|
||||
];
|
||||
|
||||
interface UpcomingItem {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
type: 'appointment' | 'followup';
|
||||
statusLabel: string;
|
||||
statusType: 'ok' | 'warn' | 'default';
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export default function Index() {
|
||||
@@ -33,24 +23,13 @@ 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(() => {
|
||||
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;
|
||||
@@ -62,32 +41,30 @@ export default function Index() {
|
||||
followupApi.listTasks(patientId, 'pending'),
|
||||
]);
|
||||
if (apptRes.status === 'fulfilled') {
|
||||
for (const a of apptRes.value.data.slice(0, 3)) {
|
||||
for (const a of apptRes.value.data.slice(0, 2)) {
|
||||
if (a.status === 'pending' || a.status === 'confirmed') {
|
||||
items.push({
|
||||
id: a.id,
|
||||
title: `${a.appointment_date} ${a.start_time}`,
|
||||
subtitle: `${a.doctor_name || '医护'} · ${a.department || ''}`,
|
||||
subtitle: `${a.doctor_name || '医护'} · ${a.department || '门诊'}`,
|
||||
type: 'appointment',
|
||||
statusLabel: a.status === 'pending' ? '待确认' : '已确认',
|
||||
statusType: a.status === 'pending' ? 'warn' : 'ok',
|
||||
icon: '约',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (taskRes.status === 'fulfilled') {
|
||||
for (const t of taskRes.value.data.slice(0, 2)) {
|
||||
for (const t of taskRes.value.data.slice(0, 1)) {
|
||||
items.push({
|
||||
id: t.id,
|
||||
title: t.follow_up_type,
|
||||
subtitle: `${t.content_template?.slice(0, 30) || ''} · 截止 ${t.planned_date}`,
|
||||
subtitle: `${t.content_template?.slice(0, 20) || '随访任务'} · 截止 ${t.planned_date}`,
|
||||
type: 'followup',
|
||||
statusLabel: '进行中',
|
||||
statusType: 'default',
|
||||
icon: '随',
|
||||
});
|
||||
}
|
||||
}
|
||||
setUpcomingItems(items);
|
||||
setUpcomingItems(items.slice(0, 3));
|
||||
} catch {
|
||||
setUpcomingItems([]);
|
||||
} finally {
|
||||
@@ -99,11 +76,29 @@ export default function Index() {
|
||||
const greeting = hour < 12 ? '上午好' : hour < 18 ? '下午好' : '晚上好';
|
||||
const displayName = user?.display_name || currentPatient?.name || '访客';
|
||||
|
||||
// 计算今日体征完成度(4 个指标:血压/心率/血糖/体重)
|
||||
const summary = todaySummary || {};
|
||||
const indicators = [
|
||||
!!summary.blood_pressure,
|
||||
!!summary.heart_rate,
|
||||
!!summary.blood_sugar,
|
||||
!!summary.weight,
|
||||
];
|
||||
const completedCount = indicators.filter(Boolean).length;
|
||||
const progressPercent = Math.round((completedCount / 4) * 100);
|
||||
|
||||
const indicatorCapsules = [
|
||||
{ label: '血压', done: !!summary.blood_pressure },
|
||||
{ label: '心率', done: !!summary.heart_rate },
|
||||
{ label: '血糖', done: !!summary.blood_sugar },
|
||||
{ label: '体重', done: !!summary.weight },
|
||||
];
|
||||
|
||||
const healthItems = [
|
||||
{ label: '血压', value: todaySummary?.blood_pressure ? `${todaySummary.blood_pressure.systolic}/${todaySummary.blood_pressure.diastolic}` : '--/--', unit: 'mmHg', status: todaySummary?.blood_pressure?.status },
|
||||
{ label: '心率', value: todaySummary?.heart_rate ? `${todaySummary.heart_rate.value}` : '--', unit: 'bpm', status: todaySummary?.heart_rate?.status },
|
||||
{ label: '血糖', value: todaySummary?.blood_sugar ? `${todaySummary.blood_sugar.value}` : '--', unit: 'mmol/L', status: todaySummary?.blood_sugar?.status },
|
||||
{ label: '体重', value: todaySummary?.weight ? `${todaySummary.weight.value}` : '--', unit: 'kg', status: todaySummary?.weight?.status },
|
||||
{ label: '血压', value: summary.blood_pressure ? `${summary.blood_pressure.systolic}/${summary.blood_pressure.diastolic}` : '—', unit: 'mmHg', status: summary.blood_pressure?.status, indicator: 'blood_pressure_systolic' },
|
||||
{ label: '心率', value: summary.heart_rate ? `${summary.heart_rate.value}` : '—', unit: 'bpm', status: summary.heart_rate?.status, indicator: 'heart_rate' },
|
||||
{ label: '血糖', value: summary.blood_sugar ? `${summary.blood_sugar.value}` : '—', unit: 'mmol/L', status: summary.blood_sugar?.status, indicator: 'blood_sugar_fasting' },
|
||||
{ label: '体重', value: summary.weight ? `${summary.weight.value}` : '—', unit: 'kg', status: summary.weight?.status, indicator: 'weight' },
|
||||
];
|
||||
|
||||
const getStatusTag = (status?: string) => {
|
||||
@@ -114,64 +109,67 @@ export default function Index() {
|
||||
|
||||
return (
|
||||
<View className='home-page'>
|
||||
{/* 问候区 */}
|
||||
{/* 区域 1:问候 + 日期 + 消息入口 */}
|
||||
<View className='greeting-section'>
|
||||
<View className='greeting-left'>
|
||||
<Text className='greeting-time'>{greeting}</Text>
|
||||
<Text className='greeting-name'>{displayName}</Text>
|
||||
<Text className='greeting-text'>{greeting},{displayName}</Text>
|
||||
<Text className='greeting-date'>
|
||||
{new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'short' })}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className='greeting-date'>{new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'short' })}</Text>
|
||||
</View>
|
||||
|
||||
{/* 设备快捷入口 — 点击直接跳转设备同步页面 */}
|
||||
<View className='device-section'>
|
||||
<View className='device-entry' onClick={() => Taro.navigateTo({ url: '/pages/device-sync/index' })}>
|
||||
<View className='device-entry-icon-wrap'>
|
||||
<Text className='device-entry-icon-text'>{'\u{1FA7A}'}</Text>
|
||||
</View>
|
||||
<View className='device-entry-info'>
|
||||
<Text className='device-entry-name'>血压计</Text>
|
||||
<Text className='device-entry-desc'>蓝牙同步 · 自动采集</Text>
|
||||
</View>
|
||||
<Text className='device-entry-arrow'>{'›'}</Text>
|
||||
</View>
|
||||
<View className='device-entry' onClick={() => Taro.navigateTo({ url: '/pages/device-sync/index' })}>
|
||||
<View className='device-entry-icon-wrap'>
|
||||
<Text className='device-entry-icon-text'>{'\u{1FA78}'}</Text>
|
||||
</View>
|
||||
<View className='device-entry-info'>
|
||||
<Text className='device-entry-name'>血糖仪</Text>
|
||||
<Text className='device-entry-desc'>蓝牙同步 · 自动采集</Text>
|
||||
</View>
|
||||
<Text className='device-entry-arrow'>{'›'}</Text>
|
||||
<View
|
||||
className='greeting-msg'
|
||||
onClick={() => Taro.switchTab({ url: '/pages/messages/index' })}
|
||||
>
|
||||
<Text className='greeting-msg-icon'>消</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 今日健康 */}
|
||||
<View className='health-section'>
|
||||
<Text className='section-title'>今日健康</Text>
|
||||
{/* 区域 2:今日体征完成度 */}
|
||||
<View
|
||||
className='checkin-card'
|
||||
onClick={() => Taro.switchTab({ url: '/pages/health/index' })}
|
||||
>
|
||||
<View className='checkin-left'>
|
||||
<ProgressRing percent={progressPercent} />
|
||||
</View>
|
||||
<View className='checkin-right'>
|
||||
<Text className='checkin-title'>
|
||||
{completedCount === 4 ? '今日体征已全部记录' : completedCount === 0 ? '今日尚未记录体征' : `今日已记录 ${completedCount}/4 项`}
|
||||
</Text>
|
||||
<View className='checkin-capsules'>
|
||||
{indicatorCapsules.map((cap) => (
|
||||
<Text
|
||||
key={cap.label}
|
||||
className={`capsule ${cap.done ? 'capsule-done' : 'capsule-pending'}`}
|
||||
>
|
||||
{cap.done ? '✓' : ''}{cap.label}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 区域 3:今日体征 2x2 网格 */}
|
||||
<View className='vitals-section'>
|
||||
{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/pkg-health/input/index' })}>
|
||||
<Text className='health-empty-btn-text'>点击开始记录</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View className='health-grid'>
|
||||
<View className='vitals-grid'>
|
||||
{healthItems.map((item) => {
|
||||
const tag = getStatusTag(item.status);
|
||||
return (
|
||||
<View className='health-cell' key={item.label} onClick={() => Taro.navigateTo({ url: `/pages/pkg-health/trend/index?indicator=${item.label === '血压' ? 'blood_pressure_systolic' : item.label === '心率' ? 'heart_rate' : item.label === '血糖' ? 'blood_sugar_fasting' : 'weight'}` })}>
|
||||
<Text className='health-cell-label'>{item.label}</Text>
|
||||
<Text className='health-cell-value'>{item.value}</Text>
|
||||
<View className='health-cell-bottom'>
|
||||
<Text className='health-cell-unit'>{item.unit}</Text>
|
||||
{tag && <Text className={`health-cell-tag ${tag.cls}`}>{tag.label}</Text>}
|
||||
<View
|
||||
className='vital-card'
|
||||
key={item.label}
|
||||
onClick={() => Taro.navigateTo({ url: `/pages/pkg-health/trend/index?indicator=${item.indicator}` })}
|
||||
>
|
||||
<Text className='vital-label'>{item.label}</Text>
|
||||
<Text className='vital-value'>{item.value}</Text>
|
||||
<View className='vital-bottom'>
|
||||
<Text className='vital-unit'>{item.unit}</Text>
|
||||
{tag && <Text className={`vital-tag ${tag.cls}`}>{tag.label}</Text>}
|
||||
{!item.status && <Text className='vital-tag tag-empty'>未记录</Text>}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -180,37 +178,21 @@ export default function Index() {
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 快捷服务 */}
|
||||
<View className='services-section'>
|
||||
<Text className='section-title'>快捷服务</Text>
|
||||
<View className='services-row'>
|
||||
{QUICK_SERVICES.map((svc) => (
|
||||
<View className='service-btn' key={svc.label} onClick={() => Taro.navigateTo({ url: svc.path })}>
|
||||
<View className='service-icon-wrap'>
|
||||
<Text className='service-icon-text'>{svc.char}</Text>
|
||||
</View>
|
||||
<Text className='service-label'>{svc.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 待办事项 */}
|
||||
<View className='upcoming-section'>
|
||||
<Text className='section-title'>待办事项</Text>
|
||||
{/* 区域 4:今日待办(≤3 条) */}
|
||||
<View className='todo-section'>
|
||||
<Text className='section-title'>今日待办</Text>
|
||||
{upcomingLoading ? (
|
||||
<Loading />
|
||||
) : upcomingItems.length === 0 ? (
|
||||
<View className='upcoming-empty'>
|
||||
<Text className='upcoming-empty-text'>暂无待办事项</Text>
|
||||
<Text className='upcoming-empty-hint'>预约挂号后将在此显示</Text>
|
||||
<View className='todo-empty'>
|
||||
<Text className='todo-empty-text'>今天没有待办事项</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className='upcoming-list'>
|
||||
<View className='todo-list'>
|
||||
{upcomingItems.map((item) => (
|
||||
<View
|
||||
key={item.id}
|
||||
className='upcoming-item'
|
||||
className='todo-item'
|
||||
onClick={() => {
|
||||
if (item.type === 'appointment') {
|
||||
Taro.navigateTo({ url: '/pages/appointment/index' });
|
||||
@@ -219,36 +201,35 @@ export default function Index() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<View className='upcoming-item-main'>
|
||||
<Text className='upcoming-item-title'>{item.title}</Text>
|
||||
<Text className='upcoming-item-sub'>{item.subtitle}</Text>
|
||||
<View className='todo-icon-wrap'>
|
||||
<Text className='todo-icon-char'>{item.icon}</Text>
|
||||
</View>
|
||||
<Text className={`upcoming-item-tag tag-${item.statusType}`}>{item.statusLabel}</Text>
|
||||
<Text className='upcoming-item-arrow'>›</Text>
|
||||
<View className='todo-info'>
|
||||
<Text className='todo-title'>{item.title}</Text>
|
||||
<Text className='todo-sub'>{item.subtitle}</Text>
|
||||
</View>
|
||||
<Text className='todo-arrow'>›</Text>
|
||||
</View>
|
||||
))}
|
||||
</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>
|
||||
))}
|
||||
{/* 区域 5:快捷操作 */}
|
||||
<View className='action-section'>
|
||||
<View
|
||||
className='action-btn action-primary'
|
||||
onClick={() => Taro.switchTab({ url: '/pages/health/index' })}
|
||||
>
|
||||
<Text className='action-btn-text'>记录体征</Text>
|
||||
</View>
|
||||
)}
|
||||
<View
|
||||
className='action-btn action-outline'
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/appointment/create/index' })}
|
||||
>
|
||||
<Text className='action-btn-text'>预约挂号</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
194
apps/miniprogram/src/pages/messages/index.scss
Normal file
194
apps/miniprogram/src/pages/messages/index.scss
Normal file
@@ -0,0 +1,194 @@
|
||||
@import '../../styles/variables.scss';
|
||||
@import '../../styles/mixins.scss';
|
||||
|
||||
.messages-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding-bottom: calc(120px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* ─── 页头 ─── */
|
||||
.messages-header {
|
||||
padding: 24px 32px 8px;
|
||||
}
|
||||
|
||||
.messages-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
/* ─── Tab 切换 ─── */
|
||||
.msg-tabs {
|
||||
display: flex;
|
||||
padding: 16px 24px 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.msg-tab {
|
||||
flex: 1;
|
||||
height: $tab-h;
|
||||
@include flex-center;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-tab-text {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.msg-tab-active .msg-tab-text {
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
.msg-tab-indicator {
|
||||
padding: 0 24px;
|
||||
height: 3px;
|
||||
background: $bd-l;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.msg-tab-bar {
|
||||
width: 50%;
|
||||
height: 3px;
|
||||
background: $pri;
|
||||
border-radius: 2px;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&.msg-tab-bar-right {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 内容区 ─── */
|
||||
.msg-content {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.msg-empty {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.msg-empty-text {
|
||||
font-size: 26px;
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
/* ─── 咨询卡片 ─── */
|
||||
.consult-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.consult-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.consult-doctor {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.consult-preview {
|
||||
font-size: 24px;
|
||||
color: $tx2;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.consult-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.consult-time {
|
||||
font-size: 22px;
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.consult-badge {
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
background: $dan;
|
||||
@include flex-center;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.consult-badge-text {
|
||||
font-size: 18px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ─── 通知卡片 ─── */
|
||||
.notify-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.notify-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notify-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.notify-desc {
|
||||
font-size: 24px;
|
||||
color: $tx2;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notify-time {
|
||||
font-size: 22px;
|
||||
color: $tx2;
|
||||
flex-shrink: 0;
|
||||
margin-left: 16px;
|
||||
}
|
||||
154
apps/miniprogram/src/pages/messages/index.tsx
Normal file
154
apps/miniprogram/src/pages/messages/index.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import { listConsultations, ConsultationSession } from '../../services/consultation';
|
||||
import Loading from '../../components/Loading';
|
||||
import './index.scss';
|
||||
|
||||
type MsgTab = 'consultation' | 'notification';
|
||||
|
||||
interface NotificationItem {
|
||||
id: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
time: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export default function Messages() {
|
||||
const [activeTab, setActiveTab] = useState<MsgTab>('consultation');
|
||||
const [sessions, setSessions] = useState<ConsultationSession[]>([]);
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useDidShow(() => {
|
||||
loadData(activeTab);
|
||||
});
|
||||
|
||||
const loadData = async (tab: MsgTab) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (tab === 'consultation') {
|
||||
const res = await listConsultations({ page: 1, page_size: 20 });
|
||||
setSessions(res.data || []);
|
||||
} else {
|
||||
// 通知目前从咨询中提取状态变化作为示例
|
||||
// 后续可对接独立通知 API
|
||||
setNotifications([]);
|
||||
}
|
||||
} catch {
|
||||
if (tab === 'consultation') setSessions([]);
|
||||
else setNotifications([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = (tab: MsgTab) => {
|
||||
setActiveTab(tab);
|
||||
loadData(tab);
|
||||
};
|
||||
|
||||
const formatTime = (dateStr: string | null) => {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
if (diffMin < 60) return `${diffMin} 分钟前`;
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
if (diffHour < 24) return `${diffHour} 小时前`;
|
||||
return dateStr.slice(0, 10);
|
||||
};
|
||||
|
||||
return (
|
||||
<View className='messages-page'>
|
||||
{/* 页头 */}
|
||||
<View className='messages-header'>
|
||||
<Text className='messages-title'>消息</Text>
|
||||
</View>
|
||||
|
||||
{/* Tab 切换 */}
|
||||
<View className='msg-tabs'>
|
||||
<View
|
||||
className={`msg-tab ${activeTab === 'consultation' ? 'msg-tab-active' : ''}`}
|
||||
onClick={() => handleTabChange('consultation')}
|
||||
>
|
||||
<Text className='msg-tab-text'>咨询</Text>
|
||||
</View>
|
||||
<View
|
||||
className={`msg-tab ${activeTab === 'notification' ? 'msg-tab-active' : ''}`}
|
||||
onClick={() => handleTabChange('notification')}
|
||||
>
|
||||
<Text className='msg-tab-text'>通知</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className='msg-tab-indicator'>
|
||||
<View className={`msg-tab-bar ${activeTab === 'notification' ? 'msg-tab-bar-right' : ''}`} />
|
||||
</View>
|
||||
|
||||
{/* 咨询列表 */}
|
||||
{activeTab === 'consultation' && (
|
||||
<View className='msg-content'>
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : sessions.length === 0 ? (
|
||||
<View className='msg-empty'>
|
||||
<Text className='msg-empty-text'>暂无咨询消息</Text>
|
||||
</View>
|
||||
) : (
|
||||
sessions.map((session) => (
|
||||
<View
|
||||
key={session.id}
|
||||
className='consult-card'
|
||||
onClick={() => Taro.navigateTo({ url: `/pages/consultation/detail/index?id=${session.id}` })}
|
||||
>
|
||||
<View className='consult-info'>
|
||||
<Text className='consult-doctor'>
|
||||
{session.consultation_type === 'online' ? '在线咨询' : '门诊咨询'}
|
||||
</Text>
|
||||
<Text className='consult-preview'>
|
||||
{session.last_message || session.subject || '暂无消息'}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='consult-meta'>
|
||||
<Text className='consult-time'>{formatTime(session.last_message_at)}</Text>
|
||||
{session.unread_count_patient > 0 && (
|
||||
<View className='consult-badge'>
|
||||
<Text className='consult-badge-text'>
|
||||
{session.unread_count_patient > 99 ? '99+' : session.unread_count_patient}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 通知列表 */}
|
||||
{activeTab === 'notification' && (
|
||||
<View className='msg-content'>
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : notifications.length === 0 ? (
|
||||
<View className='msg-empty'>
|
||||
<Text className='msg-empty-text'>暂无新通知</Text>
|
||||
</View>
|
||||
) : (
|
||||
notifications.map((n) => (
|
||||
<View key={n.id} className='notify-card'>
|
||||
<View className='notify-info'>
|
||||
<Text className='notify-title'>{n.title}</Text>
|
||||
<Text className='notify-desc'>{n.desc}</Text>
|
||||
</View>
|
||||
<Text className='notify-time'>{n.time}</Text>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -63,7 +63,7 @@
|
||||
}
|
||||
|
||||
&.resolved {
|
||||
@include tag($suc-l, $suc);
|
||||
@include tag($acc-l, $acc);
|
||||
}
|
||||
|
||||
&.chronic {
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
/* ─── 积分统计 ─── */
|
||||
/* ─── 积分 + 打卡横排 ─── */
|
||||
.profile-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -63,7 +63,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
.stat-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -72,12 +72,20 @@
|
||||
|
||||
.stat-value {
|
||||
@include serif-number;
|
||||
font-size: 36px;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value-pri {
|
||||
color: #FFD6C7;
|
||||
}
|
||||
|
||||
.stat-value-acc {
|
||||
color: #C8E6C9;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 22px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
@@ -103,6 +111,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 28px 24px;
|
||||
min-height: $menu-item-h;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child {
|
||||
@@ -120,7 +130,7 @@
|
||||
border-radius: $r-sm;
|
||||
background: $pri-l;
|
||||
@include flex-center;
|
||||
margin-right: 16px;
|
||||
margin-right: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -134,7 +144,7 @@
|
||||
|
||||
.menu-label {
|
||||
flex: 1;
|
||||
font-size: 30px;
|
||||
font-size: 28px;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
@@ -159,6 +169,6 @@
|
||||
}
|
||||
|
||||
.logout-text {
|
||||
font-size: 30px;
|
||||
font-size: 28px;
|
||||
color: $dan;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
box-shadow: $shadow-md;
|
||||
padding: 24px;
|
||||
margin: 0 24px 20px;
|
||||
padding: 28px;
|
||||
margin: 0 24px 24px;
|
||||
}
|
||||
|
||||
@mixin flex-center {
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
@mixin section-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: 30px;
|
||||
font-size: 26px;
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: 20px;
|
||||
@@ -37,8 +37,48 @@
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: $r-sm;
|
||||
font-size: 20px;
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
background: $bg;
|
||||
color: $color;
|
||||
}
|
||||
|
||||
@mixin touch-target {
|
||||
min-height: $touch-min;
|
||||
min-width: $touch-min;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@mixin btn-primary {
|
||||
height: $btn-primary-h;
|
||||
border-radius: $r;
|
||||
background: $pri;
|
||||
color: #FFFFFF;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
width: 100%;
|
||||
@include touch-target;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin btn-outline {
|
||||
height: $btn-primary-h;
|
||||
border-radius: $r;
|
||||
background: transparent;
|
||||
color: $pri;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
border: 2px solid $pri;
|
||||
width: 100%;
|
||||
@include touch-target;
|
||||
|
||||
&:active {
|
||||
background: $pri-l;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ $bg: #F5F0EB; // 主背景 (warm cream)
|
||||
$card: #FFFFFF; // 卡片白
|
||||
$surface-alt: #EDE8E2; // 辅助底
|
||||
$tx: #2D2A26; // 主文字 (warm black)
|
||||
$tx2: #7A756E; // 次文字 (warm gray)
|
||||
$tx3: #A8A29E; // 淡文字
|
||||
$tx2: #5A554F; // 次文字 (warm gray) — AA 正文对比度 ~5.5:1
|
||||
$tx3: #78716C; // 淡文字 — AA 正文对比度 ~4.6:1(仅 ≥24px)
|
||||
$bd: #E8E2DC; // 边框
|
||||
$bd-l: #F0EBE5; // 浅边框
|
||||
$dan: #B54A4A; // 危险 (muted red)
|
||||
@@ -22,11 +22,18 @@ $wrn: #C4873A; // 警告 (warm amber)
|
||||
$wrn-l: #FFF3E0; // 警告浅
|
||||
|
||||
// ─── 圆角 ───
|
||||
$r: 12px;
|
||||
$r-sm: 8px;
|
||||
$r-lg: 16px;
|
||||
$r: 16px;
|
||||
$r-sm: 12px;
|
||||
$r-lg: 20px;
|
||||
$r-pill: 999px;
|
||||
|
||||
// ─── 老年友好触控参数 ───
|
||||
$touch-min: 48px; // 最小触控区域
|
||||
$btn-primary-h: 56px; // 主按钮高度
|
||||
$menu-item-h: 64px; // 菜单项高度
|
||||
$tab-h: 56px; // Tab 切换高度
|
||||
$font-min: 22px; // 最小字号
|
||||
|
||||
// ─── 阴影 ───
|
||||
$shadow-sm: 0 1px 4px rgba(45, 42, 38, 0.04);
|
||||
$shadow-md: 0 2px 12px rgba(45, 42, 38, 0.08);
|
||||
|
||||
Reference in New Issue
Block a user