重构健康中心页面:合并指标入口、独立记录页面、多指标趋势图
- 健康中心合并血压/心率/血糖/血氧/体重为统一入口卡片 - 记录页面每个指标独立日期+保存,紧凑设计 - 趋势图支持多指标切换显示,同时显示收缩压和舒张压 - 首页健康概览修复返回页面后数据不更新的问题 - Vite 添加代理,支持手机通过局域网 IP 访问 - 医生端患者详情新增健康趋势图及指标切换 - 运动饮食页面支持删除记录 - 修复复查完成后患者端消失的问题
This commit is contained in:
63
frontend-patient/src/components/charts/MultiLineChart.tsx
Normal file
63
frontend-patient/src/components/charts/MultiLineChart.tsx
Normal 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 />;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user