From f412a474cd03c7edaa2cb0fac480da21a3af8978 Mon Sep 17 00:00:00 2001 From: MingNian <1281442923@qq.com> Date: Tue, 26 May 2026 15:56:06 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E5=81=A5=E5=BA=B7=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=E9=A1=B5=E9=9D=A2=EF=BC=9A=E5=90=88=E5=B9=B6=E6=8C=87?= =?UTF-8?q?=E6=A0=87=E5=85=A5=E5=8F=A3=E3=80=81=E7=8B=AC=E7=AB=8B=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E9=A1=B5=E9=9D=A2=E3=80=81=E5=A4=9A=E6=8C=87=E6=A0=87?= =?UTF-8?q?=E8=B6=8B=E5=8A=BF=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 健康中心合并血压/心率/血糖/血氧/体重为统一入口卡片 - 记录页面每个指标独立日期+保存,紧凑设计 - 趋势图支持多指标切换显示,同时显示收缩压和舒张压 - 首页健康概览修复返回页面后数据不更新的问题 - Vite 添加代理,支持手机通过局域网 IP 访问 - 医生端患者详情新增健康趋势图及指标切换 - 运动饮食页面支持删除记录 - 修复复查完成后患者端消失的问题 --- .../Services/HealthService.cs | 9 + .../Controllers/HealthController.cs | 8 + .../src/components/charts/MultiLineChart.tsx | 63 +++ .../src/pages/patients/PatientDetailPage.tsx | 237 ++++++++++-- .../src/components/charts/MultiLineChart.tsx | 63 +++ .../exercise-diet/ExerciseDietPage.module.css | 361 ++++++++++++++++-- .../pages/exercise-diet/ExerciseDietPage.tsx | 290 ++++++++++---- .../src/pages/health/HealthHubPage.module.css | 266 +++++++++++-- .../src/pages/health/HealthHubPage.tsx | 209 +++++----- .../pages/health/ManualEntryPage.module.css | 152 +++++++- .../src/pages/health/ManualEntryPage.tsx | 178 +++++---- .../pages/health/TrendChartPage.module.css | 44 ++- .../src/pages/health/TrendChartPage.tsx | 108 ++++-- frontend-patient/src/pages/home/HomePage.tsx | 5 +- .../src/pages/services/ChatPage.tsx | 2 +- .../src/pages/services/ReportDetailPage.tsx | 2 +- .../src/pages/services/ReportUploadPage.tsx | 2 +- frontend-patient/src/router/index.tsx | 2 +- frontend-patient/src/services/api-client.ts | 8 +- .../src/services/exercise-diet.service.ts | 8 + frontend-patient/vite.config.ts | 4 + 21 files changed, 1617 insertions(+), 404 deletions(-) create mode 100644 frontend-doctor/src/components/charts/MultiLineChart.tsx create mode 100644 frontend-patient/src/components/charts/MultiLineChart.tsx diff --git a/backend/src/HealthManager.Application/Services/HealthService.cs b/backend/src/HealthManager.Application/Services/HealthService.cs index d1c1ad5..59c9f9a 100644 --- a/backend/src/HealthManager.Application/Services/HealthService.cs +++ b/backend/src/HealthManager.Application/Services/HealthService.cs @@ -43,6 +43,15 @@ public class HealthService(AppDbContext db) return record; } + public async Task 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> GetStatsAsync(Guid userId) { var types = new[] { "blood_pressure", "heart_rate", "blood_sugar", "spo2", "weight", "steps" }; diff --git a/backend/src/HealthManager.WebApi/Controllers/HealthController.cs b/backend/src/HealthManager.WebApi/Controllers/HealthController.cs index b899074..a2c45e9 100644 --- a/backend/src/HealthManager.WebApi/Controllers/HealthController.cs +++ b/backend/src/HealthManager.WebApi/Controllers/HealthController.cs @@ -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); return Ok(new { record.Id, record.Type, Value = record.Value.RootElement.GetRawText(), record.Unit, record.RecordedAt, record.Source }); } + + [HttpDelete("{id:guid}")] + public async Task 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); diff --git a/frontend-doctor/src/components/charts/MultiLineChart.tsx b/frontend-doctor/src/components/charts/MultiLineChart.tsx new file mode 100644 index 0000000..7263f46 --- /dev/null +++ b/frontend-doctor/src/components/charts/MultiLineChart.tsx @@ -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 = `
${items[0]?.axisValue || ''}
`; + items.forEach((p) => { + const s = series.find((x) => x.name === p.seriesName); + html += `
${p.seriesName}: ${p.data} ${s?.unit || ''}
`; + }); + 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 ; +} diff --git a/frontend-doctor/src/pages/patients/PatientDetailPage.tsx b/frontend-doctor/src/pages/patients/PatientDetailPage.tsx index b7861a9..06ed983 100644 --- a/frontend-doctor/src/pages/patients/PatientDetailPage.tsx +++ b/frontend-doctor/src/pages/patients/PatientDetailPage.tsx @@ -1,6 +1,7 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo } from 'react'; import { useParams, Link } from 'react-router-dom'; import { api } from '../../services/api-client'; +import { MultiLineChart, type SeriesData } from '../../components/charts/MultiLineChart'; interface PatientDetail { 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; } +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 = { blood_pressure: '血压', heart_rate: '心率', blood_sugar: '血糖', spo2: '血氧', }; @@ -24,17 +34,52 @@ const typeBgs: Record = { blood_pressure: '#FEE9E9', heart_rate: '#FFF8E6', blood_sugar: '#EDF0FD', spo2: '#E6F9F2', }; +const exerciseIcons: Record = { + '散步': '🚶', '慢跑': '🏃', '太极拳': '🤸', '游泳': '🏊', '骑自行车': '🚴', '八段锦': '🧘', +}; + +const mealLabels: Record = { + breakfast: '早餐', lunch: '午餐', dinner: '晚餐', snack: '加餐', +}; +const mealIcons: Record = { + breakfast: '🌅', lunch: '☀️', dinner: '🌙', snack: '🍎', +}; + export function PatientDetailPage() { const { id } = useParams<{ id: string }>(); const [patient, setPatient] = useState(null); const [records, setRecords] = useState([]); + const [exercises, setExercises] = useState([]); + const [diets, setDiets] = useState([]); useEffect(() => { if (!id) return; api.get(`/api/patients/${id}`).then((r) => { if (r.data) setPatient(r.data); }).catch(() => {}); - api.get(`/api/health-records?patientId=${id}&days=30`).then((r) => setRecords(r.data)); + api.get(`/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]); if (!patient) return
加载中...
; @@ -55,9 +100,10 @@ export function PatientDetailPage() { }; return ( -
+
← 返回患者列表 + {/* Patient info card */}
{patient.phone}

-
-
- 手机号 - {patient.phone} -
-
- 性别 - {patient.gender || '-'} -
-
- 出生日期 - {patient.birthday || '-'} -
-
- 身高/体重 - {patient.heightCm}cm / {patient.weightKg}kg -
-
- 病史 - {(patient.medicalHistory || []).join('、') || '-'} -
-
- 支架日期 - {patient.stentDate || '-'} -
-
- 支架类型 - {patient.stentType || '-'} -
+ + + + + + +
-

最近健康数据

+ {/* Health vitals */} +

生命体征

{Object.entries(latestByType).map(([type, record]) => (
))}
+ + {/* Trend chart */} + + + {/* Exercise + Diet side by side */} +
+ {/* Exercise */} +
+

+ 🏃 运动记录 + + 近7天 · {exercises.reduce((s, e) => s + (e.duration || 0), 0)}分钟 + +

+ {exercises.length === 0 ? ( +
暂无运动记录
+ ) : ( + exercises.slice(0, 7).map((e, i) => ( +
+ {exerciseIcons[e.type] || '💪'} +
+
{e.type}
+
{e.duration}分钟 · {e.caloriesBurned}kcal
+
+
{e.date?.slice(5)}
+
+ )) + )} +
+ + {/* Diet */} +
+

+ 🥗 饮食记录 + + 近7天 · {diets.reduce((s, d) => s + (d.totalCalories || 0), 0)}kcal + +

+ {diets.length === 0 ? ( +
暂无饮食记录
+ ) : ( + diets.slice(0, 7).map((d, i) => ( +
+ {mealIcons[d.mealType] || '🍽️'} +
+
+ {d.foods?.map(f => f.name).join('、') || '-'} +
+
+ {mealLabels[d.mealType] || d.mealType} · {d.totalCalories}kcal +
+
+
{d.date?.slice(5)}
+
+ )) + )} +
+
+
+ ); +} + +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>(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).source || ind.type; + const field = (ind as Record).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 ( +
+

📈 健康趋势

+
+ {CHART_INDICATORS.map((ind) => ( + + ))} +
+
+ +
+
+ ); +} + +function InfoRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value}
); } diff --git a/frontend-patient/src/components/charts/MultiLineChart.tsx b/frontend-patient/src/components/charts/MultiLineChart.tsx new file mode 100644 index 0000000..7263f46 --- /dev/null +++ b/frontend-patient/src/components/charts/MultiLineChart.tsx @@ -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 = `
${items[0]?.axisValue || ''}
`; + items.forEach((p) => { + const s = series.find((x) => x.name === p.seriesName); + html += `
${p.seriesName}: ${p.data} ${s?.unit || ''}
`; + }); + 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 ; +} diff --git a/frontend-patient/src/pages/exercise-diet/ExerciseDietPage.module.css b/frontend-patient/src/pages/exercise-diet/ExerciseDietPage.module.css index 8af8093..81ea4d3 100644 --- a/frontend-patient/src/pages/exercise-diet/ExerciseDietPage.module.css +++ b/frontend-patient/src/pages/exercise-diet/ExerciseDietPage.module.css @@ -1,22 +1,339 @@ -.tabs { display: flex; gap: 8px; margin-bottom: 16px; } -.tab { padding: 6px 14px; border-radius: var(--radius-full); font-size: var(--font-size-sm); background: var(--color-bg-secondary); color: var(--color-text-secondary); font-weight: 500; } -.tabActive { background: var(--color-primary); color: var(--color-text-inverse); } -.sectionTitle { font-size: var(--font-size-base); font-weight: 700; margin: 16px 0 8px; } -.recCard { margin-bottom: 8px; } -.recHeader { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; font-weight: 600; font-size: var(--font-size-sm); } -.suitBadge { font-size: var(--font-size-xs); padding: 2px 8px; border-radius: var(--radius-sm); font-weight: 600; } -.suitYes { background: var(--color-success-bg); color: var(--color-success); } -.suitNo { background: var(--color-danger-bg); color: var(--color-danger); } -.notSuitable { opacity: 0.5; } -.recMeta { font-size: var(--font-size-xs); color: var(--color-text-tertiary); } -.recDesc { font-size: var(--font-size-xs); color: var(--color-text-secondary); margin: 6px 0; line-height: 1.5; } -.foodTags { display: flex; gap: 6px; flex-wrap: wrap; } -.foodTag { padding: 2px 8px; font-size: var(--font-size-xs); background: var(--color-primary-bg); color: var(--color-primary); border-radius: var(--radius-sm); font-weight: 500; } -.addCard { margin-bottom: 12px; display: flex; flex-direction: column; gap: 10px; } -.addRow { display: flex; gap: 8px; align-items: center; } -.select { padding: 10px 12px; border: 1.5px solid var(--color-border); border-radius: var(--radius-md); font-size: var(--font-size-sm); background: var(--color-bg); outline: none; } -.intensityRow { display: flex; gap: 8px; } -.intensityBtn { flex: 1; padding: 6px; font-size: var(--font-size-xs); background: var(--color-bg); border-radius: var(--radius-md); } -.intensityActive { background: var(--color-primary-bg); color: var(--color-primary); } -.logCard { margin-bottom: 6px; } -.logDate { font-size: var(--font-size-xs); color: var(--color-text-tertiary); margin-top: 2px; } +/* Tabs */ +.tabs { + display: flex; + gap: 8px; + margin-bottom: 16px; +} + +.tab { + flex: 1; + padding: 12px; + border-radius: 14px; + background: #F5F6F9; + border: none; + font-size: 15px; + font-weight: 600; + color: #6B7280; + cursor: pointer; + transition: all 0.25s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +.tabIcon { font-size: 18px; } + +.tabActive { + background: linear-gradient(135deg, #4F6EF7, #6C8AFF); + color: #fff; + box-shadow: 0 4px 16px rgba(79,110,247,0.3); + transform: scale(1.02); +} + +/* Summary cards */ +.summaryCard, +.summaryCardDiet { + position: relative; + border-radius: 20px; + padding: 24px; + margin-bottom: 16px; + overflow: hidden; + background: linear-gradient(135deg, #f0f4ff 0%, #fff 60%); + box-shadow: 0 2px 16px rgba(0,0,0,0.04); +} + +.summaryCardDiet { + background: linear-gradient(135deg, #f0faf6 0%, #fff 60%); +} + +.summaryBg { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; +} + +.summaryContent { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; +} + +.summaryLabel { font-size: 13px; color: #6B7280; margin-bottom: 4px; } +.summaryValue { font-size: 32px; font-weight: 800; color: #1A1D28; } +.summaryUnit { font-size: 14px; font-weight: 500; color: #9BA0B4; } +.summaryHint { font-size: 12px; color: #9BA0B4; margin-top: 4px; } + +.summaryRight { + animation: pulse 3s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.05); } +} + +/* Add card */ +.addCard { + padding: 16px; + margin-bottom: 20px; + border-radius: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.addGrid { + display: grid; + grid-template-columns: 1fr 100px; + gap: 10px; +} + +.addGrid3 { + display: grid; + grid-template-columns: 1fr 80px 100px; + gap: 10px; +} + +.select { + padding: 10px 14px; + border: 1.5px solid #E1E5ED; + border-radius: 10px; + font-size: 14px; + background: #fff; + color: #1A1D28; + outline: none; + cursor: pointer; +} + +.select:focus { border-color: #4F6EF7; } + +.intensityRow { + display: flex; + gap: 8px; +} + +.intensityBtn { + flex: 1; + padding: 10px 0; + border: 1.5px solid #E1E5ED; + border-radius: 10px; + background: #fff; + font-size: 13px; + font-weight: 500; + color: #6B7280; + cursor: pointer; + transition: all 0.2s; +} + +.intensityActive { + border-color: #4F6EF7; + background: #EEF2FF; + color: #4F6EF7; + font-weight: 600; +} + +.mealTabRow { + display: flex; + gap: 6px; +} + +.mealTab { + flex: 1; + padding: 10px 4px; + border: 1.5px solid #E1E5ED; + border-radius: 10px; + background: #fff; + font-size: 12px; + font-weight: 500; + color: #6B7280; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} + +.mealTabActive { + border-color: #20C997; + background: #E6F9F2; + color: #20C997; + font-weight: 600; +} + +/* Day groups */ +.dayGroup { + margin-bottom: 16px; +} + +.dayLabel { + font-size: 12px; + font-weight: 600; + color: #9BA0B4; + padding: 0 4px 8px; + letter-spacing: 0.3px; +} + +/* Log cards */ +.logCard { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + background: #fff; + border-radius: 14px; + margin-bottom: 8px; + box-shadow: 0 1px 8px rgba(0,0,0,0.03); + border-left: 4px solid #E1E5ED; + transition: transform 0.15s, box-shadow 0.15s; + animation: fadeInUp 0.3s ease both; +} + +.logCard:active { transform: scale(0.98); } + +@keyframes fadeInUp { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +.intensityLow { border-left-color: #20C997; } +.intensityModerate { border-left-color: #F59E0B; } +.intensityHigh { border-left-color: #EF4444; } +.mealBreakfast { border-left-color: #F59E0B; } +.mealLunch { border-left-color: #4F6EF7; } +.mealDinner { border-left-color: #845EF7; } +.mealSnack { border-left-color: #20C997; } + +.logIcon { font-size: 28px; flex-shrink: 0; } + +.logInfo { flex: 1; min-width: 0; } +.logTitle { font-size: 15px; font-weight: 600; color: #1A1D28; } +.logMeta { font-size: 12px; color: #9BA0B4; margin-top: 2px; } + +.intensityTag { + font-size: 11px; + padding: 3px 10px; + border-radius: 8px; + font-weight: 600; + background: #F5F6F9; + color: #6B7280; + flex-shrink: 0; +} + +.delBtn { + width: 24px; + height: 24px; + border: none; + background: transparent; + color: #C0C5D2; + font-size: 18px; + cursor: pointer; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all 0.15s; + margin-left: 4px; +} + +.delBtn:hover { + color: #EF4444; + background: #FEE9E9; +} + +.delBtn:active { + transform: scale(0.9); +} + +/* Section titles */ +.sectionTitle { + font-size: 16px; + font-weight: 700; + color: #1A1D28; + margin: 24px 0 12px; +} + +/* Recommendation cards grid */ +.recGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 24px; +} + +.recCard { + position: relative; + padding: 16px; + background: #fff; + border-radius: 16px; + box-shadow: 0 2px 12px rgba(0,0,0,0.04); + text-align: center; + transition: transform 0.2s; +} + +.recCard:hover { transform: translateY(-2px); } +.recNo { opacity: 0.5; } + +.recEmoji { font-size: 32px; margin-bottom: 6px; } +.recName { font-size: 14px; font-weight: 600; color: #1A1D28; margin-bottom: 2px; } +.recMeta { font-size: 11px; color: #9BA0B4; margin-bottom: 8px; } + +.recBadge { + display: inline-block; + font-size: 11px; + padding: 3px 12px; + border-radius: 10px; + font-weight: 600; +} + +.recGood { background: #E6F9F2; color: #20C997; } +.recBad { background: #FEE9E9; color: #EF4444; } + +/* Diet recommendation cards */ +.dietRecCard { + padding: 16px; + background: #fff; + border-radius: 16px; + box-shadow: 0 2px 12px rgba(0,0,0,0.04); + transition: transform 0.2s; +} + +.dietRecCard:hover { transform: translateY(-2px); } + +.dietRecHeader { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.dietRecIcon { font-size: 16px; } +.dietRecTitle { font-size: 14px; font-weight: 600; color: #1A1D28; } + +.dietRecDesc { + font-size: 12px; + color: #6B7280; + line-height: 1.5; + margin: 0 0 10px; +} + +.dietRecFoods { display: flex; flex-wrap: wrap; gap: 6px; } + +.dietFoodTag { + font-size: 11px; + padding: 4px 10px; + border-radius: 8px; + background: #E6F9F2; + color: #20C997; + font-weight: 500; +} + +/* Responsive */ +@media (max-width: 360px) { + .recGrid { grid-template-columns: 1fr; } + .addGrid3 { grid-template-columns: 1fr 1fr; } + .addGrid { grid-template-columns: 1fr 80px; } +} diff --git a/frontend-patient/src/pages/exercise-diet/ExerciseDietPage.tsx b/frontend-patient/src/pages/exercise-diet/ExerciseDietPage.tsx index 0096697..ccf34f8 100644 --- a/frontend-patient/src/pages/exercise-diet/ExerciseDietPage.tsx +++ b/frontend-patient/src/pages/exercise-diet/ExerciseDietPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo } from 'react'; import { PageHeader } from '@/components/layout/PageHeader'; import { Card } from '@/components/common/Card'; import { Button } from '@/components/common/Button'; @@ -10,144 +10,280 @@ import type { ExerciseRecord, DietRecord } from '@/types'; import { formatDate } from '@/utils/format'; import styles from './ExerciseDietPage.module.css'; +const EXERCISE_TYPES = ['散步', '慢跑', '太极拳', '游泳', '骑自行车', '八段锦', '瑜伽', '广场舞']; +const INTENSITIES = [ + { key: 'low' as const, label: '低强度', emoji: '🟢' }, + { key: 'moderate' as const, label: '中强度', emoji: '🟡' }, + { key: 'high' as const, label: '高强度', emoji: '🔴' }, +]; +const MEAL_TYPES = [ + { key: 'breakfast' as const, label: '早餐', icon: '🌅' }, + { key: 'lunch' as const, label: '午餐', icon: '☀️' }, + { key: 'dinner' as const, label: '晚餐', icon: '🌙' }, + { key: 'snack' as const, label: '加餐', icon: '🍎' }, +]; + +function groupByDate(items: T[]): [string, T[]][] { + const map = new Map(); + items.forEach((item) => { + const list = map.get(item.date) || []; + list.push(item); + map.set(item.date, list); + }); + return [...map.entries()].sort((a, b) => b[0].localeCompare(a[0])); +} + export function ExerciseDietPage() { - const [subTab, setSubTab] = useState<'recommend' | 'exercise' | 'diet'>('recommend'); + const [tab, setTab] = useState<'exercise' | 'diet'>('exercise'); const [exercises, setExercises] = useState([]); const [diets, setDiets] = useState([]); + // exercise form const [exType, setExType] = useState('散步'); const [exDuration, setExDuration] = useState('30'); const [exIntensity, setExIntensity] = useState<'low' | 'moderate' | 'high'>('low'); + // diet form const [mealType, setMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast'); const [foodName, setFoodName] = useState(''); + const [foodAmount, setFoodAmount] = useState('1份'); const [foodKcal, setFoodKcal] = useState(''); - const recommendations = exerciseDietService.getExerciseRecommendations(); + const exRecommendations = exerciseDietService.getExerciseRecommendations(); const dietRecommendations = exerciseDietService.getDietRecommendations(); + const groupedExercises = useMemo(() => groupByDate(exercises).slice(0, 7), [exercises]); + const groupedDiets = useMemo(() => groupByDate(diets).slice(0, 7), [diets]); + useEffect(() => { exerciseDietService.getExerciseLogs().then(setExercises); exerciseDietService.getDietLogs().then(setDiets); }, []); + const deleteExercise = async (id: string) => { + await exerciseDietService.deleteExerciseLog(id); + toast('已删除'); + exerciseDietService.getExerciseLogs().then(setExercises); + }; + + const deleteDiet = async (id: string) => { + await exerciseDietService.deleteDietLog(id); + toast('已删除'); + exerciseDietService.getDietLogs().then(setDiets); + }; + const addExercise = async () => { if (!exDuration) return; await exerciseDietService.addExerciseLog({ type: exType, duration: parseInt(exDuration), intensity: exIntensity, - caloriesBurned: parseInt(exDuration) * 4, date: new Date().toISOString().slice(0, 10), + caloriesBurned: parseInt(exDuration) * ({ low: 4, moderate: 7, high: 11 }[exIntensity] || 4), + date: new Date().toISOString().slice(0, 10), }); - toast('记录成功'); + toast('运动记录成功'); exerciseDietService.getExerciseLogs().then(setExercises); }; const addDiet = async () => { - if (!foodName || !foodKcal) { toast('请填写食物信息', 'error'); return; } + if (!foodName || !foodKcal) { toast('请填写食物名称和热量', 'error'); return; } await exerciseDietService.addDietLog({ - mealType, foods: [{ name: foodName, amount: '1份', calories: parseInt(foodKcal) }], + mealType, foods: [{ name: foodName, amount: foodAmount, calories: parseInt(foodKcal) }], totalCalories: parseInt(foodKcal), date: new Date().toISOString().slice(0, 10), }); - toast('记录成功'); + setFoodName(''); setFoodAmount('1份'); setFoodKcal(''); + toast('饮食记录成功'); exerciseDietService.getDietLogs().then(setDiets); }; + const todayExKcal = exercises.filter(e => e.date === new Date().toISOString().slice(0, 10)).reduce((s, e) => s + (e.caloriesBurned || 0), 0); + const todayDietKcal = diets.filter(d => d.date === new Date().toISOString().slice(0, 10)).reduce((s, d) => s + (d.totalCalories || 0), 0); + return (
+ + {/* Tab switch */}
- {[ - { key: 'recommend', label: '推荐' }, - { key: 'exercise', label: '运动' }, - { key: 'diet', label: '饮食' }, - ].map((t) => ( - - ))} + +
- {subTab === 'recommend' && ( -
-

运动推荐

- {recommendations.map((r, i) => ( - -
- {r.name} - - {r.suitable ? '适合' : '不适合'} - + {/* ============ EXERCISE TAB ============ */} + {tab === 'exercise' && ( + <> + {/* Today summary card */} +
+ + + + +
+
+
今日运动消耗
+
{todayExKcal} kcal
+
相当于慢跑 {Math.round(todayExKcal / 8)} 分钟
-
{r.duration} · {r.frequency} · {r.intensity}强度
- - ))} -

饮食推荐

- {dietRecommendations.slice(0, 3).map((d, i) => ( - -
{d.title}
-

{d.description}

-
- {d.recommendedFoods.slice(0, 3).map((f, j) => ( - {f} - ))} +
+ + + + +
- - ))} -
- )} +
+
- {subTab === 'exercise' && ( -
+ {/* Add record */} -
+
setExDuration(e.target.value)} type="number" placeholder="分钟" />
- {['low', 'moderate', 'high'].map((i) => ( - ))}
- {exercises.length === 0 ? : exercises.slice(0, 10).map((e) => ( - -
{e.type} · {e.duration}分钟 · {e.caloriesBurned}kcal
-
{formatDate(e.date, 'MM-DD')}
-
- ))} -
+ {/* Recent records */} + {groupedExercises.length === 0 ? ( + + ) : ( + groupedExercises.map(([date, items]) => ( +
+
{formatDate(date, 'MM月DD日')} · {items.reduce((s, e) => s + (e.duration || 0), 0)}分钟
+ {items.map((e, i) => ( +
+
{e.type === '散步' ? '🚶' : e.type === '慢跑' ? '🏃' : e.type === '太极拳' ? '🤸' : e.type === '游泳' ? '🏊' : e.type === '骑自行车' ? '🚴' : e.type === '八段锦' ? '🧘' : '💪'}
+
+
{e.type}
+
{e.duration}分钟 · {e.caloriesBurned}kcal
+
+ + {{ low: '低', moderate: '中', high: '高' }[e.intensity || 'low']} + + +
+ ))} +
+ )) + )} + + {/* Recommendations */} +

适合您的运动

+
+ {exRecommendations.slice(0, 4).map((r, i) => ( +
+
{r.name === '散步' ? '🚶' : r.name === '太极拳' ? '🤸' : r.name === '慢跑' ? '🏃' : r.name === '游泳' ? '🏊' : r.name === '骑自行车' ? '🚴' : r.name === '八段锦' ? '🧘' : '🏋️'}
+
{r.name}
+
{r.duration} · {r.frequency}
+ + {r.suitable ? '适合' : '避免'} + +
+ ))} +
+ )} - {subTab === 'diet' && ( -
+ {/* ============ DIET TAB ============ */} + {tab === 'diet' && ( + <> + {/* Today summary card */} +
+ + + + +
+
+
今日摄入热量
+
{todayDietKcal} kcal
+
+ {todayDietKcal < 1200 ? '摄入偏低,注意营养' : todayDietKcal < 2200 ? '摄入适中,继续保持' : '摄入偏高,注意控制'} +
+
+
+ + + + 🍽️ + +
+
+
+ + {/* Add record */} -
- - setFoodName(e.target.value)} placeholder="食物名" /> - setFoodKcal(e.target.value)} type="number" placeholder="kcal" /> +
+ {MEAL_TYPES.map((m) => ( + + ))} +
+
+ setFoodName(e.target.value)} placeholder="食物名称" /> + setFoodAmount(e.target.value)} placeholder="份量" /> + setFoodKcal(e.target.value)} type="number" placeholder="热量(kcal)" />
- {diets.length === 0 ? : diets.slice(0, 10).map((d) => ( - -
{d.foods.map((f) => f.name).join(', ')}
-
{d.totalCalories}kcal · {formatDate(d.date, 'MM-DD')}
-
- ))} -
+ {/* Recent records */} + {groupedDiets.length === 0 ? ( + + ) : ( + groupedDiets.map(([date, items]) => ( +
+
{formatDate(date, 'MM月DD日')} · {items.reduce((s, d) => s + (d.totalCalories || 0), 0)}kcal
+ {items.map((d, i) => ( +
+
{MEAL_TYPES.find(m => m.key === d.mealType)?.icon || '🍽️'}
+
+
{d.foods?.map(f => f.name).join('、')}
+
+ {MEAL_TYPES.find(m => m.key === d.mealType)?.label} · {d.totalCalories}kcal · {d.foods?.map(f => f.amount).join('、')} +
+
+ +
+ ))} +
+ )) + )} + + {/* Recommendations */} +

饮食建议

+
+ {dietRecommendations.slice(0, 4).map((d, i) => ( +
+
+ 💡 + {d.title} +
+

{d.description.slice(0, 40)}...

+
+ {d.recommendedFoods.slice(0, 3).map((f, j) => ( + {f} + ))} +
+
+ ))} +
+ )} diff --git a/frontend-patient/src/pages/health/HealthHubPage.module.css b/frontend-patient/src/pages/health/HealthHubPage.module.css index 1cbce6f..d32b94e 100644 --- a/frontend-patient/src/pages/health/HealthHubPage.module.css +++ b/frontend-patient/src/pages/health/HealthHubPage.module.css @@ -1,57 +1,251 @@ -.grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 12px; - margin-bottom: 16px; +/* Combined card */ +.combinedCard { + display: flex; + flex-direction: column; + gap: 14px; + padding: 16px; + margin: 0 -4px; + width: calc(100% + 8px); + background: #fff; + border-radius: 18px; + box-shadow: 0 2px 16px rgba(0,0,0,0.06); + cursor: pointer; + transition: transform 0.15s; } -.card { +.combinedCard:active { transform: scale(0.985); } + +.combinedRow { + display: flex; + align-items: center; + gap: 14px; +} + +.combinedIcon { + width: 52px; + height: 52px; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.combinedInfo { flex: 1; text-align: left; } +.combinedTitle { font-size: 18px; font-weight: 700; color: #1A1D28; display: block; } +.combinedDesc { font-size: 12px; color: #9BA0B4; margin-top: 2px; display: block; } +.combinedArrow { font-size: 24px; color: #C0C5D2; } + +.indicatorTags { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.tag { + font-size: 11px; + font-weight: 600; + padding: 4px 12px; + border-radius: 20px; +} + +/* Quick links — horizontal row */ +.quickRow { + display: flex; + gap: 10px; + margin: 20px -4px 0; +} + +.quickCard { + flex: 1; display: flex; flex-direction: column; align-items: center; gap: 8px; - padding: 18px 12px; - background: var(--color-white); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-sm); - transition: transform 0.2s; - -webkit-tap-highlight-color: transparent; - position: relative; - overflow: hidden; + padding: 14px 8px; + background: #fff; + border: none; + border-radius: 14px; + box-shadow: 0 1px 8px rgba(0,0,0,0.04); + cursor: pointer; + transition: transform 0.15s, box-shadow 0.15s; } -.card:active { transform: scale(0.95); } +.quickCard:active { transform: scale(0.95); } -.cardIcon { - width: 50px; - height: 50px; - border-radius: 16px; +.quickIcon { + width: 42px; + height: 42px; + border-radius: 12px; display: flex; align-items: center; justify-content: center; } -.cardTitle { font-size: var(--font-size-base); font-weight: 700; color: var(--color-text-primary); } -.cardDesc { font-size: 11px; color: var(--color-text-tertiary); } - -.extraLinks { - display: flex; - flex-direction: column; - gap: 8px; +.quickLabel { + font-size: 12px; + font-weight: 600; + color: #1A1D28; } -.linkCard { +/* AI Assistant */ +.aiCard { + display: flex; + flex-direction: column; + gap: 18px; + padding: 22px 18px; + margin: 16px -4px 0; + width: calc(100% + 8px); + background: linear-gradient(135deg, #EFF2FF 0%, #F5F7FF 40%, #FDF0F5 100%); + border-radius: 22px; + border: 1px solid rgba(79,110,247,0.08); + box-shadow: 0 2px 16px rgba(79,110,247,0.06); + transition: transform 0.15s, box-shadow 0.15s; + position: relative; + overflow: hidden; +} + +.aiCard::before { + content: ''; + position: absolute; + width: 140px; + height: 140px; + border-radius: 50%; + background: rgba(79,110,247,0.04); + top: -40px; + right: -40px; +} + +.aiCard::after { + content: ''; + position: absolute; + width: 80px; + height: 80px; + border-radius: 50%; + background: rgba(240,101,149,0.04); + bottom: -20px; + left: 40px; +} + +.aiCard:active { transform: scale(0.985); } + +.aiHeader { display: flex; align-items: center; gap: 12px; - padding: 14px 16px; - background: var(--color-white); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-sm); - font-size: var(--font-size-base); - font-weight: 600; - -webkit-tap-highlight-color: transparent; - transition: background 0.15s; + position: relative; + z-index: 1; } -.linkCard:active { background: #FAFBFC; } +.aiAvatar { + width: 50px; + height: 50px; + border-radius: 16px; + background: var(--color-primary-gradient, linear-gradient(135deg, #4F6EF7 0%, #845EF7 100%)); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + box-shadow: 0 4px 14px rgba(79,110,247,0.25); +} + +.aiTitleBlock { flex: 1; } + +.aiTitle { + font-size: 17px; + font-weight: 700; + color: var(--color-text-primary, #1A1D28); + display: block; +} + +.aiSubtitle { + font-size: 11px; + color: var(--color-text-tertiary, #9BA0B4); + display: block; + margin-top: 3px; +} + +.aiBadge { + background: var(--color-primary-bg, #EDF0FD); + color: var(--color-primary, #4F6EF7); + font-size: 10px; + font-weight: 600; + padding: 4px 12px; + border-radius: 20px; + letter-spacing: 0.5px; + z-index: 1; +} + +.aiDivider { + height: 1px; + background: rgba(79,110,247,0.08); + margin: 0 4px; + position: relative; + z-index: 1; +} + +.aiQuestionList { + display: flex; + flex-direction: column; + gap: 8px; + position: relative; + z-index: 1; +} + +.aiQuestion { + display: flex; + align-items: center; + gap: 10px; + padding: 11px 14px; + background: #fff; + border: 1px solid rgba(79,110,247,0.06); + border-radius: 14px; + font-size: 13px; + color: #3D4A6B; + text-align: left; + transition: all 0.2s; + line-height: 1.45; + box-shadow: 0 1px 4px rgba(0,0,0,0.02); +} + +.aiQuestion:hover { + background: #F9FAFF; + border-color: rgba(79,110,247,0.15); +} + +.aiDot { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--color-primary, #4F6EF7); + flex-shrink: 0; + opacity: 0.5; + margin-top: 1px; +} + +.inputHint { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + background: #fff; + border: 1.5px dashed rgba(79,110,247,0.15); + border-radius: 14px; + font-size: 13px; + color: var(--color-text-tertiary, #9BA0B4); + position: relative; + z-index: 1; + transition: all 0.2s; +} + +.inputHint:hover { + border-color: rgba(79,110,247,0.3); + background: #F9FAFF; +} + +.inputHintIcon { + width: 20px; + height: 20px; + opacity: 0.5; + flex-shrink: 0; +} diff --git a/frontend-patient/src/pages/health/HealthHubPage.tsx b/frontend-patient/src/pages/health/HealthHubPage.tsx index 9b6199f..ad090dc 100644 --- a/frontend-patient/src/pages/health/HealthHubPage.tsx +++ b/frontend-patient/src/pages/health/HealthHubPage.tsx @@ -2,117 +2,34 @@ import { useNavigate } from 'react-router-dom'; import { PageHeader } from '@/components/layout/PageHeader'; import styles from './HealthHubPage.module.css'; -const HEALTH_ITEMS = [ - { - path: '/health/records?type=blood_pressure', - label: '血压', - desc: '记录和趋势', - svg: ( - - - - - ), - bg: '#FEE9E9', - }, - { - path: '/health/records?type=heart_rate', - label: '心率', - desc: '记录和趋势', - svg: ( - - - - ), - bg: '#FFF4E5', - }, - { - path: '/health/records?type=blood_sugar', - label: '血糖', - desc: '记录和趋势', - svg: ( - - - - - ), - bg: '#F3E8FF', - }, - { - path: '/health/records?type=spo2', - label: '血氧', - desc: '记录和趋势', - svg: ( - - - - - - ), - bg: '#E6F0FF', - }, - { - path: '/health/records?type=weight', - label: '体重', - desc: '记录和趋势', - svg: ( - - - - - - ), - bg: '#E6F9F2', - }, - { - path: '/health/records?type=steps', - label: '步数', - desc: '记录和趋势', - svg: ( - - - - - ), - bg: '#EEF2FF', - }, -]; +const COMBINED = { + path: '/health/records/add', + label: '健康指标', + desc: '血压 · 心率 · 血糖 · 血氧 · 体重', + indicators: [ + { label: '血压', color: '#EF4444', bg: '#FEE9E9' }, + { label: '心率', color: '#F59E0B', bg: '#FFF4E5' }, + { label: '血糖', color: '#845EF7', bg: '#F3E8FF' }, + { label: '血氧', color: '#339AF0', bg: '#E6F0FF' }, + { label: '体重', color: '#20C997', bg: '#E6F9F2' }, + ], +}; const QUICK_LINKS = [ { - label: '健康日历', - path: '/health/calendar', - svg: ( - - - - - - - ), - bg: '#FFF0E0', + label: '健康日历', path: '/health/calendar', + color: '#F59E0B', bg: '#FFF8E6', + svg: (), }, { - label: '服药管理', - path: '/health/medications', - svg: ( - - - - - ), - bg: '#FFF4E5', + label: '服药管理', path: '/health/medications', + color: '#D67E0B', bg: '#FFF4E5', + svg: (), }, { - label: '运动饮食', - path: '/health/exercise-diet', - svg: ( - - - - - ), - bg: '#E6F9F2', + label: '运动饮食', path: '/health/exercise-diet', + color: '#20C997', bg: '#E6F9F2', + svg: (), }, ]; @@ -122,25 +39,81 @@ export function HealthHubPage() { return (
-
- {HEALTH_ITEMS.map((item) => ( - + + {/* Quick links — horizontal row */} +
+ {QUICK_LINKS.map((link) => ( + ))}
-
- {QUICK_LINKS.map((link) => ( - - ))} + +
+ +
+ + + + + 输入你的健康问题,AI 为你分析... +
); diff --git a/frontend-patient/src/pages/health/ManualEntryPage.module.css b/frontend-patient/src/pages/health/ManualEntryPage.module.css index 8cb1683..2b5fb19 100644 --- a/frontend-patient/src/pages/health/ManualEntryPage.module.css +++ b/frontend-patient/src/pages/health/ManualEntryPage.module.css @@ -1,16 +1,152 @@ -.form { +.cards { display: flex; flex-direction: column; - gap: 16px; - padding: 16px 0; + gap: 10px; } -.bpRow { - display: flex; - gap: 12px; +.card { + background: #fff; + border-radius: 14px; + padding: 12px 14px; + box-shadow: 0 1px 6px rgba(0,0,0,0.04); } -.row { +.cardHeader { display: flex; - gap: 12px; + align-items: center; + gap: 6px; + margin-bottom: 8px; } + +.cardIcon { font-size: 16px; } + +.cardTitle { + font-size: 14px; + font-weight: 700; +} + +.cardUnit { + font-size: 10px; + color: #9BA0B4; + background: #F5F6F9; + padding: 1px 6px; + border-radius: 5px; + margin-left: auto; +} + +.cardInputs { + display: flex; + gap: 6px; + align-items: center; +} + +.cardInput { + flex: 1; + min-width: 0; + padding: 9px 10px; + border: 1px solid #E8ECF2; + border-radius: 10px; + font-size: 14px; + background: #FAFBFC; + color: #1A1D28; + outline: none; +} + +.cardInput:focus { border-color: #C8CDD5; } + +.cardInput::placeholder { + color: #B0B8C1; + font-size: 12px; +} + +/* Date chip */ +.dateChip { + display: flex; + align-items: center; + gap: 4px; + padding: 0; + border: none; + border-radius: 10px; + flex-shrink: 0; + position: relative; +} + +.dateInput { + display: flex; + align-items: center; + gap: 4px; + padding: 7px 8px; + border: 1px solid #E8ECF2; + border-radius: 10px; + background: #FAFBFC; + color: #5A6072; + font-size: 12px; + font-weight: 500; + cursor: pointer; + outline: none; + width: 100%; + transition: border-color 0.15s; + -webkit-appearance: none; + appearance: none; +} + +.dateInput::-webkit-calendar-picker-indicator { + opacity: 0; + position: absolute; + inset: 0; + width: 100%; + height: 100%; + cursor: pointer; +} + +.dateInput::-webkit-datetime-edit-fields-wrapper { + padding: 0; +} + +.dateInput::-webkit-datetime-edit-text { + padding: 0 1px; +} + +/* Check button */ +.checkBtn { + width: 36px; + height: 36px; + border: none; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + transition: opacity 0.15s; +} + +.checkBtn:active { opacity: 0.8; } +.checkBtn:disabled { opacity: 0.4; } + +/* Trend card */ +.trendCard { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + margin-top: 24px; + padding: 16px; + background: #fff; + border: none; + border-radius: 16px; + box-shadow: 0 1px 8px rgba(0,0,0,0.04); + cursor: pointer; + text-align: left; + transition: transform 0.15s; +} + +.trendCard:active { transform: scale(0.985); } + +.trendIcon { font-size: 24px; flex-shrink: 0; } + +.trendInfo { flex: 1; } +.trendTitle { font-size: 15px; font-weight: 700; color: #1A1D28; display: block; } +.trendSub { font-size: 11px; color: #9BA0B4; margin-top: 2px; display: block; } + +.trendArrow { font-size: 22px; color: #C0C5D2; flex-shrink: 0; } diff --git a/frontend-patient/src/pages/health/ManualEntryPage.tsx b/frontend-patient/src/pages/health/ManualEntryPage.tsx index 2db9912..9c2f438 100644 --- a/frontend-patient/src/pages/health/ManualEntryPage.tsx +++ b/frontend-patient/src/pages/health/ManualEntryPage.tsx @@ -1,87 +1,135 @@ import { useState } from 'react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; -import { Button } from '@/components/common/Button'; -import { Input } from '@/components/common/Input'; +import { useNavigate } from 'react-router-dom'; import { PageHeader } from '@/components/layout/PageHeader'; import { ToastContainer, toast } from '@/components/common/Toast'; import * as healthService from '@/services/health.service'; -import { MEASUREMENT_TYPES } from '@/utils/constants'; -import type { MeasurementType } from '@/types'; import dayjs from 'dayjs'; import styles from './ManualEntryPage.module.css'; +const INDICATORS = [ + { + type: 'blood_pressure' as const, label: '血压', color: '#DC4A4A', + icon: '🩺', multi: true, fields: [ + { key: 'systolic', placeholder: '收缩压', hint: '120' }, + { key: 'diastolic', placeholder: '舒张压', hint: '80' }, + ], unit: 'mmHg', chartLabel: '血压', + }, + { + type: 'heart_rate' as const, label: '心率', color: '#D68B20', + icon: '💓', fields: [{ key: 'heart_rate', placeholder: '心率', hint: '72' }], unit: 'bpm', chartLabel: '心率', + }, + { + type: 'blood_sugar' as const, label: '血糖', color: '#7C5CE7', + icon: '🩸', fields: [{ key: 'blood_sugar', placeholder: '血糖', hint: '5.6' }], unit: 'mmol/L', chartLabel: '血糖', + }, + { + type: 'spo2' as const, label: '血氧', color: '#3B8ED4', + icon: '🫁', fields: [{ key: 'spo2', placeholder: '血氧', hint: '98' }], unit: '%', chartLabel: '血氧', + }, + { + type: 'weight' as const, label: '体重', color: '#3DAF86', + icon: '⚖️', fields: [{ key: 'weight', placeholder: '体重', hint: '70.5' }], unit: 'kg', chartLabel: '体重', + }, +]; + export function ManualEntryPage() { const navigate = useNavigate(); - const [searchParams] = useSearchParams(); - const type = (searchParams.get('type') || 'blood_pressure') as MeasurementType; - const config = MEASUREMENT_TYPES[type]; - const [systolic, setSystolic] = useState(''); - const [diastolic, setDiastolic] = useState(''); - const [value, setValue] = useState(''); - const [date, setDate] = useState(dayjs().format('YYYY-MM-DD')); - const [time, setTime] = useState(dayjs().format('HH:mm')); - const [loading, setLoading] = useState(false); + const [values, setValues] = useState>({ + systolic: '', diastolic: '', heart_rate: '', blood_sugar: '', spo2: '', weight: '', + }); + const [dates, setDates] = useState>( + Object.fromEntries(INDICATORS.map((i) => [i.type, dayjs().format('YYYY-MM-DD')])), + ); + const [loading, setLoading] = useState(null); - const handleSubmit = async () => { - const numVal = parseFloat(value); - if (type === 'blood_pressure') { - const sys = parseFloat(systolic); - const dia = parseFloat(diastolic); - if (!sys || !dia) { toast('请填写完整', 'error'); return; } - await healthService.addRecord({ - type, - value: { systolic: sys, diastolic: dia }, - unit: 'mmHg', - recordedAt: `${date}T${time}:00`, - recordedDate: date, - source: 'manual', - }); - } else { - if (!numVal) { toast('请填写数值', 'error'); return; } - await healthService.addRecord({ - type, - value: numVal, - unit: config.unit, - recordedAt: `${date}T${time}:00`, - recordedDate: date, - source: 'manual', - }); + const setVal = (key: string, v: string) => setValues((prev) => ({ ...prev, [key]: v })); + + const saveOne = async (type: string) => { + setLoading(type); + try { + const d = dates[type] || dayjs().format('YYYY-MM-DD'); + const recordedAt = `${d}T${dayjs().format('HH:mm:ss')}`; + const ind = INDICATORS.find((i) => i.type === type)!; + + if (type === 'blood_pressure') { + const sys = parseFloat(values.systolic); + const dia = parseFloat(values.diastolic); + if (!sys || !dia) { toast('请填写收缩压和舒张压', 'error'); return; } + await healthService.addRecord({ type, value: { systolic: sys, diastolic: dia }, unit: 'mmHg', recordedAt, recordedDate: d, source: 'manual' }); + } else { + const v = parseFloat(values[ind.fields[0].key]); + if (!v) { toast('请填写数值', 'error'); return; } + await healthService.addRecord({ type, value: v, unit: ind.unit, recordedAt, recordedDate: d, source: 'manual' }); + } + toast(`${ind.label} 已保存`); + } catch { + toast('保存失败', 'error'); + } finally { + setLoading(null); } - toast('记录成功'); - setTimeout(() => navigate(-1), 500); }; return (
- -
- {type === 'blood_pressure' ? ( - <> -
- setSystolic(e.target.value)} type="number" /> - setDiastolic(e.target.value)} type="number" /> + + +
+ {INDICATORS.map((ind) => ( +
+
+ {ind.icon} + {ind.label} + {ind.unit}
- - ) : ( - setValue(e.target.value)} - type="number" - step="0.1" - /> - )} -
- setDate(e.target.value)} type="date" /> - setTime(e.target.value)} type="time" /> -
- - +
+ {ind.fields.map((f) => ( + setVal(f.key, e.target.value)} + /> + ))} +
+ setDates((prev) => ({ ...prev, [ind.type]: e.target.value }))} + /> +
+ +
+
+ ))}
+ + {/* Trend entry */} + +
); diff --git a/frontend-patient/src/pages/health/TrendChartPage.module.css b/frontend-patient/src/pages/health/TrendChartPage.module.css index ebd4465..3888bd2 100644 --- a/frontend-patient/src/pages/health/TrendChartPage.module.css +++ b/frontend-patient/src/pages/health/TrendChartPage.module.css @@ -6,14 +6,46 @@ .periodBtn { padding: 6px 14px; - border-radius: var(--radius-full); - font-size: var(--font-size-sm); - background: var(--color-bg-secondary); - color: var(--color-text-secondary); + border-radius: 20px; + border: none; + font-size: 13px; + font-weight: 500; + background: #F5F6F9; + color: #6B7280; + cursor: pointer; transition: all 0.2s; } .active { - background: var(--color-primary); - color: var (--color-text-inverse); + background: #4F6EF7; + color: #fff; } + +.toggleBar { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; +} + +.toggleBtn { + display: flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + border-radius: 20px; + border: 1.5px solid; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + background: #fff; +} + +.toggleDot { + width: 8px; + height: 8px; + border-radius: 50%; + transition: background 0.2s; +} + diff --git a/frontend-patient/src/pages/health/TrendChartPage.tsx b/frontend-patient/src/pages/health/TrendChartPage.tsx index 6406b17..5304152 100644 --- a/frontend-patient/src/pages/health/TrendChartPage.tsx +++ b/frontend-patient/src/pages/health/TrendChartPage.tsx @@ -1,14 +1,20 @@ -import { useEffect, useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { useEffect, useState, useMemo } from 'react'; import { PageHeader } from '@/components/layout/PageHeader'; -import { LineChart } from '@/components/charts/LineChart'; +import { MultiLineChart, type SeriesData } from '@/components/charts/MultiLineChart'; import { Empty } from '@/components/common/Empty'; -import { MEASUREMENT_TYPES } from '@/utils/constants'; import * as healthService from '@/services/health.service'; -import type { HealthRecord, MeasurementType } from '@/types'; -import { Button } from '@/components/common/Button'; +import type { HealthRecord } from '@/types'; import styles from './TrendChartPage.module.css'; +const INDICATORS = [ + { type: 'bp_systolic' as const, label: '收缩压', color: '#DC4A4A', unit: 'mmHg', source: 'blood_pressure' as const, field: 'systolic' as const }, + { type: 'bp_diastolic' as const, label: '舒张压', color: '#E0558A', unit: 'mmHg', source: 'blood_pressure' as const, field: 'diastolic' as const }, + { type: 'heart_rate' as const, label: '心率', color: '#D68B20', unit: 'bpm' }, + { type: 'blood_sugar' as const, label: '血糖', color: '#7C5CE7', unit: 'mmol/L' }, + { type: 'spo2' as const, label: '血氧', color: '#3B8ED4', unit: '%' }, + { type: 'weight' as const, label: '体重', color: '#3DAF86', unit: 'kg' }, +]; + const PERIODS = [ { label: '7天', days: 7 }, { label: '14天', days: 14 }, @@ -17,25 +23,60 @@ const PERIODS = [ ]; export function TrendChartPage() { - const { type } = useParams<{ type: MeasurementType }>(); - const config = MEASUREMENT_TYPES[type || 'blood_pressure']; - const [records, setRecords] = useState([]); + const [visible, setVisible] = useState>(new Set(INDICATORS.map((i) => i.type))); const [period, setPeriod] = useState(30); + const [allRecords, setAllRecords] = useState([]); useEffect(() => { - if (type) healthService.getTrendData(type, period).then(setRecords); - }, [type, period]); + const sources = [...new Set(INDICATORS.map((i) => (i as Record).source || i.type))]; + Promise.all(sources.map((s) => healthService.getTrendData(s as Parameters[0], period))) + .then((results) => setAllRecords(results.flat())); + }, [period]); - const isBP = type === 'blood_pressure'; - const chartData = records.map((r) => ({ - date: r.recordedDate, - value: isBP ? (typeof r.value === 'object' ? r.value.systolic : 0) : (r.value as number), - value2: isBP ? (typeof r.value === 'object' ? r.value.diastolic : 0) : undefined, - })); + const series: SeriesData[] = useMemo(() => { + const result: SeriesData[] = []; + for (const ind of INDICATORS) { + if (!visible.has(ind.type)) continue; + const sourceType = (ind as Record).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)[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 (
- + + + {/* Period selector */}
{PERIODS.map((p) => (
- {chartData.length > 0 ? ( - + + {/* Toggle indicators */} +
+ {INDICATORS.map((ind) => ( + + ))} +
+ + {hasData ? ( + ) : ( )} +
); } diff --git a/frontend-patient/src/pages/home/HomePage.tsx b/frontend-patient/src/pages/home/HomePage.tsx index 581dacc..569c961 100644 --- a/frontend-patient/src/pages/home/HomePage.tsx +++ b/frontend-patient/src/pages/home/HomePage.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useMemo } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useLocation } from 'react-router-dom'; import { Card } from '@/components/common/Card'; import { useAuth } from '@/hooks/useAuth'; import { useNotificationStore } from '@/stores/notification.store'; @@ -120,6 +120,7 @@ const QUICK_ACTIONS = [ export function HomePage() { const navigate = useNavigate(); + const location = useLocation(); const { user } = useAuth(); const { unreadCount, fetchNotifications } = useNotificationStore(); const [stats, setStats] = useState([]); @@ -131,7 +132,7 @@ export function HomePage() { api.get('/api/medications/today-summary') .then((r) => setMeds(r.data)) .catch(() => {}); - }, [fetchNotifications]); + }, [fetchNotifications, location.pathname]); const bpStats = stats.find((s) => s.type === 'blood_pressure'); const hrStats = stats.find((s) => s.type === 'heart_rate'); diff --git a/frontend-patient/src/pages/services/ChatPage.tsx b/frontend-patient/src/pages/services/ChatPage.tsx index 6304496..3dd35d5 100644 --- a/frontend-patient/src/pages/services/ChatPage.tsx +++ b/frontend-patient/src/pages/services/ChatPage.tsx @@ -62,7 +62,7 @@ export function ChatPage() { // Set up SignalR connection const conn = new HubConnectionBuilder() - .withUrl('http://localhost:5000/hubs/chat', { + .withUrl('/hubs/chat', { accessTokenFactory: () => getToken(), }) .withAutomaticReconnect() diff --git a/frontend-patient/src/pages/services/ReportDetailPage.tsx b/frontend-patient/src/pages/services/ReportDetailPage.tsx index b809d2f..8e3550c 100644 --- a/frontend-patient/src/pages/services/ReportDetailPage.tsx +++ b/frontend-patient/src/pages/services/ReportDetailPage.tsx @@ -53,7 +53,7 @@ export function ReportDetailPage() {
报告图片
{report.imageUrls.map((url, i) => ( - report ))}
diff --git a/frontend-patient/src/pages/services/ReportUploadPage.tsx b/frontend-patient/src/pages/services/ReportUploadPage.tsx index 014f7b6..83b179e 100644 --- a/frontend-patient/src/pages/services/ReportUploadPage.tsx +++ b/frontend-patient/src/pages/services/ReportUploadPage.tsx @@ -38,7 +38,7 @@ export function ReportUploadPage() { const formData = new FormData(); formData.append('file', file); const token = JSON.parse(localStorage.getItem('hrt_auth') || '{}')?.state?.token; - const res = await fetch('http://localhost:5000/api/files/upload', { + const res = await fetch('/api/files/upload', { method: 'POST', headers: token ? { 'Authorization': `Bearer ${token}` } : {}, body: formData, diff --git a/frontend-patient/src/router/index.tsx b/frontend-patient/src/router/index.tsx index 52374e4..bbfb167 100644 --- a/frontend-patient/src/router/index.tsx +++ b/frontend-patient/src/router/index.tsx @@ -71,7 +71,7 @@ export const router = createBrowserRouter([ { path: 'home/device-binding', element: }, { path: 'health/records', element: }, { path: 'health/records/add', element: }, - { path: 'health/trends/:type', element: }, + { path: 'health/trends', element: }, { path: 'health/calendar', element: }, { path: 'health/medications', element: }, { path: 'health/medications/add', element: }, diff --git a/frontend-patient/src/services/api-client.ts b/frontend-patient/src/services/api-client.ts index 8988390..23b56a7 100644 --- a/frontend-patient/src/services/api-client.ts +++ b/frontend-patient/src/services/api-client.ts @@ -1,15 +1,11 @@ -/** - * Real HTTP API client — replaces mockApiResponse with actual fetch calls. - * Backend base: http://localhost:5000 - */ - +// API client — uses Vite proxy in dev, same-origin in production. interface ApiResponse { code: number; data: T; message: string; } -const BASE_URL = 'http://localhost:5000'; +const BASE_URL = ''; // Endpoints that should NEVER include auth token const PUBLIC_ENDPOINTS = ['/api/auth/login', '/api/auth/register', '/api/auth/send-sms', '/api/auth/refresh']; diff --git a/frontend-patient/src/services/exercise-diet.service.ts b/frontend-patient/src/services/exercise-diet.service.ts index a8c29a3..9f1ca83 100644 --- a/frontend-patient/src/services/exercise-diet.service.ts +++ b/frontend-patient/src/services/exercise-diet.service.ts @@ -93,6 +93,14 @@ export async function addDietLog(data: Omit): Promi }; } +export async function deleteExerciseLog(id: string): Promise { + await api.del(`/api/health-records/${id}`); +} + +export async function deleteDietLog(id: string): Promise { + await api.del(`/api/health-records/${id}`); +} + export function getExerciseRecommendations() { return EXERCISE_RECOMMENDATIONS; } diff --git a/frontend-patient/vite.config.ts b/frontend-patient/vite.config.ts index 06de9b0..339f7d8 100644 --- a/frontend-patient/vite.config.ts +++ b/frontend-patient/vite.config.ts @@ -12,5 +12,9 @@ export default defineConfig({ server: { host: '0.0.0.0', port: 5173, + proxy: { + '/api': 'http://localhost:5000', + '/hubs': { target: 'http://localhost:5000', ws: true }, + }, }, })