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

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

View File

@@ -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" };

View File

@@ -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);

View File

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

View File

@@ -1,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>
); );
} }

View File

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

View File

@@ -1,22 +1,339 @@
.tabs { display: flex; gap: 8px; margin-bottom: 16px; } /* 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; }
}

View File

@@ -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 />

View File

@@ -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;
}

View File

@@ -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>
); );

View File

@@ -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; }

View File

@@ -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>
); );

View File

@@ -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;
}

View File

@@ -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>
); );
} }

View File

@@ -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');

View File

@@ -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()

View File

@@ -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>

View File

@@ -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,

View File

@@ -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 /> },

View File

@@ -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'];

View File

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

View File

@@ -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 },
},
}, },
}) })