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

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