重构健康中心页面:合并指标入口、独立记录页面、多指标趋势图
- 健康中心合并血压/心率/血糖/血氧/体重为统一入口卡片 - 记录页面每个指标独立日期+保存,紧凑设计 - 趋势图支持多指标切换显示,同时显示收缩压和舒张压 - 首页健康概览修复返回页面后数据不更新的问题 - Vite 添加代理,支持手机通过局域网 IP 访问 - 医生端患者详情新增健康趋势图及指标切换 - 运动饮食页面支持删除记录 - 修复复查完成后患者端消失的问题
This commit is contained in:
@@ -43,6 +43,15 @@ public class HealthService(AppDbContext db)
|
|||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteAsync(Guid id, Guid userId)
|
||||||
|
{
|
||||||
|
var record = await db.HealthRecords.FirstOrDefaultAsync(hr => hr.Id == id && hr.UserId == userId);
|
||||||
|
if (record == null) return false;
|
||||||
|
db.HealthRecords.Remove(record);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Dictionary<string, object>> GetStatsAsync(Guid userId)
|
public async Task<Dictionary<string, object>> GetStatsAsync(Guid userId)
|
||||||
{
|
{
|
||||||
var types = new[] { "blood_pressure", "heart_rate", "blood_sugar", "spo2", "weight", "steps" };
|
var types = new[] { "blood_pressure", "heart_rate", "blood_sugar", "spo2", "weight", "steps" };
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ public class HealthController(HealthService healthService) : ControllerBase
|
|||||||
var record = await healthService.AddRecordAsync(UserId, request.Type, request.ValueJson, request.Unit, request.RecordedAt, request.Notes);
|
var record = await healthService.AddRecordAsync(UserId, request.Type, request.ValueJson, request.Unit, request.RecordedAt, request.Notes);
|
||||||
return Ok(new { record.Id, record.Type, Value = record.Value.RootElement.GetRawText(), record.Unit, record.RecordedAt, record.Source });
|
return Ok(new { record.Id, record.Type, Value = record.Value.RootElement.GetRawText(), record.Unit, record.RecordedAt, record.Source });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}")]
|
||||||
|
public async Task<IActionResult> DeleteRecord(Guid id)
|
||||||
|
{
|
||||||
|
var ok = await healthService.DeleteAsync(id, UserId);
|
||||||
|
if (!ok) return NotFound(new { message = "记录不存在" });
|
||||||
|
return Ok(new { message = "删除成功" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public record HealthRecordCreateRequest(string Type, string ValueJson, string Unit, DateTime RecordedAt, string? Notes);
|
public record HealthRecordCreateRequest(string Type, string ValueJson, string Unit, DateTime RecordedAt, string? Notes);
|
||||||
|
|||||||
63
frontend-doctor/src/components/charts/MultiLineChart.tsx
Normal file
63
frontend-doctor/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,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { api } from '../../services/api-client';
|
import { api } from '../../services/api-client';
|
||||||
|
import { MultiLineChart, type SeriesData } from '../../components/charts/MultiLineChart';
|
||||||
|
|
||||||
interface PatientDetail {
|
interface PatientDetail {
|
||||||
id: string; name: string; phone: string; gender: string; birthday: string;
|
id: string; name: string; phone: string; gender: string; birthday: string;
|
||||||
@@ -12,6 +13,15 @@ interface HealthRecord {
|
|||||||
id: string; type: string; value: string; unit: string; recordedAt: string;
|
id: string; type: string; value: string; unit: string; recordedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ExerciseEntry {
|
||||||
|
type: string; duration: number; intensity: string; caloriesBurned: number; date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DietEntry {
|
||||||
|
foods: { name: string; amount?: string; calories?: number }[];
|
||||||
|
mealType: string; totalCalories: number; date: string;
|
||||||
|
}
|
||||||
|
|
||||||
const typeLabels: Record<string, string> = {
|
const typeLabels: Record<string, string> = {
|
||||||
blood_pressure: '血压', heart_rate: '心率', blood_sugar: '血糖', spo2: '血氧',
|
blood_pressure: '血压', heart_rate: '心率', blood_sugar: '血糖', spo2: '血氧',
|
||||||
};
|
};
|
||||||
@@ -24,17 +34,52 @@ const typeBgs: Record<string, string> = {
|
|||||||
blood_pressure: '#FEE9E9', heart_rate: '#FFF8E6', blood_sugar: '#EDF0FD', spo2: '#E6F9F2',
|
blood_pressure: '#FEE9E9', heart_rate: '#FFF8E6', blood_sugar: '#EDF0FD', spo2: '#E6F9F2',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const exerciseIcons: Record<string, string> = {
|
||||||
|
'散步': '🚶', '慢跑': '🏃', '太极拳': '🤸', '游泳': '🏊', '骑自行车': '🚴', '八段锦': '🧘',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mealLabels: Record<string, string> = {
|
||||||
|
breakfast: '早餐', lunch: '午餐', dinner: '晚餐', snack: '加餐',
|
||||||
|
};
|
||||||
|
const mealIcons: Record<string, string> = {
|
||||||
|
breakfast: '🌅', lunch: '☀️', dinner: '🌙', snack: '🍎',
|
||||||
|
};
|
||||||
|
|
||||||
export function PatientDetailPage() {
|
export function PatientDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const [patient, setPatient] = useState<PatientDetail | null>(null);
|
const [patient, setPatient] = useState<PatientDetail | null>(null);
|
||||||
const [records, setRecords] = useState<HealthRecord[]>([]);
|
const [records, setRecords] = useState<HealthRecord[]>([]);
|
||||||
|
const [exercises, setExercises] = useState<ExerciseEntry[]>([]);
|
||||||
|
const [diets, setDiets] = useState<DietEntry[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
api.get<PatientDetail>(`/api/patients/${id}`).then((r) => {
|
api.get<PatientDetail>(`/api/patients/${id}`).then((r) => {
|
||||||
if (r.data) setPatient(r.data);
|
if (r.data) setPatient(r.data);
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
api.get<HealthRecord[]>(`/api/health-records?patientId=${id}&days=30`).then((r) => setRecords(r.data));
|
api.get<HealthRecord[]>(`/api/health-records?patientId=${id}&days=30`).then((r) => {
|
||||||
|
const all = r.data || [];
|
||||||
|
setRecords(all.filter((x) => ['blood_pressure', 'heart_rate', 'blood_sugar', 'spo2'].includes(x.type)));
|
||||||
|
|
||||||
|
// Parse exercise records
|
||||||
|
const exList: ExerciseEntry[] = [];
|
||||||
|
const dietList: DietEntry[] = [];
|
||||||
|
all.filter((x) => x.type === 'exercise' || x.type === 'diet').forEach((r) => {
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(r.value);
|
||||||
|
const date = r.recordedAt?.split('T')[0] || '';
|
||||||
|
if (r.type === 'exercise') {
|
||||||
|
exList.push({ type: v.type, duration: v.duration, intensity: v.intensity, caloriesBurned: v.caloriesBurned || v.calories, date });
|
||||||
|
} else {
|
||||||
|
dietList.push({ mealType: v.mealType || v.meal, foods: v.foods || [], totalCalories: v.totalCalories, date });
|
||||||
|
}
|
||||||
|
} catch { /* skip */ }
|
||||||
|
});
|
||||||
|
exList.sort((a, b) => b.date.localeCompare(a.date));
|
||||||
|
dietList.sort((a, b) => b.date.localeCompare(a.date));
|
||||||
|
setExercises(exList.slice(0, 10));
|
||||||
|
setDiets(dietList.slice(0, 10));
|
||||||
|
}).catch(() => {});
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
if (!patient) return <div style={{ padding: 28, color: '#9BA0B4' }}>加载中...</div>;
|
if (!patient) return <div style={{ padding: 28, color: '#9BA0B4' }}>加载中...</div>;
|
||||||
@@ -55,9 +100,10 @@ export function PatientDetailPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 28 }}>
|
<div style={{ padding: 28, maxWidth: 1100 }}>
|
||||||
<Link to="/patients" style={{ fontSize: 13, color: '#4F6EF7', fontWeight: 500 }}>← 返回患者列表</Link>
|
<Link to="/patients" style={{ fontSize: 13, color: '#4F6EF7', fontWeight: 500 }}>← 返回患者列表</Link>
|
||||||
|
|
||||||
|
{/* Patient info card */}
|
||||||
<div style={{ background: '#fff', marginTop: 16, padding: 28, borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
|
<div style={{ background: '#fff', marginTop: 16, padding: 28, borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 20 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 20 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -73,40 +119,19 @@ export function PatientDetailPage() {
|
|||||||
<p style={{ margin: '4px 0 0', fontSize: 12, color: '#9BA0B4' }}>{patient.phone}</p>
|
<p style={{ margin: '4px 0 0', fontSize: 12, color: '#9BA0B4' }}>{patient.phone}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px 32px', fontSize: 13 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px 32px', fontSize: 13 }}>
|
||||||
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}>
|
<InfoRow label="手机号" value={patient.phone} />
|
||||||
<span style={{ color: '#9BA0B4' }}>手机号</span>
|
<InfoRow label="性别" value={patient.gender || '-'} />
|
||||||
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{patient.phone}</span>
|
<InfoRow label="出生日期" value={patient.birthday || '-'} />
|
||||||
</div>
|
<InfoRow label="身高/体重" value={`${patient.heightCm}cm / ${patient.weightKg}kg`} />
|
||||||
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}>
|
<InfoRow label="病史" value={(patient.medicalHistory || []).join('、') || '-'} />
|
||||||
<span style={{ color: '#9BA0B4' }}>性别</span>
|
<InfoRow label="支架日期" value={patient.stentDate || '-'} />
|
||||||
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{patient.gender || '-'}</span>
|
<InfoRow label="支架类型" value={patient.stentType || '-'} />
|
||||||
</div>
|
|
||||||
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}>
|
|
||||||
<span style={{ color: '#9BA0B4' }}>出生日期</span>
|
|
||||||
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{patient.birthday || '-'}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}>
|
|
||||||
<span style={{ color: '#9BA0B4' }}>身高/体重</span>
|
|
||||||
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{patient.heightCm}cm / {patient.weightKg}kg</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}>
|
|
||||||
<span style={{ color: '#9BA0B4' }}>病史</span>
|
|
||||||
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{(patient.medicalHistory || []).join('、') || '-'}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}>
|
|
||||||
<span style={{ color: '#9BA0B4' }}>支架日期</span>
|
|
||||||
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{patient.stentDate || '-'}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}>
|
|
||||||
<span style={{ color: '#9BA0B4' }}>支架类型</span>
|
|
||||||
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{patient.stentType || '-'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 style={{ marginTop: 28, marginBottom: 14, fontSize: 17, fontWeight: 700, color: '#1A1D28' }}>最近健康数据</h3>
|
{/* Health vitals */}
|
||||||
|
<h3 style={{ marginTop: 28, marginBottom: 14, fontSize: 17, fontWeight: 700, color: '#1A1D28' }}>生命体征</h3>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 14 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 14 }}>
|
||||||
{Object.entries(latestByType).map(([type, record]) => (
|
{Object.entries(latestByType).map(([type, record]) => (
|
||||||
<div key={type} style={{
|
<div key={type} style={{
|
||||||
@@ -132,6 +157,152 @@ export function PatientDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Trend chart */}
|
||||||
|
<ChartSection records={records} />
|
||||||
|
|
||||||
|
{/* Exercise + Diet side by side */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20, marginTop: 28 }}>
|
||||||
|
{/* Exercise */}
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 14px', fontSize: 17, fontWeight: 700, color: '#1A1D28' }}>
|
||||||
|
🏃 运动记录
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 500, color: '#9BA0B4', marginLeft: 8 }}>
|
||||||
|
近7天 · {exercises.reduce((s, e) => s + (e.duration || 0), 0)}分钟
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
{exercises.length === 0 ? (
|
||||||
|
<div style={{ padding: 40, textAlign: 'center', color: '#C0C5D2', background: '#fff', borderRadius: 14, fontSize: 13 }}>暂无运动记录</div>
|
||||||
|
) : (
|
||||||
|
exercises.slice(0, 7).map((e, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px',
|
||||||
|
background: '#fff', borderRadius: 12, marginBottom: 8,
|
||||||
|
boxShadow: '0 1px 6px rgba(0,0,0,0.03)',
|
||||||
|
borderLeft: `4px solid ${e.intensity === '高' || e.intensity === 'high' ? '#EF4444' : e.intensity === '中' || e.intensity === 'moderate' ? '#F59E0B' : '#20C997'}`,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 22 }}>{exerciseIcons[e.type] || '💪'}</span>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, color: '#1A1D28' }}>{e.type}</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#9BA0B4' }}>{e.duration}分钟 · {e.caloriesBurned}kcal</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#C0C5D2' }}>{e.date?.slice(5)}</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Diet */}
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 14px', fontSize: 17, fontWeight: 700, color: '#1A1D28' }}>
|
||||||
|
🥗 饮食记录
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 500, color: '#9BA0B4', marginLeft: 8 }}>
|
||||||
|
近7天 · {diets.reduce((s, d) => s + (d.totalCalories || 0), 0)}kcal
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
{diets.length === 0 ? (
|
||||||
|
<div style={{ padding: 40, textAlign: 'center', color: '#C0C5D2', background: '#fff', borderRadius: 14, fontSize: 13 }}>暂无饮食记录</div>
|
||||||
|
) : (
|
||||||
|
diets.slice(0, 7).map((d, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px',
|
||||||
|
background: '#fff', borderRadius: 12, marginBottom: 8,
|
||||||
|
boxShadow: '0 1px 6px rgba(0,0,0,0.03)',
|
||||||
|
borderLeft: '4px solid #20C997',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 22 }}>{mealIcons[d.mealType] || '🍽️'}</span>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, color: '#1A1D28' }}>
|
||||||
|
{d.foods?.map(f => f.name).join('、') || '-'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#9BA0B4' }}>
|
||||||
|
{mealLabels[d.mealType] || d.mealType} · {d.totalCalories}kcal
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#C0C5D2' }}>{d.date?.slice(5)}</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHART_INDICATORS = [
|
||||||
|
{ type: 'bp_sys', label: '收缩压', color: '#DC4A4A', unit: 'mmHg', source: 'blood_pressure', field: 'systolic' as const },
|
||||||
|
{ type: 'bp_dia', label: '舒张压', color: '#E0558A', unit: 'mmHg', source: 'blood_pressure', field: 'diastolic' as const },
|
||||||
|
{ type: 'heart_rate', label: '心率', color: '#D68B20', unit: 'bpm' },
|
||||||
|
{ type: 'blood_sugar', label: '血糖', color: '#7C5CE7', unit: 'mmol/L' },
|
||||||
|
{ type: 'spo2', label: '血氧', color: '#3B8ED4', unit: '%' },
|
||||||
|
{ type: 'weight', label: '体重', color: '#3DAF86', unit: 'kg' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function ChartSection({ records }: { records: HealthRecord[] }) {
|
||||||
|
const [visible, setVisible] = useState<Set<string>>(new Set(CHART_INDICATORS.map((i) => i.type)));
|
||||||
|
|
||||||
|
const series: SeriesData[] = useMemo(() => {
|
||||||
|
return CHART_INDICATORS
|
||||||
|
.filter((ind) => visible.has(ind.type))
|
||||||
|
.map((ind) => {
|
||||||
|
const source = (ind as Record<string, string>).source || ind.type;
|
||||||
|
const field = (ind as Record<string, string>).field;
|
||||||
|
const raw = records
|
||||||
|
.filter((r) => r.type === source)
|
||||||
|
.sort((a, b) => a.recordedAt.localeCompare(b.recordedAt));
|
||||||
|
|
||||||
|
const data = raw.map((r) => {
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(r.value);
|
||||||
|
const val = field ? (v[field] ?? 0) : (v.value ?? v);
|
||||||
|
return { date: r.recordedAt.split('T')[0], value: Number(val) || 0 };
|
||||||
|
} catch { return { date: r.recordedAt.split('T')[0], value: 0 }; }
|
||||||
|
});
|
||||||
|
return { name: ind.label, color: ind.color, data, unit: ind.unit };
|
||||||
|
});
|
||||||
|
}, [records, 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);
|
||||||
|
if (!hasData) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 28 }}>
|
||||||
|
<h3 style={{ margin: '0 0 10px', fontSize: 17, fontWeight: 700, color: '#1A1D28' }}>📈 健康趋势</h3>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
|
||||||
|
{CHART_INDICATORS.map((ind) => (
|
||||||
|
<button
|
||||||
|
key={ind.type}
|
||||||
|
onClick={() => toggle(ind.type)}
|
||||||
|
style={{
|
||||||
|
padding: '5px 12px', borderRadius: 16, border: '1.5px solid',
|
||||||
|
borderColor: ind.color, fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
||||||
|
...(visible.has(ind.type)
|
||||||
|
? { background: ind.color, color: '#fff' }
|
||||||
|
: { background: '#fff', color: ind.color }),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ind.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ background: '#fff', padding: 16, borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
|
||||||
|
<MultiLineChart series={series} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}>
|
||||||
|
<span style={{ color: '#9BA0B4' }}>{label}</span>
|
||||||
|
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{value}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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; }
|
/* Tabs */
|
||||||
.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; }
|
.tabs {
|
||||||
.tabActive { background: var(--color-primary); color: var(--color-text-inverse); }
|
display: flex;
|
||||||
.sectionTitle { font-size: var(--font-size-base); font-weight: 700; margin: 16px 0 8px; }
|
gap: 8px;
|
||||||
.recCard { margin-bottom: 8px; }
|
margin-bottom: 16px;
|
||||||
.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); }
|
.tab {
|
||||||
.suitNo { background: var(--color-danger-bg); color: var(--color-danger); }
|
flex: 1;
|
||||||
.notSuitable { opacity: 0.5; }
|
padding: 12px;
|
||||||
.recMeta { font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
|
border-radius: 14px;
|
||||||
.recDesc { font-size: var(--font-size-xs); color: var(--color-text-secondary); margin: 6px 0; line-height: 1.5; }
|
background: #F5F6F9;
|
||||||
.foodTags { display: flex; gap: 6px; flex-wrap: wrap; }
|
border: none;
|
||||||
.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; }
|
font-size: 15px;
|
||||||
.addCard { margin-bottom: 12px; display: flex; flex-direction: column; gap: 10px; }
|
font-weight: 600;
|
||||||
.addRow { display: flex; gap: 8px; align-items: center; }
|
color: #6B7280;
|
||||||
.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; }
|
cursor: pointer;
|
||||||
.intensityRow { display: flex; gap: 8px; }
|
transition: all 0.25s ease;
|
||||||
.intensityBtn { flex: 1; padding: 6px; font-size: var(--font-size-xs); background: var(--color-bg); border-radius: var(--radius-md); }
|
display: flex;
|
||||||
.intensityActive { background: var(--color-primary-bg); color: var(--color-primary); }
|
align-items: center;
|
||||||
.logCard { margin-bottom: 6px; }
|
justify-content: center;
|
||||||
.logDate { font-size: var(--font-size-xs); color: var(--color-text-tertiary); margin-top: 2px; }
|
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 { PageHeader } from '@/components/layout/PageHeader';
|
||||||
import { Card } from '@/components/common/Card';
|
import { Card } from '@/components/common/Card';
|
||||||
import { Button } from '@/components/common/Button';
|
import { Button } from '@/components/common/Button';
|
||||||
@@ -10,144 +10,280 @@ import type { ExerciseRecord, DietRecord } from '@/types';
|
|||||||
import { formatDate } from '@/utils/format';
|
import { formatDate } from '@/utils/format';
|
||||||
import styles from './ExerciseDietPage.module.css';
|
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() {
|
export function ExerciseDietPage() {
|
||||||
const [subTab, setSubTab] = useState<'recommend' | 'exercise' | 'diet'>('recommend');
|
const [tab, setTab] = useState<'exercise' | 'diet'>('exercise');
|
||||||
const [exercises, setExercises] = useState<ExerciseRecord[]>([]);
|
const [exercises, setExercises] = useState<ExerciseRecord[]>([]);
|
||||||
const [diets, setDiets] = useState<DietRecord[]>([]);
|
const [diets, setDiets] = useState<DietRecord[]>([]);
|
||||||
|
|
||||||
|
// exercise form
|
||||||
const [exType, setExType] = useState('散步');
|
const [exType, setExType] = useState('散步');
|
||||||
const [exDuration, setExDuration] = useState('30');
|
const [exDuration, setExDuration] = useState('30');
|
||||||
const [exIntensity, setExIntensity] = useState<'low' | 'moderate' | 'high'>('low');
|
const [exIntensity, setExIntensity] = useState<'low' | 'moderate' | 'high'>('low');
|
||||||
|
|
||||||
|
// diet form
|
||||||
const [mealType, setMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast');
|
const [mealType, setMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast');
|
||||||
const [foodName, setFoodName] = useState('');
|
const [foodName, setFoodName] = useState('');
|
||||||
|
const [foodAmount, setFoodAmount] = useState('1份');
|
||||||
const [foodKcal, setFoodKcal] = useState('');
|
const [foodKcal, setFoodKcal] = useState('');
|
||||||
|
|
||||||
const recommendations = exerciseDietService.getExerciseRecommendations();
|
const exRecommendations = exerciseDietService.getExerciseRecommendations();
|
||||||
const dietRecommendations = exerciseDietService.getDietRecommendations();
|
const dietRecommendations = exerciseDietService.getDietRecommendations();
|
||||||
|
|
||||||
|
const groupedExercises = useMemo(() => groupByDate(exercises).slice(0, 7), [exercises]);
|
||||||
|
const groupedDiets = useMemo(() => groupByDate(diets).slice(0, 7), [diets]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
exerciseDietService.getExerciseLogs().then(setExercises);
|
exerciseDietService.getExerciseLogs().then(setExercises);
|
||||||
exerciseDietService.getDietLogs().then(setDiets);
|
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 () => {
|
const addExercise = async () => {
|
||||||
if (!exDuration) return;
|
if (!exDuration) return;
|
||||||
await exerciseDietService.addExerciseLog({
|
await exerciseDietService.addExerciseLog({
|
||||||
type: exType, duration: parseInt(exDuration), intensity: exIntensity,
|
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);
|
exerciseDietService.getExerciseLogs().then(setExercises);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addDiet = async () => {
|
const addDiet = async () => {
|
||||||
if (!foodName || !foodKcal) { toast('请填写食物信息', 'error'); return; }
|
if (!foodName || !foodKcal) { toast('请填写食物名称和热量', 'error'); return; }
|
||||||
await exerciseDietService.addDietLog({
|
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),
|
totalCalories: parseInt(foodKcal), date: new Date().toISOString().slice(0, 10),
|
||||||
});
|
});
|
||||||
toast('记录成功');
|
setFoodName(''); setFoodAmount('1份'); setFoodKcal('');
|
||||||
|
toast('饮食记录成功');
|
||||||
exerciseDietService.getDietLogs().then(setDiets);
|
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 (
|
return (
|
||||||
<div className="page--no-tab">
|
<div className="page--no-tab">
|
||||||
<PageHeader title="运动饮食" />
|
<PageHeader title="运动饮食" />
|
||||||
|
|
||||||
|
{/* Tab switch */}
|
||||||
<div className={styles.tabs}>
|
<div className={styles.tabs}>
|
||||||
{[
|
<button className={`${styles.tab} ${tab === 'exercise' ? styles.tabActive : ''}`} onClick={() => setTab('exercise')}>
|
||||||
{ key: 'recommend', label: '推荐' },
|
<span className={styles.tabIcon}>🏃</span> 运动
|
||||||
{ key: 'exercise', label: '运动' },
|
</button>
|
||||||
{ key: 'diet', label: '饮食' },
|
<button className={`${styles.tab} ${tab === 'diet' ? styles.tabActive : ''}`} onClick={() => setTab('diet')}>
|
||||||
].map((t) => (
|
<span className={styles.tabIcon}>🥗</span> 饮食
|
||||||
<button key={t.key} className={`${styles.tab} ${subTab === t.key ? styles.tabActive : ''}`} onClick={() => setSubTab(t.key as typeof subTab)}>
|
</button>
|
||||||
{t.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{subTab === 'recommend' && (
|
{/* ============ EXERCISE TAB ============ */}
|
||||||
<div>
|
{tab === 'exercise' && (
|
||||||
<h3 className={styles.sectionTitle}>运动推荐</h3>
|
<>
|
||||||
{recommendations.map((r, i) => (
|
{/* Today summary card */}
|
||||||
<Card key={i} className={`${styles.recCard} ${!r.suitable ? styles.notSuitable : ''}`}>
|
<div className={styles.summaryCard}>
|
||||||
<div className={styles.recHeader}>
|
<svg className={styles.summaryBg} viewBox="0 0 400 120" preserveAspectRatio="none">
|
||||||
<span>{r.name}</span>
|
<ellipse cx="350" cy="10" rx="180" ry="140" fill="rgba(79,110,247,0.06)" />
|
||||||
<span className={`${styles.suitBadge} ${r.suitable ? styles.suitYes : styles.suitNo}`}>
|
<ellipse cx="50" cy="100" rx="120" ry="60" fill="rgba(79,110,247,0.04)" />
|
||||||
{r.suitable ? '适合' : '不适合'}
|
</svg>
|
||||||
</span>
|
<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>
|
||||||
<div className={styles.recMeta}>{r.duration} · {r.frequency} · {r.intensity}强度</div>
|
<div className={styles.summaryRight}>
|
||||||
</Card>
|
<svg width="64" height="64" viewBox="0 0 64 64" fill="none">
|
||||||
))}
|
<circle cx="32" cy="32" r="28" stroke="#E8ECF4" strokeWidth="6" />
|
||||||
<h3 className={styles.sectionTitle}>饮食推荐</h3>
|
<circle cx="32" cy="32" r="28" stroke="url(#exGrad)" strokeWidth="6" strokeLinecap="round"
|
||||||
{dietRecommendations.slice(0, 3).map((d, i) => (
|
strokeDasharray={`${Math.min(todayExKcal / 3, 175)} 176`} transform="rotate(-90 32 32)" />
|
||||||
<Card key={i} className={styles.recCard}>
|
<defs><linearGradient id="exGrad"><stop stopColor="#4F6EF7"/><stop offset="1" stopColor="#6C8AFF"/></linearGradient></defs>
|
||||||
<div className={styles.recHeader}><span>{d.title}</span></div>
|
</svg>
|
||||||
<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>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{subTab === 'exercise' && (
|
{/* Add record */}
|
||||||
<div>
|
|
||||||
<Card className={styles.addCard}>
|
<Card className={styles.addCard}>
|
||||||
<div className={styles.addRow}>
|
<div className={styles.addGrid}>
|
||||||
<select className={styles.select} value={exType} onChange={(e) => setExType(e.target.value)}>
|
<select className={styles.select} value={exType} onChange={(e) => setExType(e.target.value)}>
|
||||||
{['散步', '慢跑', '太极拳', '游泳', '骑自行车', '八段锦'].map((t) => (
|
{EXERCISE_TYPES.map((t) => (<option key={t}>{t}</option>))}
|
||||||
<option key={t}>{t}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
<Input value={exDuration} onChange={(e) => setExDuration(e.target.value)} type="number" placeholder="分钟" />
|
<Input value={exDuration} onChange={(e) => setExDuration(e.target.value)} type="number" placeholder="分钟" />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.intensityRow}>
|
<div className={styles.intensityRow}>
|
||||||
{['low', 'moderate', 'high'].map((i) => (
|
{INTENSITIES.map((i) => (
|
||||||
<button key={i} className={`${styles.intensityBtn} ${exIntensity === i ? styles.intensityActive : ''}`} onClick={() => setExIntensity(i as typeof exIntensity)}>
|
<button key={i.key} className={`${styles.intensityBtn} ${exIntensity === i.key ? styles.intensityActive : ''}`}
|
||||||
{{ low: '低强度', moderate: '中强度', high: '高强度' }[i]}
|
onClick={() => setExIntensity(i.key)}>
|
||||||
|
{i.emoji} {i.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={addExercise}>记录运动</Button>
|
<Button size="sm" onClick={addExercise}>记录运动</Button>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{exercises.length === 0 ? <Empty message="暂无运动记录" /> : exercises.slice(0, 10).map((e) => (
|
{/* Recent records */}
|
||||||
<Card key={e.id} className={styles.logCard}>
|
{groupedExercises.length === 0 ? (
|
||||||
<div>{e.type} · {e.duration}分钟 · {e.caloriesBurned}kcal</div>
|
<Empty message="暂无运动记录,开始记录吧" />
|
||||||
<div className={styles.logDate}>{formatDate(e.date, 'MM-DD')}</div>
|
) : (
|
||||||
</Card>
|
groupedExercises.map(([date, items]) => (
|
||||||
))}
|
<div key={date} className={styles.dayGroup}>
|
||||||
</div>
|
<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' && (
|
{/* ============ DIET TAB ============ */}
|
||||||
<div>
|
{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}>
|
<Card className={styles.addCard}>
|
||||||
<div className={styles.addRow}>
|
<div className={styles.mealTabRow}>
|
||||||
<select className={styles.select} value={mealType} onChange={(e) => setMealType(e.target.value as typeof mealType)}>
|
{MEAL_TYPES.map((m) => (
|
||||||
<option value="breakfast">早餐</option>
|
<button key={m.key} className={`${styles.mealTab} ${mealType === m.key ? styles.mealTabActive : ''}`}
|
||||||
<option value="lunch">午餐</option>
|
onClick={() => setMealType(m.key)}>
|
||||||
<option value="dinner">晚餐</option>
|
{m.icon} {m.label}
|
||||||
<option value="snack">加餐</option>
|
</button>
|
||||||
</select>
|
))}
|
||||||
<Input value={foodName} onChange={(e) => setFoodName(e.target.value)} placeholder="食物名" />
|
</div>
|
||||||
<Input value={foodKcal} onChange={(e) => setFoodKcal(e.target.value)} type="number" placeholder="kcal" />
|
<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>
|
</div>
|
||||||
<Button size="sm" onClick={addDiet}>记录饮食</Button>
|
<Button size="sm" onClick={addDiet}>记录饮食</Button>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{diets.length === 0 ? <Empty message="暂无饮食记录" /> : diets.slice(0, 10).map((d) => (
|
{/* Recent records */}
|
||||||
<Card key={d.id} className={styles.logCard}>
|
{groupedDiets.length === 0 ? (
|
||||||
<div>{d.foods.map((f) => f.name).join(', ')}</div>
|
<Empty message="暂无饮食记录,开始记录吧" />
|
||||||
<div className={styles.logDate}>{d.totalCalories}kcal · {formatDate(d.date, 'MM-DD')}</div>
|
) : (
|
||||||
</Card>
|
groupedDiets.map(([date, items]) => (
|
||||||
))}
|
<div key={date} className={styles.dayGroup}>
|
||||||
</div>
|
<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 />
|
<ToastContainer />
|
||||||
|
|||||||
@@ -1,57 +1,251 @@
|
|||||||
.grid {
|
/* Combined card */
|
||||||
display: grid;
|
.combinedCard {
|
||||||
grid-template-columns: repeat(3, 1fr);
|
display: flex;
|
||||||
gap: 12px;
|
flex-direction: column;
|
||||||
margin-bottom: 16px;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 18px 12px;
|
padding: 14px 8px;
|
||||||
background: var(--color-white);
|
background: #fff;
|
||||||
border-radius: var(--radius-lg);
|
border: none;
|
||||||
box-shadow: var(--shadow-sm);
|
border-radius: 14px;
|
||||||
transition: transform 0.2s;
|
box-shadow: 0 1px 8px rgba(0,0,0,0.04);
|
||||||
-webkit-tap-highlight-color: transparent;
|
cursor: pointer;
|
||||||
position: relative;
|
transition: transform 0.15s, box-shadow 0.15s;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card:active { transform: scale(0.95); }
|
.quickCard:active { transform: scale(0.95); }
|
||||||
|
|
||||||
.cardIcon {
|
.quickIcon {
|
||||||
width: 50px;
|
width: 42px;
|
||||||
height: 50px;
|
height: 42px;
|
||||||
border-radius: 16px;
|
border-radius: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardTitle { font-size: var(--font-size-base); font-weight: 700; color: var(--color-text-primary); }
|
.quickLabel {
|
||||||
.cardDesc { font-size: 11px; color: var(--color-text-tertiary); }
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
.extraLinks {
|
color: #1A1D28;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 14px 16px;
|
position: relative;
|
||||||
background: var(--color-white);
|
z-index: 1;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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 { PageHeader } from '@/components/layout/PageHeader';
|
||||||
import styles from './HealthHubPage.module.css';
|
import styles from './HealthHubPage.module.css';
|
||||||
|
|
||||||
const HEALTH_ITEMS = [
|
const COMBINED = {
|
||||||
{
|
path: '/health/records/add',
|
||||||
path: '/health/records?type=blood_pressure',
|
label: '健康指标',
|
||||||
label: '血压',
|
desc: '血压 · 心率 · 血糖 · 血氧 · 体重',
|
||||||
desc: '记录和趋势',
|
indicators: [
|
||||||
svg: (
|
{ label: '血压', color: '#EF4444', bg: '#FEE9E9' },
|
||||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#EF4444" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
{ label: '心率', color: '#F59E0B', bg: '#FFF4E5' },
|
||||||
<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" />
|
{ label: '血糖', color: '#845EF7', bg: '#F3E8FF' },
|
||||||
<polyline points="12 7 12 13 15 15" />
|
{ label: '血氧', color: '#339AF0', bg: '#E6F0FF' },
|
||||||
</svg>
|
{ label: '体重', color: '#20C997', bg: '#E6F9F2' },
|
||||||
),
|
],
|
||||||
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 QUICK_LINKS = [
|
const QUICK_LINKS = [
|
||||||
{
|
{
|
||||||
label: '健康日历',
|
label: '健康日历', path: '/health/calendar',
|
||||||
path: '/health/calendar',
|
color: '#F59E0B', bg: '#FFF8E6',
|
||||||
svg: (
|
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>),
|
||||||
<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: '服药管理',
|
label: '服药管理', path: '/health/medications',
|
||||||
path: '/health/medications',
|
color: '#D67E0B', bg: '#FFF4E5',
|
||||||
svg: (
|
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>),
|
||||||
<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: '运动饮食',
|
label: '运动饮食', path: '/health/exercise-diet',
|
||||||
path: '/health/exercise-diet',
|
color: '#20C997', bg: '#E6F9F2',
|
||||||
svg: (
|
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>),
|
||||||
<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',
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -122,25 +39,81 @@ export function HealthHubPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader title="健康中心" showBack={false} />
|
<PageHeader title="健康中心" showBack={false} />
|
||||||
<div className={styles.grid}>
|
|
||||||
{HEALTH_ITEMS.map((item) => (
|
{/* Combined indicators card */}
|
||||||
<button key={item.path} className={styles.card} onClick={() => navigate(item.path)}>
|
<button className={styles.combinedCard} onClick={() => navigate(COMBINED.path)}>
|
||||||
<span className={styles.cardIcon} style={{ background: item.bg }}>{item.svg}</span>
|
<div className={styles.combinedRow}>
|
||||||
<span className={styles.cardTitle}>{item.label}</span>
|
<div className={styles.combinedIcon}>
|
||||||
<span className={styles.cardDesc}>{item.desc}</span>
|
<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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.extraLinks}>
|
{/* AI 健康助手 */}
|
||||||
{QUICK_LINKS.map((link) => (
|
<div className={styles.aiCard}>
|
||||||
<button key={link.path} className={styles.linkCard} onClick={() => navigate(link.path)}>
|
<div className={styles.aiHeader}>
|
||||||
<span className={styles.cardIcon} style={{ background: link.bg, width: 40, height: 40, borderRadius: 12 }}>
|
<div className={styles.aiAvatar}>
|
||||||
{link.svg}
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
</span>
|
<circle cx="12" cy="12" r="4" />
|
||||||
{link.label}
|
<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>
|
||||||
))}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,152 @@
|
|||||||
.form {
|
.cards {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 10px;
|
||||||
padding: 16px 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bpRow {
|
.card {
|
||||||
display: flex;
|
background: #fff;
|
||||||
gap: 12px;
|
border-radius: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
box-shadow: 0 1px 6px rgba(0,0,0,0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.row {
|
.cardHeader {
|
||||||
display: flex;
|
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 { useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Button } from '@/components/common/Button';
|
|
||||||
import { Input } from '@/components/common/Input';
|
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
import { PageHeader } from '@/components/layout/PageHeader';
|
||||||
import { ToastContainer, toast } from '@/components/common/Toast';
|
import { ToastContainer, toast } from '@/components/common/Toast';
|
||||||
import * as healthService from '@/services/health.service';
|
import * as healthService from '@/services/health.service';
|
||||||
import { MEASUREMENT_TYPES } from '@/utils/constants';
|
|
||||||
import type { MeasurementType } from '@/types';
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import styles from './ManualEntryPage.module.css';
|
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() {
|
export function ManualEntryPage() {
|
||||||
const navigate = useNavigate();
|
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 [values, setValues] = useState<Record<string, string>>({
|
||||||
const [diastolic, setDiastolic] = useState('');
|
systolic: '', diastolic: '', heart_rate: '', blood_sugar: '', spo2: '', weight: '',
|
||||||
const [value, setValue] = useState('');
|
});
|
||||||
const [date, setDate] = useState(dayjs().format('YYYY-MM-DD'));
|
const [dates, setDates] = useState<Record<string, string>>(
|
||||||
const [time, setTime] = useState(dayjs().format('HH:mm'));
|
Object.fromEntries(INDICATORS.map((i) => [i.type, dayjs().format('YYYY-MM-DD')])),
|
||||||
const [loading, setLoading] = useState(false);
|
);
|
||||||
|
const [loading, setLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const setVal = (key: string, v: string) => setValues((prev) => ({ ...prev, [key]: v }));
|
||||||
const numVal = parseFloat(value);
|
|
||||||
if (type === 'blood_pressure') {
|
const saveOne = async (type: string) => {
|
||||||
const sys = parseFloat(systolic);
|
setLoading(type);
|
||||||
const dia = parseFloat(diastolic);
|
try {
|
||||||
if (!sys || !dia) { toast('请填写完整', 'error'); return; }
|
const d = dates[type] || dayjs().format('YYYY-MM-DD');
|
||||||
await healthService.addRecord({
|
const recordedAt = `${d}T${dayjs().format('HH:mm:ss')}`;
|
||||||
type,
|
const ind = INDICATORS.find((i) => i.type === type)!;
|
||||||
value: { systolic: sys, diastolic: dia },
|
|
||||||
unit: 'mmHg',
|
if (type === 'blood_pressure') {
|
||||||
recordedAt: `${date}T${time}:00`,
|
const sys = parseFloat(values.systolic);
|
||||||
recordedDate: date,
|
const dia = parseFloat(values.diastolic);
|
||||||
source: 'manual',
|
if (!sys || !dia) { toast('请填写收缩压和舒张压', 'error'); return; }
|
||||||
});
|
await healthService.addRecord({ type, value: { systolic: sys, diastolic: dia }, unit: 'mmHg', recordedAt, recordedDate: d, source: 'manual' });
|
||||||
} else {
|
} else {
|
||||||
if (!numVal) { toast('请填写数值', 'error'); return; }
|
const v = parseFloat(values[ind.fields[0].key]);
|
||||||
await healthService.addRecord({
|
if (!v) { toast('请填写数值', 'error'); return; }
|
||||||
type,
|
await healthService.addRecord({ type, value: v, unit: ind.unit, recordedAt, recordedDate: d, source: 'manual' });
|
||||||
value: numVal,
|
}
|
||||||
unit: config.unit,
|
toast(`${ind.label} 已保存`);
|
||||||
recordedAt: `${date}T${time}:00`,
|
} catch {
|
||||||
recordedDate: date,
|
toast('保存失败', 'error');
|
||||||
source: 'manual',
|
} finally {
|
||||||
});
|
setLoading(null);
|
||||||
}
|
}
|
||||||
toast('记录成功');
|
|
||||||
setTimeout(() => navigate(-1), 500);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page--no-tab">
|
<div className="page--no-tab">
|
||||||
<PageHeader title={`新增${config.label}记录`} />
|
<PageHeader title="健康指标" />
|
||||||
<div className={styles.form}>
|
|
||||||
{type === 'blood_pressure' ? (
|
<div className={styles.cards}>
|
||||||
<>
|
{INDICATORS.map((ind) => (
|
||||||
<div className={styles.bpRow}>
|
<div key={ind.type} className={styles.card}>
|
||||||
<Input label="收缩压 (mmHg)" value={systolic} onChange={(e) => setSystolic(e.target.value)} type="number" />
|
<div className={styles.cardHeader}>
|
||||||
<Input label="舒张压 (mmHg)" value={diastolic} onChange={(e) => setDiastolic(e.target.value)} type="number" />
|
<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>
|
</div>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
label={`${config.label} (${config.unit})`}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => setValue(e.target.value)}
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.row}>
|
<div className={styles.cardInputs}>
|
||||||
<Input label="日期" value={date} onChange={(e) => setDate(e.target.value)} type="date" />
|
{ind.fields.map((f) => (
|
||||||
<Input label="时间" value={time} onChange={(e) => setTime(e.target.value)} type="time" />
|
<input
|
||||||
</div>
|
key={f.key}
|
||||||
|
className={styles.cardInput}
|
||||||
<Button variant="primary" size="lg" fullWidth loading={loading} onClick={handleSubmit}>
|
type="number"
|
||||||
保存记录
|
step={ind.type === 'blood_sugar' || ind.type === 'weight' ? '0.1' : '1'}
|
||||||
</Button>
|
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>
|
</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 />
|
<ToastContainer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,14 +6,46 @@
|
|||||||
|
|
||||||
.periodBtn {
|
.periodBtn {
|
||||||
padding: 6px 14px;
|
padding: 6px 14px;
|
||||||
border-radius: var(--radius-full);
|
border-radius: 20px;
|
||||||
font-size: var(--font-size-sm);
|
border: none;
|
||||||
background: var(--color-bg-secondary);
|
font-size: 13px;
|
||||||
color: var(--color-text-secondary);
|
font-weight: 500;
|
||||||
|
background: #F5F6F9;
|
||||||
|
color: #6B7280;
|
||||||
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.active {
|
.active {
|
||||||
background: var(--color-primary);
|
background: #4F6EF7;
|
||||||
color: var (--color-text-inverse);
|
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 { useEffect, useState, useMemo } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
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 { Empty } from '@/components/common/Empty';
|
||||||
import { MEASUREMENT_TYPES } from '@/utils/constants';
|
|
||||||
import * as healthService from '@/services/health.service';
|
import * as healthService from '@/services/health.service';
|
||||||
import type { HealthRecord, MeasurementType } from '@/types';
|
import type { HealthRecord } from '@/types';
|
||||||
import { Button } from '@/components/common/Button';
|
|
||||||
import styles from './TrendChartPage.module.css';
|
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 = [
|
const PERIODS = [
|
||||||
{ label: '7天', days: 7 },
|
{ label: '7天', days: 7 },
|
||||||
{ label: '14天', days: 14 },
|
{ label: '14天', days: 14 },
|
||||||
@@ -17,25 +23,60 @@ const PERIODS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function TrendChartPage() {
|
export function TrendChartPage() {
|
||||||
const { type } = useParams<{ type: MeasurementType }>();
|
const [visible, setVisible] = useState<Set<string>>(new Set(INDICATORS.map((i) => i.type)));
|
||||||
const config = MEASUREMENT_TYPES[type || 'blood_pressure'];
|
|
||||||
const [records, setRecords] = useState<HealthRecord[]>([]);
|
|
||||||
const [period, setPeriod] = useState(30);
|
const [period, setPeriod] = useState(30);
|
||||||
|
const [allRecords, setAllRecords] = useState<HealthRecord[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (type) healthService.getTrendData(type, period).then(setRecords);
|
const sources = [...new Set(INDICATORS.map((i) => (i as Record<string, string>).source || i.type))];
|
||||||
}, [type, period]);
|
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 series: SeriesData[] = useMemo(() => {
|
||||||
const chartData = records.map((r) => ({
|
const result: SeriesData[] = [];
|
||||||
date: r.recordedDate,
|
for (const ind of INDICATORS) {
|
||||||
value: isBP ? (typeof r.value === 'object' ? r.value.systolic : 0) : (r.value as number),
|
if (!visible.has(ind.type)) continue;
|
||||||
value2: isBP ? (typeof r.value === 'object' ? r.value.diastolic : 0) : undefined,
|
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 (
|
return (
|
||||||
<div className="page--no-tab">
|
<div className="page--no-tab">
|
||||||
<PageHeader title={`${config.label}趋势`} />
|
<PageHeader title="健康趋势" />
|
||||||
|
|
||||||
|
{/* Period selector */}
|
||||||
<div className={styles.periodBar}>
|
<div className={styles.periodBar}>
|
||||||
{PERIODS.map((p) => (
|
{PERIODS.map((p) => (
|
||||||
<button
|
<button
|
||||||
@@ -47,18 +88,31 @@ export function TrendChartPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{chartData.length > 0 ? (
|
|
||||||
<LineChart
|
{/* Toggle indicators */}
|
||||||
data={chartData}
|
<div className={styles.toggleBar}>
|
||||||
seriesName={isBP ? '收缩压' : config.label}
|
{INDICATORS.map((ind) => (
|
||||||
seriesName2={isBP ? '舒张压' : undefined}
|
<button
|
||||||
unit={config.unit}
|
key={ind.type}
|
||||||
markLine={isBP ? 140 : undefined}
|
className={`${styles.toggleBtn} ${visible.has(ind.type) ? styles.toggleOn : styles.toggleOff}`}
|
||||||
markLineLabel={isBP ? '140警戒线' : undefined}
|
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="暂无数据" />
|
<Empty message="暂无数据" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState, useMemo } from 'react';
|
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 { Card } from '@/components/common/Card';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useNotificationStore } from '@/stores/notification.store';
|
import { useNotificationStore } from '@/stores/notification.store';
|
||||||
@@ -120,6 +120,7 @@ const QUICK_ACTIONS = [
|
|||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { unreadCount, fetchNotifications } = useNotificationStore();
|
const { unreadCount, fetchNotifications } = useNotificationStore();
|
||||||
const [stats, setStats] = useState<HealthStats[]>([]);
|
const [stats, setStats] = useState<HealthStats[]>([]);
|
||||||
@@ -131,7 +132,7 @@ export function HomePage() {
|
|||||||
api.get<MedSummary[]>('/api/medications/today-summary')
|
api.get<MedSummary[]>('/api/medications/today-summary')
|
||||||
.then((r) => setMeds(r.data))
|
.then((r) => setMeds(r.data))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [fetchNotifications]);
|
}, [fetchNotifications, location.pathname]);
|
||||||
|
|
||||||
const bpStats = stats.find((s) => s.type === 'blood_pressure');
|
const bpStats = stats.find((s) => s.type === 'blood_pressure');
|
||||||
const hrStats = stats.find((s) => s.type === 'heart_rate');
|
const hrStats = stats.find((s) => s.type === 'heart_rate');
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function ChatPage() {
|
|||||||
|
|
||||||
// Set up SignalR connection
|
// Set up SignalR connection
|
||||||
const conn = new HubConnectionBuilder()
|
const conn = new HubConnectionBuilder()
|
||||||
.withUrl('http://localhost:5000/hubs/chat', {
|
.withUrl('/hubs/chat', {
|
||||||
accessTokenFactory: () => getToken(),
|
accessTokenFactory: () => getToken(),
|
||||||
})
|
})
|
||||||
.withAutomaticReconnect()
|
.withAutomaticReconnect()
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function ReportDetailPage() {
|
|||||||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8 }}>报告图片</div>
|
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8 }}>报告图片</div>
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
{report.imageUrls.map((url, i) => (
|
{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' }} />
|
style={{ width: 80, height: 80, borderRadius: 8, objectFit: 'cover', border: '1px solid #eee' }} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function ReportUploadPage() {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
const token = JSON.parse(localStorage.getItem('hrt_auth') || '{}')?.state?.token;
|
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',
|
method: 'POST',
|
||||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
|
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
|
||||||
body: formData,
|
body: formData,
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'home/device-binding', element: <DeviceBindingPage /> },
|
{ path: 'home/device-binding', element: <DeviceBindingPage /> },
|
||||||
{ path: 'health/records', element: <HealthRecordListPage /> },
|
{ path: 'health/records', element: <HealthRecordListPage /> },
|
||||||
{ path: 'health/records/add', element: <ManualEntryPage /> },
|
{ path: 'health/records/add', element: <ManualEntryPage /> },
|
||||||
{ path: 'health/trends/:type', element: <TrendChartPage /> },
|
{ path: 'health/trends', element: <TrendChartPage /> },
|
||||||
{ path: 'health/calendar', element: <HealthCalendarPage /> },
|
{ path: 'health/calendar', element: <HealthCalendarPage /> },
|
||||||
{ path: 'health/medications', element: <MedicationListPage /> },
|
{ path: 'health/medications', element: <MedicationListPage /> },
|
||||||
{ path: 'health/medications/add', element: <MedicationEditPage /> },
|
{ path: 'health/medications/add', element: <MedicationEditPage /> },
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
/**
|
// API client — uses Vite proxy in dev, same-origin in production.
|
||||||
* Real HTTP API client — replaces mockApiResponse with actual fetch calls.
|
|
||||||
* Backend base: http://localhost:5000
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
code: number;
|
code: number;
|
||||||
data: T;
|
data: T;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE_URL = 'http://localhost:5000';
|
const BASE_URL = '';
|
||||||
|
|
||||||
// Endpoints that should NEVER include auth token
|
// Endpoints that should NEVER include auth token
|
||||||
const PUBLIC_ENDPOINTS = ['/api/auth/login', '/api/auth/register', '/api/auth/send-sms', '/api/auth/refresh'];
|
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() {
|
export function getExerciseRecommendations() {
|
||||||
return EXERCISE_RECOMMENDATIONS;
|
return EXERCISE_RECOMMENDATIONS;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,5 +12,9 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:5000',
|
||||||
|
'/hubs': { target: 'http://localhost:5000', ws: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user