feat: medication reminders, follow-up/visit separation, health record page

Backend:
- MedicationService: today-summary with missed detection (local time)
- FollowUpService: doctor-initiated follow-ups filter, AddAsync supports Notes
- FollowUpController: type query param (followup/recheck)
- MedicationController: today-summary endpoint
- Auth: UpdateProfileRequest→class, StentDate/StentType, soft-delete fix

Patient frontend:
- HomePage: date display, medication reminder cards with missed status
- MedicationListPage: beautified with delete button, slot preview
- MedicationDetailPage: redesigned with progress bars, new CSS
- ProfilePage: beautified menu icons, health record link
- HealthRecordPage: new page with indicators, history, meds, reports
- ServicesHub: added doctor-visit card
- VisitListPage: doctor-initiated follow-ups view
- EditProfilePage: removed height/weight, added stent fields
- Fixed getProfile field mappings (nickname, height, weight, stent)

Doctor frontend:
- Layout: added 随访管理 sidebar item with SVG icon
- FollowUpListPage: recheck-only filter, complete/delete buttons, collapsed completed
- VisitListPage/EditPage: doctor follow-up management
- PatientListPage: added stentType column
- Dashboard: fixed pending reports endpoint
- ReportListPage/DetailPage: fixed uploadedAt field
- ChatPage: SignalR real-time, dynamic hostname
This commit is contained in:
MingNian
2026-05-25 14:48:05 +08:00
parent db443b258e
commit 39ab6062b5
33 changed files with 1657 additions and 238 deletions

View File

@@ -1,18 +1,22 @@
.greetingBar {
padding: 8px 0 16px;
padding: 12px 0 16px;
display: flex;
justify-content: space-between;
justify-content: center;
align-items: center;
position: relative;
}
.greetingText {
font-size: 22px;
font-weight: 800;
color: var(--color-text-primary);
.dateText {
font-size: 15px;
font-weight: 600;
color: var(--color-text-secondary);
}
.notifyBtn {
position: relative;
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
width: 44px;
height: 44px;
display: flex;
@@ -206,11 +210,143 @@
}
.quickLabel {
font-size: 11px;
color: var(--color-text-secondary);
font-size: 12px;
color: var(--color-text-primary);
font-weight: 600;
}
/* Today's Medications */
/* Today's Medications */
.medSection {
margin-bottom: 20px;
}
.medSectionTitle {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
font-size: 17px;
font-weight: 700;
color: var(--color-text-primary);
}
.medTitleIcon {
width: 36px;
height: 36px;
border-radius: 12px;
background: var(--color-primary-gradient);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(79,110,247,0.3);
}
.medTitleCount {
margin-left: auto;
font-size: 12px;
font-weight: 600;
color: var(--color-primary);
background: #EDF0FD;
padding: 4px 12px;
border-radius: 20px;
}
.medCard {
background: var(--color-white);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: 18px;
margin-bottom: 10px;
cursor: pointer;
transition: transform 0.15s;
}
.medCard:active {
transform: scale(0.98);
}
.medHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 10px;
}
.medNameGroup {
display: flex;
flex-direction: column;
gap: 4px;
}
.medName {
font-size: 16px;
font-weight: 700;
color: var(--color-text-primary);
}
.medDosage {
font-size: 12px;
font-weight: 500;
color: var(--color-text-tertiary);
}
.medStatus {
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: 20px;
}
.medStatusDone { background: var(--color-success-bg); color: #0D8A5E; }
.medStatusPending { background: var(--color-primary-bg); color: var(--color-primary); }
.medStatusMissed { background: var(--color-danger-bg); color: #D53131; }
.medSlots {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.medSlot {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 10px;
border-radius: 8px;
font-size: 12px;
font-weight: 500;
}
.medSlotTaken { background: var(--color-success-bg); color: #0D8A5E; }
.medSlotTodo { background: var(--color-bg); color: var(--color-text-secondary); }
.medSlotMissed { background: var(--color-danger-bg); color: #D53131; }
.medSlotIcon {
width: 14px;
height: 14px;
opacity: 0.6;
}
.medSlotDot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.medSlotDotTaken { background: var(--color-success); }
.medSlotDotTodo { background: var(--color-text-tertiary); }
.medSlotDotMissed { background: var(--color-danger); }
.medEmpty {
text-align: center;
padding: 32px 24px;
color: var(--color-text-tertiary);
font-size: 13px;
background: var(--color-white);
border-radius: var(--radius-lg);
}
/* Health Tip */
.tipCard {
margin-bottom: 16px;

View File

@@ -1,12 +1,18 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card } from '@/components/common/Card';
import { useAuth } from '@/hooks/useAuth';
import { useNotificationStore } from '@/stores/notification.store';
import { api } from '@/services/api-client';
import * as healthService from '@/services/health.service';
import type { HealthStats } from '@/types';
import styles from './HomePage.module.css';
interface MedSlot { time: string; taken: boolean; missed: boolean; takenAt?: string }
interface MedSummary { id: string; drugName: string; dosage: string; frequency: string; slots: MedSlot[]; allTaken: boolean }
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'];
const QUICK_ACTIONS = [
{
key: 'bp',
@@ -117,10 +123,14 @@ export function HomePage() {
const { user } = useAuth();
const { unreadCount, fetchNotifications } = useNotificationStore();
const [stats, setStats] = useState<HealthStats[]>([]);
const [meds, setMeds] = useState<MedSummary[]>([]);
useEffect(() => {
healthService.getLatestStats().then(setStats);
fetchNotifications();
api.get<MedSummary[]>('/api/medications/today-summary')
.then((r) => setMeds(r.data))
.catch(() => {});
}, [fetchNotifications]);
const bpStats = stats.find((s) => s.type === 'blood_pressure');
@@ -132,6 +142,11 @@ export function HomePage() {
const systolic = typeof bpValue === 'object' ? bpValue.systolic : null;
const diastolic = typeof bpValue === 'object' ? bpValue.diastolic : null;
const todayDate = useMemo(() => {
const d = new Date();
return `${d.getMonth() + 1}${d.getDate()}日 星期${WEEKDAYS[d.getDay()]}`;
}, []);
const bpAbnormal = systolic !== null && diastolic !== null
&& (systolic >= 120 || diastolic >= 80);
@@ -147,7 +162,7 @@ export function HomePage() {
return (
<div className="page" style={{ paddingTop: 0 }}>
<div className={styles.greetingBar}>
<div className={styles.greetingText}>{user?.nickname || '用户'}</div>
<div className={styles.dateText}>{todayDate}</div>
<button onClick={() => navigate('/notifications')} className={styles.notifyBtn}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-secondary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
@@ -209,6 +224,49 @@ export function HomePage() {
</button>
))}
</div>
<div className={styles.medSection}>
<div className={styles.medSectionTitle}>
<span className={styles.medTitleIcon}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="4" y="5" width="16" height="14" rx="4" />
<path d="M10 9v6M14 9v6" />
</svg>
</span>
{meds.length > 0 && <span className={styles.medTitleCount}>{meds.length}</span>}
</div>
{meds.length === 0 ? (
<div className={styles.medEmpty}></div>
) : (
meds.map((med) => (
<Card key={med.id} className={styles.medCard} onClick={() => navigate(`/health/medications/${med.id}`)}>
<div className={styles.medHeader}>
<div className={styles.medNameGroup}>
<span className={styles.medName}>{med.drugName}</span>
<span className={styles.medDosage}>{med.dosage} · {med.frequency}</span>
</div>
<span className={`${styles.medStatus} ${med.allTaken ? styles.medStatusDone : med.slots.some(s => s.missed) ? styles.medStatusMissed : styles.medStatusPending}`}>
{med.allTaken ? '已完成' : med.slots.some(s => s.missed) ? '有漏服' : '待服用'}
</span>
</div>
<div className={styles.medSlots}>
{med.slots.map((slot) => (
<div key={slot.time} className={`${styles.medSlot} ${slot.taken ? styles.medSlotTaken : slot.missed ? styles.medSlotMissed : styles.medSlotTodo}`}>
<span className={`${styles.medSlotDot} ${slot.taken ? styles.medSlotDotTaken : slot.missed ? styles.medSlotDotMissed : styles.medSlotDotTodo}`} />
<svg className={styles.medSlotIcon} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
{slot.time}
{slot.missed && !slot.taken && <span style={{ fontSize: 10, opacity: 0.7, marginLeft: 2 }}></span>}
</div>
))}
</div>
</Card>
))
)}
</div>
</div>
);
}

View File

@@ -1,25 +1,234 @@
.infoCard { margin-bottom: 12px; }
.infoCard { margin-bottom: 14px; padding: 20px; }
.infoTitle { font-size: var(--font-size-lg); font-weight: 700; margin-bottom: 12px; }
.infoRow {
.heroHeader {
display: flex;
justify-content: space-between;
padding: 8px 0;
font-size: var(--font-size-sm);
border-bottom: 1px solid var(--color-divider);
align-items: flex-start;
gap: 16px;
margin-bottom: 20px;
}
.heroIcon {
width: 52px;
height: 52px;
border-radius: 16px;
background: var(--color-primary-gradient);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4px 16px rgba(79,110,247,0.3);
}
.heroInfo { flex: 1; }
.heroName {
font-size: 18px;
font-weight: 800;
color: var(--color-text-primary);
margin-bottom: 4px;
}
.heroMeta {
font-size: 13px;
color: var(--color-text-secondary);
}
.activeBadge { color: var(--color-success); font-weight: 600; }
.infoGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.adherenceCard { text-align: center; }
.infoItem {
background: var(--color-bg);
border-radius: 12px;
padding: 12px;
}
.adherenceTitle { font-size: var(--font-size-sm); color: var(--color-text-secondary); margin-bottom: 4px; }
.adherenceRate {
font-size: var(--font-size-3xl);
font-weight: 800;
color: var(--color-success);
.infoLabel {
font-size: 11px;
color: var(--color-text-tertiary);
margin-bottom: 4px;
}
.infoValue {
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
}
/* Today tracking */
.todayTitle {
font-size: 16px;
font-weight: 700;
margin-bottom: 4px;
color: var(--color-text-primary);
}
.todayDate {
font-size: 12px;
color: var(--color-text-tertiary);
margin-bottom: 14px;
}
.slotRow {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px;
border-radius: 14px;
margin-bottom: 8px;
}
.slotRowTaken {
background: linear-gradient(135deg, #ECFDF5, #F0FFF4);
border: 1px solid #A7F3D0;
}
.slotRowPending {
background: var(--color-bg);
border: 1.5px dashed var(--color-border);
}
.slotLeft {
display: flex;
align-items: center;
gap: 12px;
}
.slotCircle {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
}
.slotCircleTaken {
background: var(--color-success);
color: #fff;
}
.slotCirclePending {
background: var(--color-white);
color: var(--color-primary);
border: 2px solid var(--color-primary);
}
.slotTime {
font-size: 16px;
font-weight: 700;
color: var(--color-text-primary);
}
.slotLabel {
font-size: 12px;
color: var(--color-text-tertiary);
}
.todaySummary {
margin-top: 14px;
padding: 14px 16px;
background: var(--color-bg);
border-radius: 12px;
display: flex;
align-items: center;
}
.todayProgress {
font-size: 13px;
font-weight: 600;
color: var(--color-text-secondary);
white-space: nowrap;
}
.todayProgressBar {
flex: 1;
height: 6px;
border-radius: 3px;
background: var(--color-border);
margin: 0 12px;
overflow: hidden;
}
.todayProgressFill {
height: 100%;
border-radius: 3px;
background: var(--color-primary-gradient);
transition: width 0.3s;
}
/* 7-day chart */
.chartTitle {
font-size: 16px;
font-weight: 700;
margin-bottom: 14px;
color: var(--color-text-primary);
}
.chartBars {
display: flex;
gap: 6px;
align-items: flex-end;
margin-bottom: 6px;
}
.chartBarWrap {
flex: 1;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.chartBar {
width: 100%;
max-width: 32px;
border-radius: 8px;
min-height: 8px;
}
.chartBarFull {
background: var(--color-primary-gradient);
}
.chartBarPartial {
background: linear-gradient(180deg, #4F6EF7 0%, #B8C4FD 100%);
}
.chartBarEmpty {
background: var(--color-border);
}
.chartDate {
font-size: 10px;
color: var(--color-text-tertiary);
}
.chartLegend {
display: flex;
gap: 14px;
margin-top: 10px;
font-size: 11px;
color: var(--color-text-tertiary);
}
.chartLegendItem {
display: flex;
align-items: center;
gap: 4px;
}
.chartLegendDot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.chartLegendDotFull { background: var(--color-primary); }
.chartLegendDotPartial { background: #B8C4FD; }
.chartLegendDotEmpty { background: var(--color-border); }

View File

@@ -27,7 +27,6 @@ export function MedicationDetailPage() {
const today = new Date().toISOString().split('T')[0];
const todayRecords = records.filter((r) => r.takenAt?.startsWith(today) || !r.takenAt);
const todayTaken = todayRecords.filter((r) => r.isTaken);
const todaySlots = med?.timeSlots || [];
const handleMarkTaken = async (slot: string) => {
@@ -47,7 +46,6 @@ export function MedicationDetailPage() {
const slotTaken = (slot: string) => todayRecords.some((r) => r.timeSlot === slot && r.isTaken);
// Recent 7 days adherence
const last7Days: { date: string; taken: number; total: number }[] = [];
for (let i = 6; i >= 0; i--) {
const d = new Date();
@@ -60,71 +58,116 @@ export function MedicationDetailPage() {
return (
<div className="page--no-tab">
<PageHeader title={med.drugName} />
<Card className={styles.infoCard}>
<div className={styles.infoTitle}>{med.drugName}</div>
<div className={styles.infoRow}><span></span><span>{med.dosage}</span></div>
<div className={styles.infoRow}><span></span><span>{med.frequency} · {med.timeSlots.join(', ')}</span></div>
<div className={styles.infoRow}><span></span><span>{med.startDate} ~ {med.endDate || '长期'}</span></div>
{med.notes && <div className={styles.infoRow}><span></span><span>{med.notes}</span></div>}
<div className={styles.heroHeader}>
<div className={styles.heroIcon}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="4" y="5" width="16" height="14" rx="4" />
<path d="M10 9v6M14 9v6" />
</svg>
</div>
<div className={styles.heroInfo}>
<div className={styles.heroName}>{med.drugName}</div>
<div className={styles.heroMeta}>{med.dosage} · {med.frequency}</div>
</div>
</div>
<div className={styles.infoGrid}>
<div className={styles.infoItem}>
<div className={styles.infoLabel}></div>
<div className={styles.infoValue}>{med.dosage}</div>
</div>
<div className={styles.infoItem}>
<div className={styles.infoLabel}></div>
<div className={styles.infoValue}>{med.frequency}</div>
</div>
<div className={styles.infoItem}>
<div className={styles.infoLabel}></div>
<div className={styles.infoValue}>{med.timeSlots.join(', ')}</div>
</div>
<div className={styles.infoItem}>
<div className={styles.infoLabel}></div>
<div className={styles.infoValue}>{med.startDate} ~ {med.endDate || '长期'}</div>
</div>
</div>
</Card>
{/* Today's medication tracking */}
{med.notes && (
<Card className={styles.infoCard}>
<div className={styles.infoLabel} style={{ marginBottom: 4 }}></div>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.6 }}>{med.notes}</div>
</Card>
)}
<Card className={styles.infoCard}>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>
<span style={{ color: '#9CA3AF', fontWeight: 400, fontSize: 13 }}>{today}</span>
</div>
<div className={styles.todayTitle}></div>
<div className={styles.todayDate}>{today}</div>
{todaySlots.map((slot) => {
const taken = slotTaken(slot);
return (
<div key={slot} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '10px 0', borderBottom: '1px solid #f0f0f0',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 10, height: 10, borderRadius: 5,
background: taken ? '#10B981' : '#E5E7EB',
}} />
<span style={{ fontSize: 14 }}>{slot}</span>
<span style={{ fontSize: 12, color: taken ? '#10B981' : '#9CA3AF' }}>
{taken ? '已服用' : '未服用'}
</span>
<div key={slot} className={`${styles.slotRow} ${taken ? styles.slotRowTaken : styles.slotRowPending}`}>
<div className={styles.slotLeft}>
<div className={`${styles.slotCircle} ${taken ? styles.slotCircleTaken : styles.slotCirclePending}`}>
{taken ? '✓' : slot}
</div>
<div>
<div className={styles.slotTime}>{slot}</div>
<div className={styles.slotLabel}>{taken ? '已服用' : '待服用'}</div>
</div>
</div>
{!taken && med.status === 'active' && (
<Button size="sm" variant="outline" loading={loading} onClick={() => handleMarkTaken(slot)}>
<Button size="sm" variant="primary" loading={loading} onClick={() => handleMarkTaken(slot)}>
</Button>
)}
</div>
);
})}
<div style={{ marginTop: 12, fontSize: 12, color: '#9CA3AF' }}>
{todaySlots.filter((s) => slotTaken(s)).length}/{todaySlots.length}
<div className={styles.todaySummary}>
<span className={styles.todayProgress}>
{todaySlots.filter((s) => slotTaken(s)).length}/{todaySlots.length}
</span>
<div className={styles.todayProgressBar}>
<div className={styles.todayProgressFill} style={{
width: `${todaySlots.length > 0 ? (todaySlots.filter((s) => slotTaken(s)).length / todaySlots.length) * 100 : 0}%`,
}} />
</div>
</div>
</Card>
{/* 7-day adherence */}
<Card className={styles.infoCard}>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>7</div>
<div style={{ display: 'flex', gap: 4 }}>
<div className={styles.chartTitle}>7</div>
<div className={styles.chartBars}>
{last7Days.map((d) => {
const pct = d.total > 0 ? (d.taken / d.total) * 100 : 0;
const height = d.total > 0 ? Math.max(8, (d.taken / d.total) * 60) : 8;
return (
<div key={d.date} style={{ flex: 1, textAlign: 'center' }}>
<div style={{
height: 40, borderRadius: 6, marginBottom: 4,
background: pct === 100 ? '#10B981' : pct > 0 ? '#F59E0B' : '#E5E7EB',
transition: 'background 0.3s',
}} />
<div style={{ fontSize: 10, color: '#9CA3AF' }}>{d.date.slice(5)}</div>
<div key={d.date} className={styles.chartBarWrap}>
<div
className={`${styles.chartBar} ${pct === 100 ? styles.chartBarFull : pct > 0 ? styles.chartBarPartial : styles.chartBarEmpty}`}
style={{ height }}
/>
<div className={styles.chartDate}>{d.date.slice(5)}</div>
</div>
);
})}
</div>
<div style={{ display: 'flex', gap: 12, marginTop: 8, fontSize: 11, color: '#9CA3AF' }}>
<span style={{display:'inline-flex',alignItems:'center',gap:4}}><span style={{width:10,height:10,borderRadius:'50%',background:'#20C997',display:'inline-block'}}/> </span><span style={{display:'inline-flex',alignItems:'center',gap:4}}><span style={{width:10,height:10,borderRadius:'50%',background:'#F59E0B',display:'inline-block'}}/> </span><span style={{display:'inline-flex',alignItems:'center',gap:4}}><span style={{width:10,height:10,borderRadius:'50%',background:'#E4E8EE',display:'inline-block'}}/> </span>
<div className={styles.chartLegend}>
<div className={styles.chartLegendItem}>
<span className={`${styles.chartLegendDot} ${styles.chartLegendDotFull}`} />
</div>
<div className={styles.chartLegendItem}>
<span className={`${styles.chartLegendDot} ${styles.chartLegendDotPartial}`} />
</div>
<div className={styles.chartLegendItem}>
<span className={`${styles.chartLegendDot} ${styles.chartLegendDotEmpty}`} />
</div>
</div>
</Card>
<ToastContainer />
</div>
);

View File

@@ -1,45 +1,182 @@
.tabs {
display: flex;
gap: 12px;
margin-bottom: 14px;
gap: 8px;
margin-bottom: 16px;
padding: 4px;
background: var(--color-bg-secondary);
border-radius: 12px;
}
.tab {
padding: 6px 16px;
border-radius: var(--radius-full);
font-size: var(--font-size-sm);
background: var(--color-bg-secondary);
flex: 1;
padding: 10px 0;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
color: var(--color-text-secondary);
font-weight: 500;
text-align: center;
transition: all 0.2s;
}
.tabActive {
background: var(--color-primary);
color: var(--color-text-inverse);
background: var(--color-white);
color: var(--color-primary);
box-shadow: var(--shadow-sm);
}
.medCard { margin-bottom: 8px; }
.medCard {
margin-bottom: 10px;
padding: 18px;
position: relative;
overflow: hidden;
}
.medCard::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
border-radius: 4px 0 0 4px;
}
.medCardActive::before {
background: var(--color-primary-gradient);
}
.medCardEnded::before {
background: var(--color-border);
}
.medHeader {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
justify-content: space-between;
margin-bottom: 6px;
}
.medName { font-size: var(--font-size-base); font-weight: 600; }
.medDosage { font-size: var(--font-size-sm); color: var(--color-text-secondary); }
.medNote { font-size: var(--font-size-xs); color: var(--color-text-tertiary); margin-top: 4px; }
.medName {
font-size: 16px;
font-weight: 700;
color: var(--color-text-primary);
}
.medStatus {
font-size: 11px;
font-weight: 600;
padding: 4px 10px;
border-radius: 12px;
}
.medStatusActive {
background: var(--color-primary-bg);
color: var(--color-primary);
}
.medStatusEnded {
background: var(--color-bg);
color: var(--color-text-tertiary);
}
.medMeta {
display: flex;
align-items: center;
gap: 12px;
margin-top: 4px;
}
.medDosage {
font-size: 13px;
color: var(--color-text-secondary);
}
.medFrequency {
font-size: 12px;
color: var(--color-text-tertiary);
background: var(--color-bg);
padding: 2px 8px;
border-radius: 6px;
}
.medSlots {
display: flex;
gap: 6px;
margin-top: 10px;
flex-wrap: wrap;
}
.medSlot {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 8px;
font-size: 12px;
font-weight: 500;
background: var(--color-bg);
color: var(--color-text-secondary);
}
.medSlotTaken {
background: var(--color-success-bg);
color: #0D8A5E;
}
.medSlotDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-text-tertiary);
}
.medSlotDotTaken {
background: var(--color-success);
}
.medNote {
font-size: 12px;
color: var(--color-text-tertiary);
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--color-divider);
}
.deleteBtn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: var(--color-text-tertiary);
transition: all 0.15s;
}
.deleteBtn:active {
background: var(--color-danger-bg);
color: var(--color-danger);
}
.fab {
position: fixed;
bottom: 80px;
right: max(16px, calc((100vw - var(--max-content-width)) / 2 + 16px));
padding: 12px 20px;
width: 52px;
height: 52px;
border-radius: 16px;
background: var(--color-primary-gradient);
color: var(--color-text-inverse);
border-radius: var(--radius-full);
font-weight: 600;
font-size: 22px;
font-weight: 700;
box-shadow: 0 4px 16px rgba(79,110,247,0.35);
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.fab:active {
transform: scale(0.92);
}

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { PageHeader } from '@/components/layout/PageHeader';
import { Card } from '@/components/common/Card';
import { Empty } from '@/components/common/Empty';
import { ToastContainer, toast } from '@/components/common/Toast';
import * as medicationService from '@/services/medication.service';
import type { Medication } from '@/types';
import styles from './MedicationListPage.module.css';
@@ -12,13 +13,20 @@ export function MedicationListPage() {
const [medications, setMedications] = useState<Medication[]>([]);
const [tab, setTab] = useState<'active' | 'ended'>('active');
useEffect(() => {
medicationService.getMedications().then(setMedications);
}, []);
const load = () => { medicationService.getMedications().then(setMedications); };
const filtered = medications.filter((m) => tab === 'active' ? m.status === 'active' : m.status === 'ended');
useEffect(() => { load(); }, []);
const allTaken = (med: Medication) => med.records?.every((r) => r.taken);
const handleDelete = async (e: React.MouseEvent, medId: string) => {
e.stopPropagation();
try {
await medicationService.deleteMedication(medId);
toast('已删除');
load();
} catch { toast('删除失败', 'error'); }
};
const filtered = medications.filter((m) => tab === 'active' ? m.status === 'active' : m.status !== 'active');
return (
<div className="page--no-tab">
@@ -32,22 +40,54 @@ export function MedicationListPage() {
<Empty message={tab === 'active' ? '暂无进行中的用药' : '暂无已结束的用药'} />
) : (
filtered.map((med) => (
<Card key={med.id} className={styles.medCard} onClick={() => navigate(`/health/medications/${med.id}`)}>
<Card
key={med.id}
className={`${styles.medCard} ${med.status === 'active' ? styles.medCardActive : styles.medCardEnded}`}
onClick={() => navigate(`/health/medications/${med.id}`)}
>
<div className={styles.medHeader}>
<span className={styles.medName}>{med.drugName}</span>
{med.status === 'active' && allTaken(med) && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#10B981" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className={`${styles.medStatus} ${med.status === 'active' ? styles.medStatusActive : styles.medStatusEnded}`}>
{med.status === 'active' ? '进行中' : '已结束'}
</span>
<button className={styles.deleteBtn} onClick={(e) => handleDelete(e, med.id)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</div>
<div className={styles.medDosage}>{med.dosage} · {med.frequency}</div>
{med.note && <div className={styles.medNote}>{med.note}</div>}
<div className={styles.medMeta}>
<span className={styles.medDosage}>{med.dosage}</span>
<span className={styles.medFrequency}>{med.frequency}</span>
</div>
{med.timeSlots && med.timeSlots.length > 0 && (
<div className={styles.medSlots}>
{med.timeSlots.map((slot) => {
const record = med.records?.find((r) => r.timeSlot === slot);
const taken = record?.isTaken;
return (
<div key={slot} className={`${styles.medSlot} ${taken ? styles.medSlotTaken : ''}`}>
<span className={`${styles.medSlotDot} ${taken ? styles.medSlotDotTaken : ''}`} />
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
{slot}
</div>
);
})}
</div>
)}
{med.notes && <div className={styles.medNote}>{med.notes}</div>}
</Card>
))
)}
<button className={styles.fab} onClick={() => navigate('/health/medications/add')}>+ </button>
<button className={styles.fab} onClick={() => navigate('/health/medications/add')}>+</button>
<ToastContainer />
</div>
);
}

View File

@@ -15,18 +15,18 @@ export function EditProfilePage() {
const [name, setName] = useState('');
const [gender, setGender] = useState('');
const [birthday, setBirthday] = useState('');
const [height, setHeight] = useState('');
const [weight, setWeight] = useState('');
const [history, setHistory] = useState('');
const [stentDate, setStentDate] = useState('');
const [stentType, setStentType] = useState('');
useEffect(() => {
if (user) {
setName(user.nickname || '');
setGender(user.gender || '');
setBirthday(user.birthday || '');
setHeight(user.height ? String(user.height) : '');
setWeight(user.weight ? String(user.weight) : '');
setHistory((user.medicalHistory || []).join('、'));
setStentDate(user.stentImplantDate || '');
setStentType(user.stentType || '');
}
}, [user]);
@@ -37,18 +37,18 @@ export function EditProfilePage() {
name: name || undefined,
gender: gender || undefined,
birthday: birthday || undefined,
heightCm: height ? Number(height) : undefined,
weightKg: weight ? Number(weight) : undefined,
medicalHistory: history ? history.split(/[、,,]/).filter(Boolean) : undefined,
stentDate: stentDate || undefined,
stentType: stentType || undefined,
};
await authService.updateProfile(data);
updateProfile({
nickname: name,
gender: gender as 'male' | 'female' | 'unknown',
birthday,
height: height ? Number(height) : 0,
weight: weight ? Number(weight) : 0,
medicalHistory: history ? history.split(/[、,,]/).filter(Boolean) : [],
stentImplantDate: stentDate,
stentType,
});
toast('保存成功');
setTimeout(() => navigate(-1), 800);
@@ -92,17 +92,6 @@ export function EditProfilePage() {
<input className={styles.input} type="date" value={birthday} onChange={(e) => setBirthday(e.target.value)} />
</div>
<div className={styles.row}>
<div className={styles.field}>
<label className={styles.label}> (cm)</label>
<input className={styles.input} type="number" value={height} onChange={(e) => setHeight(e.target.value)} placeholder="170" />
</div>
<div className={styles.field}>
<label className={styles.label}> (kg)</label>
<input className={styles.input} type="number" value={weight} onChange={(e) => setWeight(e.target.value)} placeholder="70" />
</div>
</div>
<div className={styles.field}>
<label className={styles.label}></label>
<textarea
@@ -114,6 +103,16 @@ export function EditProfilePage() {
/>
</div>
<div className={styles.field}>
<label className={styles.label}></label>
<input className={styles.input} type="date" value={stentDate} onChange={(e) => setStentDate(e.target.value)} />
</div>
<div className={styles.field}>
<label className={styles.label}></label>
<input className={styles.input} value={stentType} onChange={(e) => setStentType(e.target.value)} placeholder="如:药物洗脱支架(DES)" />
</div>
<Button variant="primary" size="lg" fullWidth loading={loading} onClick={handleSave}>
</Button>

View File

@@ -0,0 +1,191 @@
.profileCard {
margin-bottom: 14px;
padding: 18px;
display: flex;
align-items: center;
gap: 14px;
}
.avatar {
width: 52px;
height: 52px;
border-radius: 16px;
background: var(--color-primary-gradient);
color: #fff;
font-size: 22px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.profileInfo {
flex: 1;
}
.name {
font-size: 17px;
font-weight: 700;
color: var(--color-text-primary);
}
.phone {
font-size: 13px;
color: var(--color-text-tertiary);
margin-top: 2px;
}
.arrow {
font-size: 24px;
color: var(--color-text-tertiary);
}
.detailCard {
margin-bottom: 14px;
padding: 20px;
}
.cardTitle {
font-size: 16px;
font-weight: 700;
color: var(--color-text-primary);
margin-bottom: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.cardTitle::before {
content: '';
width: 4px;
height: 16px;
border-radius: 2px;
background: var(--color-primary);
}
.cardCount {
margin-left: auto;
font-size: 12px;
font-weight: 500;
color: var(--color-text-tertiary);
background: var(--color-bg);
padding: 2px 10px;
border-radius: 12px;
}
.historyTags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.historyTag {
padding: 5px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
color: var(--color-primary);
background: var(--color-primary-bg);
}
.stentInfo {
margin-top: 12px;
font-size: 13px;
color: var(--color-text-secondary);
}
.indicatorsGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.indicatorItem {
text-align: center;
padding: 12px 8px;
background: var(--color-bg);
border-radius: 12px;
}
.indicatorValue {
font-size: 20px;
font-weight: 800;
}
.indicatorLabel {
font-size: 12px;
color: var(--color-text-secondary);
margin-top: 4px;
}
.indicatorUnit {
font-size: 10px;
color: var(--color-text-tertiary);
margin-left: 2px;
}
.medItem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid var(--color-divider);
cursor: pointer;
}
.medItem:last-child {
border-bottom: none;
}
.medName {
font-size: 14px;
font-weight: 500;
color: var(--color-text-primary);
}
.medMeta {
font-size: 12px;
color: var(--color-text-tertiary);
}
.reportItem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid var(--color-divider);
cursor: pointer;
}
.reportItem:last-child {
border-bottom: none;
}
.reportName {
font-size: 14px;
font-weight: 500;
color: var(--color-text-primary);
}
.reportStatus {
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: 12px;
}
.reportDone {
background: var(--color-success-bg);
color: #0D8A5E;
}
.reportPending {
background: var(--color-warning-bg);
color: #D67E0B;
}
.emptyText {
text-align: center;
padding: 16px;
color: var(--color-text-tertiary);
font-size: 13px;
}

View File

@@ -0,0 +1,125 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { PageHeader } from '@/components/layout/PageHeader';
import { Card } from '@/components/common/Card';
import { useAuth } from '@/hooks/useAuth';
import { api } from '@/services/api-client';
import * as healthService from '@/services/health.service';
import type { HealthStats, Report, Medication } from '@/types';
import styles from './HealthRecordPage.module.css';
export function HealthRecordPage() {
const navigate = useNavigate();
const { user } = useAuth();
const [stats, setStats] = useState<HealthStats[]>([]);
const [reports, setReports] = useState<Report[]>([]);
const [meds, setMeds] = useState<Medication[]>([]);
useEffect(() => {
healthService.getLatestStats().then(setStats).catch(() => {});
api.get<Report[]>('/api/reports').then((r) => setReports(r.data.slice(0, 5))).catch(() => {});
api.get<Medication[]>('/api/medications').then((r) => {
setMeds(r.data.filter((m: Medication) => m.status === 'active'));
}).catch(() => {});
}, []);
const bp = stats.find((s) => s.type === 'blood_pressure');
const hr = stats.find((s) => s.type === 'heart_rate');
const sugar = stats.find((s) => s.type === 'blood_sugar');
const spo2 = stats.find((s) => s.type === 'spo2');
const weight = stats.find((s) => s.type === 'weight');
const bpVal = bp?.latest?.value;
const systolic = typeof bpVal === 'object' ? (bpVal as Record<string,number>).systolic : null;
const diastolic = typeof bpVal === 'object' ? (bpVal as Record<string,number>).diastolic : null;
const indicators = [
{ label: '血压', value: systolic ? `${systolic}/${diastolic}` : '--', unit: 'mmHg', color: '#EF4444' },
{ label: '心率', value: hr?.latest?.value ?? '--', unit: 'bpm', color: '#F59E0B' },
{ label: '血糖', value: sugar?.latest?.value ?? '--', unit: 'mmol/L', color: '#4F6EF7' },
{ label: '血氧', value: spo2?.latest?.value ?? '--', unit: '%', color: '#20C997' },
{ label: '体重', value: weight?.latest?.value ?? '--', unit: 'kg', color: '#845EF7' },
];
return (
<div className="page--no-tab">
<PageHeader title="健康档案" />
<Card className={styles.profileCard} onClick={() => navigate('/profile/edit')}>
<div className={styles.avatar}>{user?.nickname?.[0] || '用'}</div>
<div className={styles.profileInfo}>
<div className={styles.name}>{user?.nickname || '用户'}</div>
<div className={styles.phone}>{user?.phone}</div>
</div>
<span className={styles.arrow}></span>
</Card>
{user?.medicalHistory && user.medicalHistory.length > 0 && (
<Card className={styles.detailCard}>
<div className={styles.cardTitle}></div>
<div className={styles.historyTags}>
{user.medicalHistory.map((h, i) => (
<span key={i} className={styles.historyTag}>{h}</span>
))}
</div>
{user.stentImplantDate && (
<div className={styles.stentInfo}>
{user.stentImplantDate}{user.stentType ? ` · ${user.stentType}` : ''}
</div>
)}
</Card>
)}
<Card className={styles.detailCard}>
<div className={styles.cardTitle}></div>
<div className={styles.indicatorsGrid}>
{indicators.map((item) => (
<div key={item.label} className={styles.indicatorItem}>
<div className={styles.indicatorValue} style={{ color: item.color }}>
{item.value}
</div>
<div className={styles.indicatorLabel}>
{item.label}<span className={styles.indicatorUnit}>{item.unit}</span>
</div>
</div>
))}
</div>
</Card>
<Card className={styles.detailCard}>
<div className={styles.cardTitle}>
<span className={styles.cardCount}>{meds.length}</span>
</div>
{meds.length === 0 ? (
<div className={styles.emptyText}></div>
) : (
meds.map((m) => (
<div key={m.id} className={styles.medItem} onClick={() => navigate(`/health/medications/${m.id}`)}>
<span className={styles.medName}>{m.drugName}</span>
<span className={styles.medMeta}>{m.dosage} · {m.timeSlots?.join(', ')}</span>
</div>
))
)}
</Card>
<Card className={styles.detailCard}>
<div className={styles.cardTitle}>
<span className={styles.cardCount}>{reports.length}</span>
</div>
{reports.length === 0 ? (
<div className={styles.emptyText}></div>
) : (
reports.map((r: Record<string, unknown>, i: number) => (
<div key={i} className={styles.reportItem} onClick={() => navigate(`/services/reports/${r.id}`)}>
<span className={styles.reportName}>{r.title as string}</span>
<span className={`${styles.reportStatus} ${r.status === 'completed' ? styles.reportDone : styles.reportPending}`}>
{r.status === 'completed' ? '已解读' : '待审核'}
</span>
</div>
))
)}
</Card>
</div>
);
}

View File

@@ -2,83 +2,131 @@
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 12px;
margin-bottom: 20px;
background: var(--color-primary-gradient);
color: #fff;
border-radius: var(--radius-xl);
padding: 20px;
box-shadow: 0 6px 24px rgba(79,110,247,0.3);
padding: 24px 20px;
box-shadow: 0 8px 30px rgba(79,110,247,0.3);
position: relative;
overflow: hidden;
}
.profileCard::after {
content: '';
position: absolute;
width: 120px;
height: 120px;
border-radius: 50%;
background: rgba(255,255,255,0.06);
right: -30px;
top: -30px;
}
.avatar {
width: 56px;
height: 56px;
border-radius: 18px;
background: rgba(255,255,255,0.25);
width: 60px;
height: 60px;
border-radius: 20px;
background: rgba(255,255,255,0.2);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-xl);
font-size: 24px;
font-weight: 800;
backdrop-filter: blur(4px);
flex-shrink: 0;
}
.profileInfo { flex: 1; }
.profileInfo { flex: 1; position: relative; z-index: 1; }
.nickname { font-size: var(--font-size-lg); font-weight: 700; }
.phone { font-size: 12px; opacity: 0.7; margin-top: 2px; }
.editHint { color: rgba(255,255,255,0.8); }
.nickname { font-size: 20px; font-weight: 800; }
.phone { font-size: 13px; opacity: 0.7; margin-top: 3px; }
.statsCard {
display: flex;
.editBadge {
display: inline-flex;
align-items: center;
justify-content: space-around;
margin-bottom: 16px;
padding: 16px 0;
gap: 2px;
margin-top: 8px;
padding: 3px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
background: rgba(255,255,255,0.2);
color: #fff;
}
.stat { text-align: center; }
.statValue { font-size: 18px; font-weight: 800; display: block; color: var(--color-text-primary); }
.statLabel { font-size: 11px; color: var(--color-text-tertiary); font-weight: 500; }
.statDivider { width: 1px; height: 32px; background: var(--color-divider); }
.menuSection {
margin-bottom: 16px;
}
.menuSectionTitle {
font-size: 12px;
font-weight: 600;
color: var(--color-text-tertiary);
padding: 0 4px 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.menuList {
background: var(--color-white);
border-radius: var(--radius-xl);
overflow: hidden;
box-shadow: var(--shadow-sm);
margin-bottom: 16px;
}
.menuItem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 18px;
padding: 16px 18px;
width: 100%;
font-size: var(--font-size-base);
font-size: 15px;
font-weight: 500;
color: var(--color-text-primary);
border-bottom: 1px solid var(--color-divider);
transition: background 0.15s;
}
.menuItem:last-child { border-bottom: none; }
.menuItem:active { background: #FAFBFC; }
.menuItem:active { background: var(--color-bg); }
.menuRight { display: flex; align-items: center; gap: 8px; }
.menuItemLeft {
display: flex;
align-items: center;
gap: 12px;
}
.menuIcon {
width: 38px;
height: 38px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.menuArrow {
font-size: 18px;
color: var(--color-text-tertiary);
}
.menuBadge {
margin-right: 6px;
}
.logoutBtn {
display: block;
width: 100%;
padding: 14px;
padding: 15px;
background: var(--color-white);
color: var(--color-danger);
border-radius: var(--radius-xl);
font-size: var(--font-size-base);
font-size: 15px;
font-weight: 600;
box-shadow: var(--shadow-sm);
border: 1.5px solid #FDD;
margin-top: 4px;
}
.logoutBtn:active { background: #FFF5F5; }
.logoutBtn:active { background: var(--color-danger-bg); }

View File

@@ -30,77 +30,67 @@ export function ProfilePage() {
</div>
</Card>
<Card className={styles.statsCard}>
<div className={styles.stat}>
<span className={styles.statValue}>{user?.height || '-'}cm</span>
<span className={styles.statLabel}></span>
</div>
<div className={styles.statDivider} />
<div className={styles.stat}>
<span className={styles.statValue}>{user?.weight || '-'}kg</span>
<span className={styles.statLabel}></span>
</div>
<div className={styles.statDivider} />
<div className={styles.stat}>
<span className={styles.statValue}>{user?.medicalHistory?.join('、') || '-'}</span>
<span className={styles.statLabel}></span>
</div>
</Card>
<div className={styles.menuList}>
<button className={styles.menuItem} onClick={() => navigate('/health/medications')}>
<span>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#D67E0B" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ verticalAlign: 'middle', marginRight: 10 }}>
<rect x="4" y="5" width="16" height="14" rx="4" />
<path d="M10 9v6M14 9v6M8 12h8" />
</svg>
<button className={styles.menuItem} onClick={() => navigate('/profile/health-record')}>
<span className={styles.menuItemLeft}>
<span className={styles.menuIcon} style={{ background: 'var(--color-primary-bg)' }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
</span>
</span>
<span></span>
</button>
<button className={styles.menuItem} onClick={() => navigate('/notifications')}>
<span>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#4F6EF7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ verticalAlign: 'middle', marginRight: 10 }}>
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
<span className={styles.menuItemLeft}>
<span className={styles.menuIcon} style={{ background: '#EFF6FF' }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#339AF0" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
</span>
</span>
<div className={styles.menuRight}>
<span className={styles.menuArrow}>
{unreadCount > 0 && <Badge count={unreadCount} />}
<span></span>
</div>
</span>
</button>
<button className={styles.menuItem} onClick={() => navigate('/home/device-binding')}>
<span>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#6366F1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ verticalAlign: 'middle', marginRight: 10 }}>
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
<line x1="12" y1="18" x2="12.01" y2="18" />
</svg>
<span className={styles.menuItemLeft}>
<span className={styles.menuIcon} style={{ background: '#F3E8FF' }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#845EF7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
<line x1="12" y1="18" x2="12.01" y2="18" />
</svg>
</span>
</span>
<span></span>
</button>
<button className={styles.menuItem} onClick={() => navigate('/profile/settings')}>
<span>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#845EF7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ verticalAlign: 'middle', marginRight: 10 }}>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
<span className={styles.menuItemLeft}>
<span className={styles.menuIcon} style={{ background: '#EDF0FD' }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#4F6EF7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</span>
</span>
<span></span>
</button>
<button className={styles.menuItem} onClick={() => navigate('/profile/settings/about')}>
<span>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#20C997" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ verticalAlign: 'middle', marginRight: 10 }}>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
<span className={styles.menuItemLeft}>
<span className={styles.menuIcon} style={{ background: '#E6F9F2' }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#20C997" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
</span>
</span>
<span></span>
</button>
</div>

View File

@@ -14,7 +14,7 @@ export function FollowUpListPage() {
const [tab, setTab] = useState<'upcoming' | 'completed'>('upcoming');
useEffect(() => {
followupService.getFollowUps().then(setFollowups);
followupService.getFollowUps('recheck').then(setFollowups);
}, []);
const filtered = followups.filter((f) => tab === 'upcoming' ? f.status === 'upcoming' : f.status === 'completed');

View File

@@ -41,6 +41,19 @@ const SERVICES = [
),
bg: '#FEE9E9',
},
{
label: '医生随访',
desc: '查看随访计划',
path: '/services/visits',
svg: (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#20C997" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
<rect x="8" y="2" width="8" height="4" rx="1" ry="1" />
<path d="M9 14l2 2 4-4" />
</svg>
),
bg: '#E6F9F2',
},
];
export function ServicesHubPage() {

View File

@@ -0,0 +1,58 @@
.card {
padding: 18px;
margin-bottom: 10px;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
}
.title {
font-size: 16px;
font-weight: 700;
color: var(--color-text-primary);
flex: 1;
}
.badge {
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: 12px;
flex-shrink: 0;
}
.badgePending {
background: var(--color-warning-bg);
color: #D67E0B;
}
.badgeDone {
background: var(--color-success-bg);
color: #0D8A5E;
}
.info {
font-size: 13px;
color: var(--color-text-secondary);
margin-bottom: 4px;
}
.note {
font-size: 12px;
color: var(--color-text-tertiary);
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--color-divider);
line-height: 1.5;
}
.empty {
text-align: center;
padding: 60px 24px;
color: var(--color-text-tertiary);
font-size: 14px;
}

View File

@@ -0,0 +1,45 @@
import { useEffect, useState } from 'react';
import { PageHeader } from '@/components/layout/PageHeader';
import { Card } from '@/components/common/Card';
import { api } from '@/services/api-client';
import styles from './VisitListPage.module.css';
interface VisitItem {
id: string; title: string; scheduledAt: string; status: string;
doctorName?: string; doctorId?: string; notes?: string; description?: string;
}
export function VisitListPage() {
const [visits, setVisits] = useState<VisitItem[]>([]);
useEffect(() => {
api.get<VisitItem[]>('/api/follow-ups?type=followup')
.then((r) => setVisits(r.data)).catch(() => {});
}, []);
return (
<div className="page--no-tab">
<PageHeader title="医生随访" />
{visits.length === 0 ? (
<div className={styles.empty}>访</div>
) : (
visits.map((v) => (
<Card key={v.id} className={styles.card}>
<div className={styles.header}>
<div className={styles.title}>{v.title}</div>
<span className={`${styles.badge} ${v.status === 'upcoming' ? styles.badgePending : styles.badgeDone}`}>
{v.status === 'upcoming' ? '待随访' : '已完成'}
</span>
</div>
<div className={styles.info}>
{v.doctorName ? `医生:${v.doctorName}` : ''} · {v.scheduledAt?.split('T')[0]}
</div>
{(v.description || v.notes) && (
<div className={styles.note}>{v.description || v.notes}</div>
)}
</Card>
))
)}
</div>
);
}

View File

@@ -22,9 +22,11 @@ import { ReportUploadPage } from '@/pages/services/ReportUploadPage';
import { ReportDetailPage } from '@/pages/services/ReportDetailPage';
import { FollowUpListPage } from '@/pages/services/FollowUpListPage';
import { FollowUpEditPage } from '@/pages/services/FollowUpEditPage';
import { VisitListPage } from '@/pages/services/VisitListPage';
import { ExerciseDietPage } from '@/pages/exercise-diet/ExerciseDietPage';
import { ProfilePage } from '@/pages/profile/ProfilePage';
import { EditProfilePage } from '@/pages/profile/EditProfilePage';
import { HealthRecordPage } from '@/pages/profile/HealthRecordPage';
import { SettingsPage } from '@/pages/profile/SettingsPage';
import {
NotificationSettingsPage,
@@ -81,7 +83,9 @@ export const router = createBrowserRouter([
{ path: 'services/reports/:id', element: <ReportDetailPage /> },
{ path: 'services/follow-ups', element: <FollowUpListPage /> },
{ path: 'services/follow-ups/add', element: <FollowUpEditPage /> },
{ path: 'services/visits', element: <VisitListPage /> },
{ path: 'profile/edit', element: <EditProfilePage /> },
{ path: 'profile/health-record', element: <HealthRecordPage /> },
{ path: 'profile/settings', element: <SettingsPage /> },
{ path: 'profile/settings/notifications', element: <NotificationSettingsPage /> },
{ path: 'profile/settings/privacy', element: <PrivacyPage /> },

View File

@@ -72,13 +72,27 @@ export async function getProfile(): Promise<User> {
weight: res.data.weightKg || 0,
medicalHistory: res.data.medicalHistory || [],
stentImplantDate: res.data.stentDate || '',
stentType: res.data.stentType || '',
};
localStorage.setItem('hrt_auth', JSON.stringify(state));
}
}
} catch { /* ignore */ }
return res.data as unknown as User;
return {
id: res.data.id,
phone: res.data.phone,
nickname: res.data.name,
avatar: '',
gender: res.data.gender || 'unknown',
birthday: res.data.birthday || '',
height: res.data.heightCm || 0,
weight: res.data.weightKg || 0,
medicalHistory: res.data.medicalHistory || [],
stentImplantDate: res.data.stentDate || '',
stentType: res.data.stentType || '',
createdAt: new Date().toISOString(),
};
}
export async function updateProfile(data: Record<string, unknown>): Promise<void> {

View File

@@ -33,8 +33,9 @@ function mapFollowUp(f: RawFollowUp): FollowUp {
};
}
export async function getFollowUps(): Promise<FollowUp[]> {
const res = await api.get<RawFollowUp[]>('/api/follow-ups');
export async function getFollowUps(type?: string): Promise<FollowUp[]> {
const path = type ? `/api/follow-ups?type=${type}` : '/api/follow-ups';
const res = await api.get<RawFollowUp[]>(path);
return res.data.map(mapFollowUp);
}

View File

@@ -9,6 +9,7 @@ export interface User {
weight: number;
medicalHistory: string[];
stentImplantDate: string;
stentType: string;
createdAt: string;
}