重构健康中心页面:合并指标入口、独立记录页面、多指标趋势图

- 健康中心合并血压/心率/血糖/血氧/体重为统一入口卡片
- 记录页面每个指标独立日期+保存,紧凑设计
- 趋势图支持多指标切换显示,同时显示收缩压和舒张压
- 首页健康概览修复返回页面后数据不更新的问题
- Vite 添加代理,支持手机通过局域网 IP 访问
- 医生端患者详情新增健康趋势图及指标切换
- 运动饮食页面支持删除记录
- 修复复查完成后患者端消失的问题
This commit is contained in:
MingNian
2026-05-26 15:56:06 +08:00
parent d5f167167a
commit f412a474cd
21 changed files with 1617 additions and 404 deletions

View File

@@ -0,0 +1,63 @@
import ReactECharts from 'echarts-for-react';
export interface SeriesData {
name: string;
color: string;
data: { date: string; value: number }[];
unit: string;
}
interface MultiLineChartProps {
series: SeriesData[];
}
export function MultiLineChart({ series }: MultiLineChartProps) {
if (series.length === 0) return null;
const allDates = [...new Set(series.flatMap((s) => s.data.map((d) => d.date)))].sort();
const option = {
grid: { top: 16, right: 24, bottom: 24, left: 48 },
tooltip: {
trigger: 'axis',
formatter: (params: unknown[]) => {
const items = params as { axisValue: string; color: string; seriesName: string; data: number }[];
let html = `<div style="font-weight:600;margin-bottom:4px">${items[0]?.axisValue || ''}</div>`;
items.forEach((p) => {
const s = series.find((x) => x.name === p.seriesName);
html += `<div><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${p.color};margin-right:6px"></span>${p.seriesName}: ${p.data} ${s?.unit || ''}</div>`;
});
return html;
},
},
xAxis: {
type: 'category',
data: allDates.map((d) => d.slice(5)),
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { fontSize: 10, color: '#9CA3AF' },
},
yAxis: series.map((s, i) => ({
type: 'value',
name: i === 0 ? undefined : '',
splitLine: i === 0 ? { lineStyle: { color: '#F3F4F6' } } : { show: false },
axisLabel: { fontSize: 10, color: i === 0 ? '#9CA3AF' : 'transparent' },
})),
series: series.map((s, i) => ({
name: s.name,
type: 'line',
data: allDates.map((date) => {
const match = s.data.find((d) => d.date === date);
return match ? match.value : null;
}),
smooth: true,
symbol: 'circle',
symbolSize: 4,
lineStyle: { color: s.color, width: 2 },
itemStyle: { color: s.color },
yAxisIndex: i,
})),
};
return <ReactECharts option={option} style={{ height: 320 }} notMerge />;
}

View File

@@ -1,22 +1,339 @@
.tabs { display: flex; gap: 8px; margin-bottom: 16px; }
.tab { padding: 6px 14px; border-radius: var(--radius-full); font-size: var(--font-size-sm); background: var(--color-bg-secondary); color: var(--color-text-secondary); font-weight: 500; }
.tabActive { background: var(--color-primary); color: var(--color-text-inverse); }
.sectionTitle { font-size: var(--font-size-base); font-weight: 700; margin: 16px 0 8px; }
.recCard { margin-bottom: 8px; }
.recHeader { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; font-weight: 600; font-size: var(--font-size-sm); }
.suitBadge { font-size: var(--font-size-xs); padding: 2px 8px; border-radius: var(--radius-sm); font-weight: 600; }
.suitYes { background: var(--color-success-bg); color: var(--color-success); }
.suitNo { background: var(--color-danger-bg); color: var(--color-danger); }
.notSuitable { opacity: 0.5; }
.recMeta { font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
.recDesc { font-size: var(--font-size-xs); color: var(--color-text-secondary); margin: 6px 0; line-height: 1.5; }
.foodTags { display: flex; gap: 6px; flex-wrap: wrap; }
.foodTag { padding: 2px 8px; font-size: var(--font-size-xs); background: var(--color-primary-bg); color: var(--color-primary); border-radius: var(--radius-sm); font-weight: 500; }
.addCard { margin-bottom: 12px; display: flex; flex-direction: column; gap: 10px; }
.addRow { display: flex; gap: 8px; align-items: center; }
.select { padding: 10px 12px; border: 1.5px solid var(--color-border); border-radius: var(--radius-md); font-size: var(--font-size-sm); background: var(--color-bg); outline: none; }
.intensityRow { display: flex; gap: 8px; }
.intensityBtn { flex: 1; padding: 6px; font-size: var(--font-size-xs); background: var(--color-bg); border-radius: var(--radius-md); }
.intensityActive { background: var(--color-primary-bg); color: var(--color-primary); }
.logCard { margin-bottom: 6px; }
.logDate { font-size: var(--font-size-xs); color: var(--color-text-tertiary); margin-top: 2px; }
/* Tabs */
.tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.tab {
flex: 1;
padding: 12px;
border-radius: 14px;
background: #F5F6F9;
border: none;
font-size: 15px;
font-weight: 600;
color: #6B7280;
cursor: pointer;
transition: all 0.25s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.tabIcon { font-size: 18px; }
.tabActive {
background: linear-gradient(135deg, #4F6EF7, #6C8AFF);
color: #fff;
box-shadow: 0 4px 16px rgba(79,110,247,0.3);
transform: scale(1.02);
}
/* Summary cards */
.summaryCard,
.summaryCardDiet {
position: relative;
border-radius: 20px;
padding: 24px;
margin-bottom: 16px;
overflow: hidden;
background: linear-gradient(135deg, #f0f4ff 0%, #fff 60%);
box-shadow: 0 2px 16px rgba(0,0,0,0.04);
}
.summaryCardDiet {
background: linear-gradient(135deg, #f0faf6 0%, #fff 60%);
}
.summaryBg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.summaryContent {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
}
.summaryLabel { font-size: 13px; color: #6B7280; margin-bottom: 4px; }
.summaryValue { font-size: 32px; font-weight: 800; color: #1A1D28; }
.summaryUnit { font-size: 14px; font-weight: 500; color: #9BA0B4; }
.summaryHint { font-size: 12px; color: #9BA0B4; margin-top: 4px; }
.summaryRight {
animation: pulse 3s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
/* Add card */
.addCard {
padding: 16px;
margin-bottom: 20px;
border-radius: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.addGrid {
display: grid;
grid-template-columns: 1fr 100px;
gap: 10px;
}
.addGrid3 {
display: grid;
grid-template-columns: 1fr 80px 100px;
gap: 10px;
}
.select {
padding: 10px 14px;
border: 1.5px solid #E1E5ED;
border-radius: 10px;
font-size: 14px;
background: #fff;
color: #1A1D28;
outline: none;
cursor: pointer;
}
.select:focus { border-color: #4F6EF7; }
.intensityRow {
display: flex;
gap: 8px;
}
.intensityBtn {
flex: 1;
padding: 10px 0;
border: 1.5px solid #E1E5ED;
border-radius: 10px;
background: #fff;
font-size: 13px;
font-weight: 500;
color: #6B7280;
cursor: pointer;
transition: all 0.2s;
}
.intensityActive {
border-color: #4F6EF7;
background: #EEF2FF;
color: #4F6EF7;
font-weight: 600;
}
.mealTabRow {
display: flex;
gap: 6px;
}
.mealTab {
flex: 1;
padding: 10px 4px;
border: 1.5px solid #E1E5ED;
border-radius: 10px;
background: #fff;
font-size: 12px;
font-weight: 500;
color: #6B7280;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.mealTabActive {
border-color: #20C997;
background: #E6F9F2;
color: #20C997;
font-weight: 600;
}
/* Day groups */
.dayGroup {
margin-bottom: 16px;
}
.dayLabel {
font-size: 12px;
font-weight: 600;
color: #9BA0B4;
padding: 0 4px 8px;
letter-spacing: 0.3px;
}
/* Log cards */
.logCard {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: #fff;
border-radius: 14px;
margin-bottom: 8px;
box-shadow: 0 1px 8px rgba(0,0,0,0.03);
border-left: 4px solid #E1E5ED;
transition: transform 0.15s, box-shadow 0.15s;
animation: fadeInUp 0.3s ease both;
}
.logCard:active { transform: scale(0.98); }
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.intensityLow { border-left-color: #20C997; }
.intensityModerate { border-left-color: #F59E0B; }
.intensityHigh { border-left-color: #EF4444; }
.mealBreakfast { border-left-color: #F59E0B; }
.mealLunch { border-left-color: #4F6EF7; }
.mealDinner { border-left-color: #845EF7; }
.mealSnack { border-left-color: #20C997; }
.logIcon { font-size: 28px; flex-shrink: 0; }
.logInfo { flex: 1; min-width: 0; }
.logTitle { font-size: 15px; font-weight: 600; color: #1A1D28; }
.logMeta { font-size: 12px; color: #9BA0B4; margin-top: 2px; }
.intensityTag {
font-size: 11px;
padding: 3px 10px;
border-radius: 8px;
font-weight: 600;
background: #F5F6F9;
color: #6B7280;
flex-shrink: 0;
}
.delBtn {
width: 24px;
height: 24px;
border: none;
background: transparent;
color: #C0C5D2;
font-size: 18px;
cursor: pointer;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.15s;
margin-left: 4px;
}
.delBtn:hover {
color: #EF4444;
background: #FEE9E9;
}
.delBtn:active {
transform: scale(0.9);
}
/* Section titles */
.sectionTitle {
font-size: 16px;
font-weight: 700;
color: #1A1D28;
margin: 24px 0 12px;
}
/* Recommendation cards grid */
.recGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 24px;
}
.recCard {
position: relative;
padding: 16px;
background: #fff;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0,0,0,0.04);
text-align: center;
transition: transform 0.2s;
}
.recCard:hover { transform: translateY(-2px); }
.recNo { opacity: 0.5; }
.recEmoji { font-size: 32px; margin-bottom: 6px; }
.recName { font-size: 14px; font-weight: 600; color: #1A1D28; margin-bottom: 2px; }
.recMeta { font-size: 11px; color: #9BA0B4; margin-bottom: 8px; }
.recBadge {
display: inline-block;
font-size: 11px;
padding: 3px 12px;
border-radius: 10px;
font-weight: 600;
}
.recGood { background: #E6F9F2; color: #20C997; }
.recBad { background: #FEE9E9; color: #EF4444; }
/* Diet recommendation cards */
.dietRecCard {
padding: 16px;
background: #fff;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0,0,0,0.04);
transition: transform 0.2s;
}
.dietRecCard:hover { transform: translateY(-2px); }
.dietRecHeader {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.dietRecIcon { font-size: 16px; }
.dietRecTitle { font-size: 14px; font-weight: 600; color: #1A1D28; }
.dietRecDesc {
font-size: 12px;
color: #6B7280;
line-height: 1.5;
margin: 0 0 10px;
}
.dietRecFoods { display: flex; flex-wrap: wrap; gap: 6px; }
.dietFoodTag {
font-size: 11px;
padding: 4px 10px;
border-radius: 8px;
background: #E6F9F2;
color: #20C997;
font-weight: 500;
}
/* Responsive */
@media (max-width: 360px) {
.recGrid { grid-template-columns: 1fr; }
.addGrid3 { grid-template-columns: 1fr 1fr; }
.addGrid { grid-template-columns: 1fr 80px; }
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useMemo } from 'react';
import { PageHeader } from '@/components/layout/PageHeader';
import { Card } from '@/components/common/Card';
import { Button } from '@/components/common/Button';
@@ -10,144 +10,280 @@ import type { ExerciseRecord, DietRecord } from '@/types';
import { formatDate } from '@/utils/format';
import styles from './ExerciseDietPage.module.css';
const EXERCISE_TYPES = ['散步', '慢跑', '太极拳', '游泳', '骑自行车', '八段锦', '瑜伽', '广场舞'];
const INTENSITIES = [
{ key: 'low' as const, label: '低强度', emoji: '🟢' },
{ key: 'moderate' as const, label: '中强度', emoji: '🟡' },
{ key: 'high' as const, label: '高强度', emoji: '🔴' },
];
const MEAL_TYPES = [
{ key: 'breakfast' as const, label: '早餐', icon: '🌅' },
{ key: 'lunch' as const, label: '午餐', icon: '☀️' },
{ key: 'dinner' as const, label: '晚餐', icon: '🌙' },
{ key: 'snack' as const, label: '加餐', icon: '🍎' },
];
function groupByDate<T extends { date: string }>(items: T[]): [string, T[]][] {
const map = new Map<string, T[]>();
items.forEach((item) => {
const list = map.get(item.date) || [];
list.push(item);
map.set(item.date, list);
});
return [...map.entries()].sort((a, b) => b[0].localeCompare(a[0]));
}
export function ExerciseDietPage() {
const [subTab, setSubTab] = useState<'recommend' | 'exercise' | 'diet'>('recommend');
const [tab, setTab] = useState<'exercise' | 'diet'>('exercise');
const [exercises, setExercises] = useState<ExerciseRecord[]>([]);
const [diets, setDiets] = useState<DietRecord[]>([]);
// exercise form
const [exType, setExType] = useState('散步');
const [exDuration, setExDuration] = useState('30');
const [exIntensity, setExIntensity] = useState<'low' | 'moderate' | 'high'>('low');
// diet form
const [mealType, setMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast');
const [foodName, setFoodName] = useState('');
const [foodAmount, setFoodAmount] = useState('1份');
const [foodKcal, setFoodKcal] = useState('');
const recommendations = exerciseDietService.getExerciseRecommendations();
const exRecommendations = exerciseDietService.getExerciseRecommendations();
const dietRecommendations = exerciseDietService.getDietRecommendations();
const groupedExercises = useMemo(() => groupByDate(exercises).slice(0, 7), [exercises]);
const groupedDiets = useMemo(() => groupByDate(diets).slice(0, 7), [diets]);
useEffect(() => {
exerciseDietService.getExerciseLogs().then(setExercises);
exerciseDietService.getDietLogs().then(setDiets);
}, []);
const deleteExercise = async (id: string) => {
await exerciseDietService.deleteExerciseLog(id);
toast('已删除');
exerciseDietService.getExerciseLogs().then(setExercises);
};
const deleteDiet = async (id: string) => {
await exerciseDietService.deleteDietLog(id);
toast('已删除');
exerciseDietService.getDietLogs().then(setDiets);
};
const addExercise = async () => {
if (!exDuration) return;
await exerciseDietService.addExerciseLog({
type: exType, duration: parseInt(exDuration), intensity: exIntensity,
caloriesBurned: parseInt(exDuration) * 4, date: new Date().toISOString().slice(0, 10),
caloriesBurned: parseInt(exDuration) * ({ low: 4, moderate: 7, high: 11 }[exIntensity] || 4),
date: new Date().toISOString().slice(0, 10),
});
toast('记录成功');
toast('运动记录成功');
exerciseDietService.getExerciseLogs().then(setExercises);
};
const addDiet = async () => {
if (!foodName || !foodKcal) { toast('请填写食物信息', 'error'); return; }
if (!foodName || !foodKcal) { toast('请填写食物名称和热量', 'error'); return; }
await exerciseDietService.addDietLog({
mealType, foods: [{ name: foodName, amount: '1份', calories: parseInt(foodKcal) }],
mealType, foods: [{ name: foodName, amount: foodAmount, calories: parseInt(foodKcal) }],
totalCalories: parseInt(foodKcal), date: new Date().toISOString().slice(0, 10),
});
toast('记录成功');
setFoodName(''); setFoodAmount('1份'); setFoodKcal('');
toast('饮食记录成功');
exerciseDietService.getDietLogs().then(setDiets);
};
const todayExKcal = exercises.filter(e => e.date === new Date().toISOString().slice(0, 10)).reduce((s, e) => s + (e.caloriesBurned || 0), 0);
const todayDietKcal = diets.filter(d => d.date === new Date().toISOString().slice(0, 10)).reduce((s, d) => s + (d.totalCalories || 0), 0);
return (
<div className="page--no-tab">
<PageHeader title="运动饮食" />
{/* Tab switch */}
<div className={styles.tabs}>
{[
{ key: 'recommend', label: '推荐' },
{ key: 'exercise', label: '运动' },
{ key: 'diet', label: '饮食' },
].map((t) => (
<button key={t.key} className={`${styles.tab} ${subTab === t.key ? styles.tabActive : ''}`} onClick={() => setSubTab(t.key as typeof subTab)}>
{t.label}
</button>
))}
<button className={`${styles.tab} ${tab === 'exercise' ? styles.tabActive : ''}`} onClick={() => setTab('exercise')}>
<span className={styles.tabIcon}>🏃</span>
</button>
<button className={`${styles.tab} ${tab === 'diet' ? styles.tabActive : ''}`} onClick={() => setTab('diet')}>
<span className={styles.tabIcon}>🥗</span>
</button>
</div>
{subTab === 'recommend' && (
<div>
<h3 className={styles.sectionTitle}></h3>
{recommendations.map((r, i) => (
<Card key={i} className={`${styles.recCard} ${!r.suitable ? styles.notSuitable : ''}`}>
<div className={styles.recHeader}>
<span>{r.name}</span>
<span className={`${styles.suitBadge} ${r.suitable ? styles.suitYes : styles.suitNo}`}>
{r.suitable ? '适合' : '不适合'}
</span>
{/* ============ EXERCISE TAB ============ */}
{tab === 'exercise' && (
<>
{/* Today summary card */}
<div className={styles.summaryCard}>
<svg className={styles.summaryBg} viewBox="0 0 400 120" preserveAspectRatio="none">
<ellipse cx="350" cy="10" rx="180" ry="140" fill="rgba(79,110,247,0.06)" />
<ellipse cx="50" cy="100" rx="120" ry="60" fill="rgba(79,110,247,0.04)" />
</svg>
<div className={styles.summaryContent}>
<div className={styles.summaryLeft}>
<div className={styles.summaryLabel}></div>
<div className={styles.summaryValue}>{todayExKcal} <span className={styles.summaryUnit}>kcal</span></div>
<div className={styles.summaryHint}> {Math.round(todayExKcal / 8)} </div>
</div>
<div className={styles.recMeta}>{r.duration} · {r.frequency} · {r.intensity}</div>
</Card>
))}
<h3 className={styles.sectionTitle}></h3>
{dietRecommendations.slice(0, 3).map((d, i) => (
<Card key={i} className={styles.recCard}>
<div className={styles.recHeader}><span>{d.title}</span></div>
<p className={styles.recDesc}>{d.description}</p>
<div className={styles.foodTags}>
{d.recommendedFoods.slice(0, 3).map((f, j) => (
<span key={j} className={styles.foodTag}>{f}</span>
))}
<div className={styles.summaryRight}>
<svg width="64" height="64" viewBox="0 0 64 64" fill="none">
<circle cx="32" cy="32" r="28" stroke="#E8ECF4" strokeWidth="6" />
<circle cx="32" cy="32" r="28" stroke="url(#exGrad)" strokeWidth="6" strokeLinecap="round"
strokeDasharray={`${Math.min(todayExKcal / 3, 175)} 176`} transform="rotate(-90 32 32)" />
<defs><linearGradient id="exGrad"><stop stopColor="#4F6EF7"/><stop offset="1" stopColor="#6C8AFF"/></linearGradient></defs>
</svg>
</div>
</Card>
))}
</div>
)}
</div>
</div>
{subTab === 'exercise' && (
<div>
{/* Add record */}
<Card className={styles.addCard}>
<div className={styles.addRow}>
<div className={styles.addGrid}>
<select className={styles.select} value={exType} onChange={(e) => setExType(e.target.value)}>
{['散步', '慢跑', '太极拳', '游泳', '骑自行车', '八段锦'].map((t) => (
<option key={t}>{t}</option>
))}
{EXERCISE_TYPES.map((t) => (<option key={t}>{t}</option>))}
</select>
<Input value={exDuration} onChange={(e) => setExDuration(e.target.value)} type="number" placeholder="分钟" />
</div>
<div className={styles.intensityRow}>
{['low', 'moderate', 'high'].map((i) => (
<button key={i} className={`${styles.intensityBtn} ${exIntensity === i ? styles.intensityActive : ''}`} onClick={() => setExIntensity(i as typeof exIntensity)}>
{{ low: '低强度', moderate: '中强度', high: '高强度' }[i]}
{INTENSITIES.map((i) => (
<button key={i.key} className={`${styles.intensityBtn} ${exIntensity === i.key ? styles.intensityActive : ''}`}
onClick={() => setExIntensity(i.key)}>
{i.emoji} {i.label}
</button>
))}
</div>
<Button size="sm" onClick={addExercise}></Button>
</Card>
{exercises.length === 0 ? <Empty message="暂无运动记录" /> : exercises.slice(0, 10).map((e) => (
<Card key={e.id} className={styles.logCard}>
<div>{e.type} · {e.duration} · {e.caloriesBurned}kcal</div>
<div className={styles.logDate}>{formatDate(e.date, 'MM-DD')}</div>
</Card>
))}
</div>
{/* Recent records */}
{groupedExercises.length === 0 ? (
<Empty message="暂无运动记录,开始记录吧" />
) : (
groupedExercises.map(([date, items]) => (
<div key={date} className={styles.dayGroup}>
<div className={styles.dayLabel}>{formatDate(date, 'MM月DD日')} · {items.reduce((s, e) => s + (e.duration || 0), 0)}</div>
{items.map((e, i) => (
<div key={i} className={`${styles.logCard} ${styles[`intensity${e.intensity?.[0]?.toUpperCase()}${e.intensity?.slice(1)}`] || ''}`}>
<div className={styles.logIcon}>{e.type === '散步' ? '🚶' : e.type === '慢跑' ? '🏃' : e.type === '太极拳' ? '🤸' : e.type === '游泳' ? '🏊' : e.type === '骑自行车' ? '🚴' : e.type === '八段锦' ? '🧘' : '💪'}</div>
<div className={styles.logInfo}>
<div className={styles.logTitle}>{e.type}</div>
<div className={styles.logMeta}>{e.duration} · {e.caloriesBurned}kcal</div>
</div>
<span className={styles.intensityTag}>
{{ low: '低', moderate: '中', high: '高' }[e.intensity || 'low']}
</span>
<button className={styles.delBtn} onClick={() => deleteExercise(e.id)} title="删除">×</button>
</div>
))}
</div>
))
)}
{/* Recommendations */}
<h3 className={styles.sectionTitle}></h3>
<div className={styles.recGrid}>
{exRecommendations.slice(0, 4).map((r, i) => (
<div key={i} className={`${styles.recCard} ${!r.suitable ? styles.recNo : ''}`}>
<div className={styles.recEmoji}>{r.name === '散步' ? '🚶' : r.name === '太极拳' ? '🤸' : r.name === '慢跑' ? '🏃' : r.name === '游泳' ? '🏊' : r.name === '骑自行车' ? '🚴' : r.name === '八段锦' ? '🧘' : '🏋️'}</div>
<div className={styles.recName}>{r.name}</div>
<div className={styles.recMeta}>{r.duration} · {r.frequency}</div>
<span className={`${styles.recBadge} ${r.suitable ? styles.recGood : styles.recBad}`}>
{r.suitable ? '适合' : '避免'}
</span>
</div>
))}
</div>
</>
)}
{subTab === 'diet' && (
<div>
{/* ============ DIET TAB ============ */}
{tab === 'diet' && (
<>
{/* Today summary card */}
<div className={styles.summaryCardDiet}>
<svg className={styles.summaryBg} viewBox="0 0 400 120" preserveAspectRatio="none">
<ellipse cx="350" cy="10" rx="180" ry="140" fill="rgba(32,201,151,0.06)" />
<ellipse cx="50" cy="100" rx="120" ry="60" fill="rgba(32,201,151,0.04)" />
</svg>
<div className={styles.summaryContent}>
<div className={styles.summaryLeft}>
<div className={styles.summaryLabel}></div>
<div className={styles.summaryValue}>{todayDietKcal} <span className={styles.summaryUnit}>kcal</span></div>
<div className={styles.summaryHint}>
{todayDietKcal < 1200 ? '摄入偏低,注意营养' : todayDietKcal < 2200 ? '摄入适中,继续保持' : '摄入偏高,注意控制'}
</div>
</div>
<div className={styles.summaryRight}>
<svg width="64" height="64" viewBox="0 0 64 64">
<path d="M16 24c0-4 3-8 8-8h16c5 0 8 4 8 8v8c0 8-3 16-16 16s-16-8-16-16V24z" fill="#20C997" opacity="0.15" />
<circle cx="40" cy="44" r="14" fill="#20C997" opacity="0.1" />
<text x="32" y="40" textAnchor="middle" fontSize="18">🍽</text>
</svg>
</div>
</div>
</div>
{/* Add record */}
<Card className={styles.addCard}>
<div className={styles.addRow}>
<select className={styles.select} value={mealType} onChange={(e) => setMealType(e.target.value as typeof mealType)}>
<option value="breakfast"></option>
<option value="lunch"></option>
<option value="dinner"></option>
<option value="snack"></option>
</select>
<Input value={foodName} onChange={(e) => setFoodName(e.target.value)} placeholder="食物名" />
<Input value={foodKcal} onChange={(e) => setFoodKcal(e.target.value)} type="number" placeholder="kcal" />
<div className={styles.mealTabRow}>
{MEAL_TYPES.map((m) => (
<button key={m.key} className={`${styles.mealTab} ${mealType === m.key ? styles.mealTabActive : ''}`}
onClick={() => setMealType(m.key)}>
{m.icon} {m.label}
</button>
))}
</div>
<div className={styles.addGrid3}>
<Input value={foodName} onChange={(e) => setFoodName(e.target.value)} placeholder="食物名称" />
<Input value={foodAmount} onChange={(e) => setFoodAmount(e.target.value)} placeholder="份量" />
<Input value={foodKcal} onChange={(e) => setFoodKcal(e.target.value)} type="number" placeholder="热量(kcal)" />
</div>
<Button size="sm" onClick={addDiet}></Button>
</Card>
{diets.length === 0 ? <Empty message="暂无饮食记录" /> : diets.slice(0, 10).map((d) => (
<Card key={d.id} className={styles.logCard}>
<div>{d.foods.map((f) => f.name).join(', ')}</div>
<div className={styles.logDate}>{d.totalCalories}kcal · {formatDate(d.date, 'MM-DD')}</div>
</Card>
))}
</div>
{/* Recent records */}
{groupedDiets.length === 0 ? (
<Empty message="暂无饮食记录,开始记录吧" />
) : (
groupedDiets.map(([date, items]) => (
<div key={date} className={styles.dayGroup}>
<div className={styles.dayLabel}>{formatDate(date, 'MM月DD日')} · {items.reduce((s, d) => s + (d.totalCalories || 0), 0)}kcal</div>
{items.map((d, i) => (
<div key={i} className={`${styles.logCard} ${styles[`meal${d.mealType?.[0]?.toUpperCase()}${d.mealType?.slice(1)}`] || ''}`}>
<div className={styles.logIcon}>{MEAL_TYPES.find(m => m.key === d.mealType)?.icon || '🍽️'}</div>
<div className={styles.logInfo}>
<div className={styles.logTitle}>{d.foods?.map(f => f.name).join('、')}</div>
<div className={styles.logMeta}>
{MEAL_TYPES.find(m => m.key === d.mealType)?.label} · {d.totalCalories}kcal · {d.foods?.map(f => f.amount).join('、')}
</div>
</div>
<button className={styles.delBtn} onClick={() => deleteDiet(d.id)} title="删除">×</button>
</div>
))}
</div>
))
)}
{/* Recommendations */}
<h3 className={styles.sectionTitle}></h3>
<div className={styles.recGrid}>
{dietRecommendations.slice(0, 4).map((d, i) => (
<div key={i} className={styles.dietRecCard}>
<div className={styles.dietRecHeader}>
<span className={styles.dietRecIcon}>💡</span>
<span className={styles.dietRecTitle}>{d.title}</span>
</div>
<p className={styles.dietRecDesc}>{d.description.slice(0, 40)}...</p>
<div className={styles.dietRecFoods}>
{d.recommendedFoods.slice(0, 3).map((f, j) => (
<span key={j} className={styles.dietFoodTag}>{f}</span>
))}
</div>
</div>
))}
</div>
</>
)}
<ToastContainer />

View File

@@ -1,57 +1,251 @@
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 16px;
/* Combined card */
.combinedCard {
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px;
margin: 0 -4px;
width: calc(100% + 8px);
background: #fff;
border-radius: 18px;
box-shadow: 0 2px 16px rgba(0,0,0,0.06);
cursor: pointer;
transition: transform 0.15s;
}
.card {
.combinedCard:active { transform: scale(0.985); }
.combinedRow {
display: flex;
align-items: center;
gap: 14px;
}
.combinedIcon {
width: 52px;
height: 52px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.combinedInfo { flex: 1; text-align: left; }
.combinedTitle { font-size: 18px; font-weight: 700; color: #1A1D28; display: block; }
.combinedDesc { font-size: 12px; color: #9BA0B4; margin-top: 2px; display: block; }
.combinedArrow { font-size: 24px; color: #C0C5D2; }
.indicatorTags {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.tag {
font-size: 11px;
font-weight: 600;
padding: 4px 12px;
border-radius: 20px;
}
/* Quick links — horizontal row */
.quickRow {
display: flex;
gap: 10px;
margin: 20px -4px 0;
}
.quickCard {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 18px 12px;
background: var(--color-white);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
transition: transform 0.2s;
-webkit-tap-highlight-color: transparent;
position: relative;
overflow: hidden;
padding: 14px 8px;
background: #fff;
border: none;
border-radius: 14px;
box-shadow: 0 1px 8px rgba(0,0,0,0.04);
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
}
.card:active { transform: scale(0.95); }
.quickCard:active { transform: scale(0.95); }
.cardIcon {
width: 50px;
height: 50px;
border-radius: 16px;
.quickIcon {
width: 42px;
height: 42px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.cardTitle { font-size: var(--font-size-base); font-weight: 700; color: var(--color-text-primary); }
.cardDesc { font-size: 11px; color: var(--color-text-tertiary); }
.extraLinks {
display: flex;
flex-direction: column;
gap: 8px;
.quickLabel {
font-size: 12px;
font-weight: 600;
color: #1A1D28;
}
.linkCard {
/* AI Assistant */
.aiCard {
display: flex;
flex-direction: column;
gap: 18px;
padding: 22px 18px;
margin: 16px -4px 0;
width: calc(100% + 8px);
background: linear-gradient(135deg, #EFF2FF 0%, #F5F7FF 40%, #FDF0F5 100%);
border-radius: 22px;
border: 1px solid rgba(79,110,247,0.08);
box-shadow: 0 2px 16px rgba(79,110,247,0.06);
transition: transform 0.15s, box-shadow 0.15s;
position: relative;
overflow: hidden;
}
.aiCard::before {
content: '';
position: absolute;
width: 140px;
height: 140px;
border-radius: 50%;
background: rgba(79,110,247,0.04);
top: -40px;
right: -40px;
}
.aiCard::after {
content: '';
position: absolute;
width: 80px;
height: 80px;
border-radius: 50%;
background: rgba(240,101,149,0.04);
bottom: -20px;
left: 40px;
}
.aiCard:active { transform: scale(0.985); }
.aiHeader {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: var(--color-white);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
font-size: var(--font-size-base);
font-weight: 600;
-webkit-tap-highlight-color: transparent;
transition: background 0.15s;
position: relative;
z-index: 1;
}
.linkCard:active { background: #FAFBFC; }
.aiAvatar {
width: 50px;
height: 50px;
border-radius: 16px;
background: var(--color-primary-gradient, linear-gradient(135deg, #4F6EF7 0%, #845EF7 100%));
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4px 14px rgba(79,110,247,0.25);
}
.aiTitleBlock { flex: 1; }
.aiTitle {
font-size: 17px;
font-weight: 700;
color: var(--color-text-primary, #1A1D28);
display: block;
}
.aiSubtitle {
font-size: 11px;
color: var(--color-text-tertiary, #9BA0B4);
display: block;
margin-top: 3px;
}
.aiBadge {
background: var(--color-primary-bg, #EDF0FD);
color: var(--color-primary, #4F6EF7);
font-size: 10px;
font-weight: 600;
padding: 4px 12px;
border-radius: 20px;
letter-spacing: 0.5px;
z-index: 1;
}
.aiDivider {
height: 1px;
background: rgba(79,110,247,0.08);
margin: 0 4px;
position: relative;
z-index: 1;
}
.aiQuestionList {
display: flex;
flex-direction: column;
gap: 8px;
position: relative;
z-index: 1;
}
.aiQuestion {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 14px;
background: #fff;
border: 1px solid rgba(79,110,247,0.06);
border-radius: 14px;
font-size: 13px;
color: #3D4A6B;
text-align: left;
transition: all 0.2s;
line-height: 1.45;
box-shadow: 0 1px 4px rgba(0,0,0,0.02);
}
.aiQuestion:hover {
background: #F9FAFF;
border-color: rgba(79,110,247,0.15);
}
.aiDot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--color-primary, #4F6EF7);
flex-shrink: 0;
opacity: 0.5;
margin-top: 1px;
}
.inputHint {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: #fff;
border: 1.5px dashed rgba(79,110,247,0.15);
border-radius: 14px;
font-size: 13px;
color: var(--color-text-tertiary, #9BA0B4);
position: relative;
z-index: 1;
transition: all 0.2s;
}
.inputHint:hover {
border-color: rgba(79,110,247,0.3);
background: #F9FAFF;
}
.inputHintIcon {
width: 20px;
height: 20px;
opacity: 0.5;
flex-shrink: 0;
}

View File

@@ -2,117 +2,34 @@ import { useNavigate } from 'react-router-dom';
import { PageHeader } from '@/components/layout/PageHeader';
import styles from './HealthHubPage.module.css';
const HEALTH_ITEMS = [
{
path: '/health/records?type=blood_pressure',
label: '血压',
desc: '记录和趋势',
svg: (
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#EF4444" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
<polyline points="12 7 12 13 15 15" />
</svg>
),
bg: '#FEE9E9',
},
{
path: '/health/records?type=heart_rate',
label: '心率',
desc: '记录和趋势',
svg: (
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#F59E0B" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
),
bg: '#FFF4E5',
},
{
path: '/health/records?type=blood_sugar',
label: '血糖',
desc: '记录和趋势',
svg: (
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#845EF7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" />
</svg>
),
bg: '#F3E8FF',
},
{
path: '/health/records?type=spo2',
label: '血氧',
desc: '记录和趋势',
svg: (
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#339AF0" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
),
bg: '#E6F0FF',
},
{
path: '/health/records?type=weight',
label: '体重',
desc: '记录和趋势',
svg: (
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#20C997" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 20V10" />
<path d="M18 20V4" />
<path d="M6 20v-4" />
</svg>
),
bg: '#E6F9F2',
},
{
path: '/health/records?type=steps',
label: '步数',
desc: '记录和趋势',
svg: (
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#6366F1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M13 4h3l3 7-4 2v7H9v-7l-4-2 3-7h3" />
<circle cx="12" cy="4" r="2" />
</svg>
),
bg: '#EEF2FF',
},
];
const COMBINED = {
path: '/health/records/add',
label: '健康指标',
desc: '血压 · 心率 · 血糖 · 血氧 · 体重',
indicators: [
{ label: '血压', color: '#EF4444', bg: '#FEE9E9' },
{ label: '心率', color: '#F59E0B', bg: '#FFF4E5' },
{ label: '血糖', color: '#845EF7', bg: '#F3E8FF' },
{ label: '血氧', color: '#339AF0', bg: '#E6F0FF' },
{ label: '体重', color: '#20C997', bg: '#E6F9F2' },
],
};
const QUICK_LINKS = [
{
label: '健康日历',
path: '/health/calendar',
svg: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#F59E0B" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
),
bg: '#FFF0E0',
label: '健康日历', path: '/health/calendar',
color: '#F59E0B', bg: '#FFF8E6',
svg: (<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2" /><line x1="16" y1="2" x2="16" y2="6" /><line x1="8" y1="2" x2="8" y2="6" /><line x1="3" y1="10" x2="21" y2="10" /></svg>),
},
{
label: '服药管理',
path: '/health/medications',
svg: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#D67E0B" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="4" y="5" width="16" height="14" rx="4" />
<path d="M10 9v6M14 9v6M8 12h8" />
</svg>
),
bg: '#FFF4E5',
label: '服药管理', path: '/health/medications',
color: '#D67E0B', bg: '#FFF4E5',
svg: (<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="4" y="5" width="16" height="14" rx="4" /><path d="M10 9v6M14 9v6M8 12h8" /></svg>),
},
{
label: '运动饮食',
path: '/health/exercise-diet',
svg: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#20C997" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" />
</svg>
),
bg: '#E6F9F2',
label: '运动饮食', path: '/health/exercise-diet',
color: '#20C997', bg: '#E6F9F2',
svg: (<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10" /><path d="M12 6v6l4 2" /></svg>),
},
];
@@ -122,25 +39,81 @@ export function HealthHubPage() {
return (
<div className="page">
<PageHeader title="健康中心" showBack={false} />
<div className={styles.grid}>
{HEALTH_ITEMS.map((item) => (
<button key={item.path} className={styles.card} onClick={() => navigate(item.path)}>
<span className={styles.cardIcon} style={{ background: item.bg }}>{item.svg}</span>
<span className={styles.cardTitle}>{item.label}</span>
<span className={styles.cardDesc}>{item.desc}</span>
{/* Combined indicators card */}
<button className={styles.combinedCard} onClick={() => navigate(COMBINED.path)}>
<div className={styles.combinedRow}>
<div className={styles.combinedIcon}>
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
<rect x="2" y="2" width="12" height="12" rx="3" fill="#EEF2FF" stroke="#4F6EF7" strokeWidth="1.5" />
<rect x="18" y="2" width="12" height="12" rx="3" fill="#FEE9E9" stroke="#EF4444" strokeWidth="1.5" />
<rect x="2" y="18" width="12" height="12" rx="3" fill="#FFF4E5" stroke="#F59E0B" strokeWidth="1.5" />
<rect x="18" y="18" width="12" height="12" rx="3" fill="#E6F9F2" stroke="#20C997" strokeWidth="1.5" />
</svg>
</div>
<div className={styles.combinedInfo}>
<span className={styles.combinedTitle}>{COMBINED.label}</span>
<span className={styles.combinedDesc}>{COMBINED.desc}</span>
</div>
<span className={styles.combinedArrow}></span>
</div>
<div className={styles.indicatorTags}>
{COMBINED.indicators.map((ind) => (
<span key={ind.label} className={styles.tag} style={{ background: ind.bg, color: ind.color }}>
{ind.label}
</span>
))}
</div>
</button>
{/* Quick links — horizontal row */}
<div className={styles.quickRow}>
{QUICK_LINKS.map((link) => (
<button key={link.path} className={styles.quickCard} onClick={() => navigate(link.path)}>
<span className={styles.quickIcon} style={{ background: link.bg, color: link.color }}>
{link.svg}
</span>
<span className={styles.quickLabel}>{link.label}</span>
</button>
))}
</div>
<div className={styles.extraLinks}>
{QUICK_LINKS.map((link) => (
<button key={link.path} className={styles.linkCard} onClick={() => navigate(link.path)}>
<span className={styles.cardIcon} style={{ background: link.bg, width: 40, height: 40, borderRadius: 12 }}>
{link.svg}
</span>
{link.label}
{/* AI 健康助手 */}
<div className={styles.aiCard}>
<div className={styles.aiHeader}>
<div className={styles.aiAvatar}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</svg>
</div>
<div className={styles.aiTitleBlock}>
<span className={styles.aiTitle}>AI </span>
<span className={styles.aiSubtitle}> · </span>
</div>
<span className={styles.aiBadge}>Beta</span>
</div>
<div className={styles.aiDivider} />
<div className={styles.aiQuestionList}>
<button className={styles.aiQuestion}>
<span className={styles.aiDot} />
</button>
))}
<button className={styles.aiQuestion}>
<span className={styles.aiDot} />
</button>
</div>
<div className={styles.inputHint}>
<svg className={styles.inputHintIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
AI ...
</div>
</div>
</div>
);

View File

@@ -1,16 +1,152 @@
.form {
.cards {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px 0;
gap: 10px;
}
.bpRow {
display: flex;
gap: 12px;
.card {
background: #fff;
border-radius: 14px;
padding: 12px 14px;
box-shadow: 0 1px 6px rgba(0,0,0,0.04);
}
.row {
.cardHeader {
display: flex;
gap: 12px;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.cardIcon { font-size: 16px; }
.cardTitle {
font-size: 14px;
font-weight: 700;
}
.cardUnit {
font-size: 10px;
color: #9BA0B4;
background: #F5F6F9;
padding: 1px 6px;
border-radius: 5px;
margin-left: auto;
}
.cardInputs {
display: flex;
gap: 6px;
align-items: center;
}
.cardInput {
flex: 1;
min-width: 0;
padding: 9px 10px;
border: 1px solid #E8ECF2;
border-radius: 10px;
font-size: 14px;
background: #FAFBFC;
color: #1A1D28;
outline: none;
}
.cardInput:focus { border-color: #C8CDD5; }
.cardInput::placeholder {
color: #B0B8C1;
font-size: 12px;
}
/* Date chip */
.dateChip {
display: flex;
align-items: center;
gap: 4px;
padding: 0;
border: none;
border-radius: 10px;
flex-shrink: 0;
position: relative;
}
.dateInput {
display: flex;
align-items: center;
gap: 4px;
padding: 7px 8px;
border: 1px solid #E8ECF2;
border-radius: 10px;
background: #FAFBFC;
color: #5A6072;
font-size: 12px;
font-weight: 500;
cursor: pointer;
outline: none;
width: 100%;
transition: border-color 0.15s;
-webkit-appearance: none;
appearance: none;
}
.dateInput::-webkit-calendar-picker-indicator {
opacity: 0;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
cursor: pointer;
}
.dateInput::-webkit-datetime-edit-fields-wrapper {
padding: 0;
}
.dateInput::-webkit-datetime-edit-text {
padding: 0 1px;
}
/* Check button */
.checkBtn {
width: 36px;
height: 36px;
border: none;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
transition: opacity 0.15s;
}
.checkBtn:active { opacity: 0.8; }
.checkBtn:disabled { opacity: 0.4; }
/* Trend card */
.trendCard {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
margin-top: 24px;
padding: 16px;
background: #fff;
border: none;
border-radius: 16px;
box-shadow: 0 1px 8px rgba(0,0,0,0.04);
cursor: pointer;
text-align: left;
transition: transform 0.15s;
}
.trendCard:active { transform: scale(0.985); }
.trendIcon { font-size: 24px; flex-shrink: 0; }
.trendInfo { flex: 1; }
.trendTitle { font-size: 15px; font-weight: 700; color: #1A1D28; display: block; }
.trendSub { font-size: 11px; color: #9BA0B4; margin-top: 2px; display: block; }
.trendArrow { font-size: 22px; color: #C0C5D2; flex-shrink: 0; }

View File

@@ -1,87 +1,135 @@
import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Button } from '@/components/common/Button';
import { Input } from '@/components/common/Input';
import { useNavigate } from 'react-router-dom';
import { PageHeader } from '@/components/layout/PageHeader';
import { ToastContainer, toast } from '@/components/common/Toast';
import * as healthService from '@/services/health.service';
import { MEASUREMENT_TYPES } from '@/utils/constants';
import type { MeasurementType } from '@/types';
import dayjs from 'dayjs';
import styles from './ManualEntryPage.module.css';
const INDICATORS = [
{
type: 'blood_pressure' as const, label: '血压', color: '#DC4A4A',
icon: '🩺', multi: true, fields: [
{ key: 'systolic', placeholder: '收缩压', hint: '120' },
{ key: 'diastolic', placeholder: '舒张压', hint: '80' },
], unit: 'mmHg', chartLabel: '血压',
},
{
type: 'heart_rate' as const, label: '心率', color: '#D68B20',
icon: '💓', fields: [{ key: 'heart_rate', placeholder: '心率', hint: '72' }], unit: 'bpm', chartLabel: '心率',
},
{
type: 'blood_sugar' as const, label: '血糖', color: '#7C5CE7',
icon: '🩸', fields: [{ key: 'blood_sugar', placeholder: '血糖', hint: '5.6' }], unit: 'mmol/L', chartLabel: '血糖',
},
{
type: 'spo2' as const, label: '血氧', color: '#3B8ED4',
icon: '🫁', fields: [{ key: 'spo2', placeholder: '血氧', hint: '98' }], unit: '%', chartLabel: '血氧',
},
{
type: 'weight' as const, label: '体重', color: '#3DAF86',
icon: '⚖️', fields: [{ key: 'weight', placeholder: '体重', hint: '70.5' }], unit: 'kg', chartLabel: '体重',
},
];
export function ManualEntryPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const type = (searchParams.get('type') || 'blood_pressure') as MeasurementType;
const config = MEASUREMENT_TYPES[type];
const [systolic, setSystolic] = useState('');
const [diastolic, setDiastolic] = useState('');
const [value, setValue] = useState('');
const [date, setDate] = useState(dayjs().format('YYYY-MM-DD'));
const [time, setTime] = useState(dayjs().format('HH:mm'));
const [loading, setLoading] = useState(false);
const [values, setValues] = useState<Record<string, string>>({
systolic: '', diastolic: '', heart_rate: '', blood_sugar: '', spo2: '', weight: '',
});
const [dates, setDates] = useState<Record<string, string>>(
Object.fromEntries(INDICATORS.map((i) => [i.type, dayjs().format('YYYY-MM-DD')])),
);
const [loading, setLoading] = useState<string | null>(null);
const handleSubmit = async () => {
const numVal = parseFloat(value);
if (type === 'blood_pressure') {
const sys = parseFloat(systolic);
const dia = parseFloat(diastolic);
if (!sys || !dia) { toast('请填写完整', 'error'); return; }
await healthService.addRecord({
type,
value: { systolic: sys, diastolic: dia },
unit: 'mmHg',
recordedAt: `${date}T${time}:00`,
recordedDate: date,
source: 'manual',
});
} else {
if (!numVal) { toast('请填写数值', 'error'); return; }
await healthService.addRecord({
type,
value: numVal,
unit: config.unit,
recordedAt: `${date}T${time}:00`,
recordedDate: date,
source: 'manual',
});
const setVal = (key: string, v: string) => setValues((prev) => ({ ...prev, [key]: v }));
const saveOne = async (type: string) => {
setLoading(type);
try {
const d = dates[type] || dayjs().format('YYYY-MM-DD');
const recordedAt = `${d}T${dayjs().format('HH:mm:ss')}`;
const ind = INDICATORS.find((i) => i.type === type)!;
if (type === 'blood_pressure') {
const sys = parseFloat(values.systolic);
const dia = parseFloat(values.diastolic);
if (!sys || !dia) { toast('请填写收缩压和舒张压', 'error'); return; }
await healthService.addRecord({ type, value: { systolic: sys, diastolic: dia }, unit: 'mmHg', recordedAt, recordedDate: d, source: 'manual' });
} else {
const v = parseFloat(values[ind.fields[0].key]);
if (!v) { toast('请填写数值', 'error'); return; }
await healthService.addRecord({ type, value: v, unit: ind.unit, recordedAt, recordedDate: d, source: 'manual' });
}
toast(`${ind.label} 已保存`);
} catch {
toast('保存失败', 'error');
} finally {
setLoading(null);
}
toast('记录成功');
setTimeout(() => navigate(-1), 500);
};
return (
<div className="page--no-tab">
<PageHeader title={`新增${config.label}记录`} />
<div className={styles.form}>
{type === 'blood_pressure' ? (
<>
<div className={styles.bpRow}>
<Input label="收缩压 (mmHg)" value={systolic} onChange={(e) => setSystolic(e.target.value)} type="number" />
<Input label="舒张压 (mmHg)" value={diastolic} onChange={(e) => setDiastolic(e.target.value)} type="number" />
<PageHeader title="健康指标" />
<div className={styles.cards}>
{INDICATORS.map((ind) => (
<div key={ind.type} className={styles.card}>
<div className={styles.cardHeader}>
<span className={styles.cardIcon}>{ind.icon}</span>
<span className={styles.cardTitle} style={{ color: ind.color }}>{ind.label}</span>
<span className={styles.cardUnit}>{ind.unit}</span>
</div>
</>
) : (
<Input
label={`${config.label} (${config.unit})`}
value={value}
onChange={(e) => setValue(e.target.value)}
type="number"
step="0.1"
/>
)}
<div className={styles.row}>
<Input label="日期" value={date} onChange={(e) => setDate(e.target.value)} type="date" />
<Input label="时间" value={time} onChange={(e) => setTime(e.target.value)} type="time" />
</div>
<Button variant="primary" size="lg" fullWidth loading={loading} onClick={handleSubmit}>
</Button>
<div className={styles.cardInputs}>
{ind.fields.map((f) => (
<input
key={f.key}
className={styles.cardInput}
type="number"
step={ind.type === 'blood_sugar' || ind.type === 'weight' ? '0.1' : '1'}
placeholder={`${f.placeholder} ${f.hint}`}
value={values[f.key] || ''}
onChange={(e) => setVal(f.key, e.target.value)}
/>
))}
<div className={styles.dateChip}>
<input
className={styles.dateInput}
type="date"
value={dates[ind.type] || ''}
onChange={(e) => setDates((prev) => ({ ...prev, [ind.type]: e.target.value }))}
/>
</div>
<button
className={styles.checkBtn}
style={{ background: ind.color }}
onClick={() => saveOne(ind.type)}
disabled={loading === ind.type}
>
{loading === ind.type ? '…' : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</button>
</div>
</div>
))}
</div>
{/* Trend entry */}
<button className={styles.trendCard} onClick={() => navigate('/health/trends')}>
<span className={styles.trendIcon}>📈</span>
<div className={styles.trendInfo}>
<span className={styles.trendTitle}></span>
<span className={styles.trendSub}> · · · · </span>
</div>
<span className={styles.trendArrow}></span>
</button>
<ToastContainer />
</div>
);

View File

@@ -6,14 +6,46 @@
.periodBtn {
padding: 6px 14px;
border-radius: var(--radius-full);
font-size: var(--font-size-sm);
background: var(--color-bg-secondary);
color: var(--color-text-secondary);
border-radius: 20px;
border: none;
font-size: 13px;
font-weight: 500;
background: #F5F6F9;
color: #6B7280;
cursor: pointer;
transition: all 0.2s;
}
.active {
background: var(--color-primary);
color: var (--color-text-inverse);
background: #4F6EF7;
color: #fff;
}
.toggleBar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.toggleBtn {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: 20px;
border: 1.5px solid;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
background: #fff;
}
.toggleDot {
width: 8px;
height: 8px;
border-radius: 50%;
transition: background 0.2s;
}

View File

@@ -1,14 +1,20 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useEffect, useState, useMemo } from 'react';
import { PageHeader } from '@/components/layout/PageHeader';
import { LineChart } from '@/components/charts/LineChart';
import { MultiLineChart, type SeriesData } from '@/components/charts/MultiLineChart';
import { Empty } from '@/components/common/Empty';
import { MEASUREMENT_TYPES } from '@/utils/constants';
import * as healthService from '@/services/health.service';
import type { HealthRecord, MeasurementType } from '@/types';
import { Button } from '@/components/common/Button';
import type { HealthRecord } from '@/types';
import styles from './TrendChartPage.module.css';
const INDICATORS = [
{ type: 'bp_systolic' as const, label: '收缩压', color: '#DC4A4A', unit: 'mmHg', source: 'blood_pressure' as const, field: 'systolic' as const },
{ type: 'bp_diastolic' as const, label: '舒张压', color: '#E0558A', unit: 'mmHg', source: 'blood_pressure' as const, field: 'diastolic' as const },
{ type: 'heart_rate' as const, label: '心率', color: '#D68B20', unit: 'bpm' },
{ type: 'blood_sugar' as const, label: '血糖', color: '#7C5CE7', unit: 'mmol/L' },
{ type: 'spo2' as const, label: '血氧', color: '#3B8ED4', unit: '%' },
{ type: 'weight' as const, label: '体重', color: '#3DAF86', unit: 'kg' },
];
const PERIODS = [
{ label: '7天', days: 7 },
{ label: '14天', days: 14 },
@@ -17,25 +23,60 @@ const PERIODS = [
];
export function TrendChartPage() {
const { type } = useParams<{ type: MeasurementType }>();
const config = MEASUREMENT_TYPES[type || 'blood_pressure'];
const [records, setRecords] = useState<HealthRecord[]>([]);
const [visible, setVisible] = useState<Set<string>>(new Set(INDICATORS.map((i) => i.type)));
const [period, setPeriod] = useState(30);
const [allRecords, setAllRecords] = useState<HealthRecord[]>([]);
useEffect(() => {
if (type) healthService.getTrendData(type, period).then(setRecords);
}, [type, period]);
const sources = [...new Set(INDICATORS.map((i) => (i as Record<string, string>).source || i.type))];
Promise.all(sources.map((s) => healthService.getTrendData(s as Parameters<typeof healthService.getTrendData>[0], period)))
.then((results) => setAllRecords(results.flat()));
}, [period]);
const isBP = type === 'blood_pressure';
const chartData = records.map((r) => ({
date: r.recordedDate,
value: isBP ? (typeof r.value === 'object' ? r.value.systolic : 0) : (r.value as number),
value2: isBP ? (typeof r.value === 'object' ? r.value.diastolic : 0) : undefined,
}));
const series: SeriesData[] = useMemo(() => {
const result: SeriesData[] = [];
for (const ind of INDICATORS) {
if (!visible.has(ind.type)) continue;
const sourceType = (ind as Record<string, string>).source || ind.type;
const raw = allRecords
.filter((r) => r.type === sourceType)
.sort((a, b) => a.recordedDate.localeCompare(b.recordedDate));
if ('field' in ind && ind.field) {
result.push({
name: ind.label,
color: ind.color,
data: raw.map((r) => ({
date: r.recordedDate,
value: typeof r.value === 'object' ? (r.value as Record<string, number>)[ind.field!] : 0,
})),
unit: ind.unit,
});
} else {
result.push({
name: ind.label,
color: ind.color,
data: raw.map((r) => ({ date: r.recordedDate, value: r.value as number })),
unit: ind.unit,
});
}
}
return result;
}, [allRecords, visible]);
const toggle = (type: string) => {
const next = new Set(visible);
if (next.has(type)) next.delete(type); else next.add(type);
setVisible(next);
};
const hasData = series.some((s) => s.data.length > 0);
return (
<div className="page--no-tab">
<PageHeader title={`${config.label}趋势`} />
<PageHeader title="健康趋势" />
{/* Period selector */}
<div className={styles.periodBar}>
{PERIODS.map((p) => (
<button
@@ -47,18 +88,31 @@ export function TrendChartPage() {
</button>
))}
</div>
{chartData.length > 0 ? (
<LineChart
data={chartData}
seriesName={isBP ? '收缩压' : config.label}
seriesName2={isBP ? '舒张压' : undefined}
unit={config.unit}
markLine={isBP ? 140 : undefined}
markLineLabel={isBP ? '140警戒线' : undefined}
/>
{/* Toggle indicators */}
<div className={styles.toggleBar}>
{INDICATORS.map((ind) => (
<button
key={ind.type}
className={`${styles.toggleBtn} ${visible.has(ind.type) ? styles.toggleOn : styles.toggleOff}`}
style={{
borderColor: ind.color,
...(visible.has(ind.type) ? { background: ind.color, color: '#fff' } : { color: ind.color }),
}}
onClick={() => toggle(ind.type)}
>
<span className={styles.toggleDot} style={{ background: visible.has(ind.type) ? '#fff' : ind.color }} />
{ind.label}
</button>
))}
</div>
{hasData ? (
<MultiLineChart series={series} />
) : (
<Empty message="暂无数据" />
)}
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useLocation } from 'react-router-dom';
import { Card } from '@/components/common/Card';
import { useAuth } from '@/hooks/useAuth';
import { useNotificationStore } from '@/stores/notification.store';
@@ -120,6 +120,7 @@ const QUICK_ACTIONS = [
export function HomePage() {
const navigate = useNavigate();
const location = useLocation();
const { user } = useAuth();
const { unreadCount, fetchNotifications } = useNotificationStore();
const [stats, setStats] = useState<HealthStats[]>([]);
@@ -131,7 +132,7 @@ export function HomePage() {
api.get<MedSummary[]>('/api/medications/today-summary')
.then((r) => setMeds(r.data))
.catch(() => {});
}, [fetchNotifications]);
}, [fetchNotifications, location.pathname]);
const bpStats = stats.find((s) => s.type === 'blood_pressure');
const hrStats = stats.find((s) => s.type === 'heart_rate');

View File

@@ -62,7 +62,7 @@ export function ChatPage() {
// Set up SignalR connection
const conn = new HubConnectionBuilder()
.withUrl('http://localhost:5000/hubs/chat', {
.withUrl('/hubs/chat', {
accessTokenFactory: () => getToken(),
})
.withAutomaticReconnect()

View File

@@ -53,7 +53,7 @@ export function ReportDetailPage() {
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8 }}></div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{report.imageUrls.map((url, i) => (
<img key={i} src={`http://localhost:5000${url}`} alt="report"
<img key={i} src={url} alt="report"
style={{ width: 80, height: 80, borderRadius: 8, objectFit: 'cover', border: '1px solid #eee' }} />
))}
</div>

View File

@@ -38,7 +38,7 @@ export function ReportUploadPage() {
const formData = new FormData();
formData.append('file', file);
const token = JSON.parse(localStorage.getItem('hrt_auth') || '{}')?.state?.token;
const res = await fetch('http://localhost:5000/api/files/upload', {
const res = await fetch('/api/files/upload', {
method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: formData,

View File

@@ -71,7 +71,7 @@ export const router = createBrowserRouter([
{ path: 'home/device-binding', element: <DeviceBindingPage /> },
{ path: 'health/records', element: <HealthRecordListPage /> },
{ path: 'health/records/add', element: <ManualEntryPage /> },
{ path: 'health/trends/:type', element: <TrendChartPage /> },
{ path: 'health/trends', element: <TrendChartPage /> },
{ path: 'health/calendar', element: <HealthCalendarPage /> },
{ path: 'health/medications', element: <MedicationListPage /> },
{ path: 'health/medications/add', element: <MedicationEditPage /> },

View File

@@ -1,15 +1,11 @@
/**
* Real HTTP API client — replaces mockApiResponse with actual fetch calls.
* Backend base: http://localhost:5000
*/
// API client — uses Vite proxy in dev, same-origin in production.
interface ApiResponse<T> {
code: number;
data: T;
message: string;
}
const BASE_URL = 'http://localhost:5000';
const BASE_URL = '';
// Endpoints that should NEVER include auth token
const PUBLIC_ENDPOINTS = ['/api/auth/login', '/api/auth/register', '/api/auth/send-sms', '/api/auth/refresh'];

View File

@@ -93,6 +93,14 @@ export async function addDietLog(data: Omit<DietRecord, 'id' | 'userId'>): Promi
};
}
export async function deleteExerciseLog(id: string): Promise<void> {
await api.del(`/api/health-records/${id}`);
}
export async function deleteDietLog(id: string): Promise<void> {
await api.del(`/api/health-records/${id}`);
}
export function getExerciseRecommendations() {
return EXERCISE_RECOMMENDATIONS;
}

View File

@@ -12,5 +12,9 @@ export default defineConfig({
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': 'http://localhost:5000',
'/hubs': { target: 'http://localhost:5000', ws: true },
},
},
})