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:
@@ -14,7 +14,18 @@ public record UserProfileResponse(
|
|||||||
List<string>? MedicalHistory, DateOnly? StentDate, string? StentType,
|
List<string>? MedicalHistory, DateOnly? StentDate, string? StentType,
|
||||||
string? Department, string? Title, List<string>? Specialty, string? Introduction);
|
string? Department, string? Title, List<string>? Specialty, string? Introduction);
|
||||||
|
|
||||||
public record UpdateProfileRequest(
|
public class UpdateProfileRequest
|
||||||
string? Name, string? Gender, DateOnly? Birthday,
|
{
|
||||||
decimal? HeightCm, decimal? WeightKg, List<string>? MedicalHistory,
|
public string? Name { get; set; }
|
||||||
string? Department, string? Title, string? Introduction, List<string>? Specialty);
|
public string? Gender { get; set; }
|
||||||
|
public DateOnly? Birthday { get; set; }
|
||||||
|
public decimal? HeightCm { get; set; }
|
||||||
|
public decimal? WeightKg { get; set; }
|
||||||
|
public List<string>? MedicalHistory { get; set; }
|
||||||
|
public DateOnly? StentDate { get; set; }
|
||||||
|
public string? StentType { get; set; }
|
||||||
|
public string? Department { get; set; }
|
||||||
|
public string? Title { get; set; }
|
||||||
|
public string? Introduction { get; set; }
|
||||||
|
public List<string>? Specialty { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ public class FollowUpService(AppDbContext db)
|
|||||||
.OrderBy(f => f.ScheduledAt)
|
.OrderBy(f => f.ScheduledAt)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<List<FollowUp>> GetDoctorInitiatedFollowUpsAsync(Guid doctorId)
|
||||||
|
=> await db.FollowUps
|
||||||
|
.Include(f => f.Patient)
|
||||||
|
.Where(f => f.DoctorId == doctorId)
|
||||||
|
.OrderBy(f => f.ScheduledAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
public async Task<FollowUp> AddAsync(Guid patientId, string title, string? description, DateTime scheduledAt, bool reminderEnabled, Guid? doctorId = null, string? notes = null)
|
public async Task<FollowUp> AddAsync(Guid patientId, string title, string? description, DateTime scheduledAt, bool reminderEnabled, Guid? doctorId = null, string? notes = null)
|
||||||
{
|
{
|
||||||
var followUp = new FollowUp
|
var followUp = new FollowUp
|
||||||
|
|||||||
@@ -120,4 +120,53 @@ public class MedicationService(AppDbContext db)
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<object>> GetTodaySummaryAsync(Guid userId)
|
||||||
|
{
|
||||||
|
var today = DateTime.UtcNow.Date;
|
||||||
|
var medications = await db.Medications
|
||||||
|
.Where(m => m.UserId == userId && m.Status == "active")
|
||||||
|
.OrderBy(m => m.CreatedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var allRecords = await db.MedicationRecords
|
||||||
|
.Where(mr => mr.UserId == userId && mr.CreatedAt.Date == today)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var now = DateTime.Now;
|
||||||
|
return medications.Select(m =>
|
||||||
|
{
|
||||||
|
var slots = m.TimeSlots.Select(slot =>
|
||||||
|
{
|
||||||
|
var record = allRecords.FirstOrDefault(r =>
|
||||||
|
r.MedicationId == m.Id && r.TimeSlot == slot);
|
||||||
|
var taken = record?.IsTaken ?? false;
|
||||||
|
|
||||||
|
// Parse slot time and mark as missed if past due
|
||||||
|
var parts = slot.Split(':');
|
||||||
|
var slotHour = int.Parse(parts[0]);
|
||||||
|
var slotMinute = int.Parse(parts[1]);
|
||||||
|
var slotTime = today.AddHours(slotHour).AddMinutes(slotMinute);
|
||||||
|
var missed = !taken && now > slotTime;
|
||||||
|
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
time = slot,
|
||||||
|
taken,
|
||||||
|
missed,
|
||||||
|
takenAt = record?.TakenAt,
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return (object)new
|
||||||
|
{
|
||||||
|
m.Id,
|
||||||
|
m.DrugName,
|
||||||
|
m.Dosage,
|
||||||
|
m.Frequency,
|
||||||
|
slots,
|
||||||
|
allTaken = slots.All(s => s.taken),
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,8 @@ public class AuthController(
|
|||||||
if (request.HeightCm.HasValue) user.HeightCm = request.HeightCm;
|
if (request.HeightCm.HasValue) user.HeightCm = request.HeightCm;
|
||||||
if (request.WeightKg.HasValue) user.WeightKg = request.WeightKg;
|
if (request.WeightKg.HasValue) user.WeightKg = request.WeightKg;
|
||||||
if (request.MedicalHistory != null) user.MedicalHistory = request.MedicalHistory;
|
if (request.MedicalHistory != null) user.MedicalHistory = request.MedicalHistory;
|
||||||
|
if (request.StentDate.HasValue) user.StentDate = request.StentDate;
|
||||||
|
if (request.StentType != null) user.StentType = request.StentType;
|
||||||
if (request.Department != null) user.Department = request.Department;
|
if (request.Department != null) user.Department = request.Department;
|
||||||
if (request.Title != null) user.Title = request.Title;
|
if (request.Title != null) user.Title = request.Title;
|
||||||
if (request.Introduction != null) user.Introduction = request.Introduction;
|
if (request.Introduction != null) user.Introduction = request.Introduction;
|
||||||
|
|||||||
@@ -14,11 +14,22 @@ public class FollowUpController(FollowUpService followUpService) : ControllerBas
|
|||||||
private string Role => User.FindFirstValue(ClaimTypes.Role)!;
|
private string Role => User.FindFirstValue(ClaimTypes.Role)!;
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetFollowUps()
|
public async Task<IActionResult> GetFollowUps([FromQuery] string? type)
|
||||||
{
|
{
|
||||||
var followUps = Role == "doctor"
|
List<HealthManager.Domain.Entities.FollowUp> followUps;
|
||||||
? await followUpService.GetDoctorFollowUpsAsync(UserId)
|
|
||||||
: await followUpService.GetPatientFollowUpsAsync(UserId);
|
if (Role == "doctor" && type == "followup")
|
||||||
|
followUps = await followUpService.GetDoctorInitiatedFollowUpsAsync(UserId);
|
||||||
|
else if (Role == "doctor")
|
||||||
|
followUps = await followUpService.GetDoctorFollowUpsAsync(UserId);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
followUps = await followUpService.GetPatientFollowUpsAsync(UserId);
|
||||||
|
if (type == "followup")
|
||||||
|
followUps = followUps.Where(f => f.DoctorId != null).ToList();
|
||||||
|
else if (type == "recheck")
|
||||||
|
followUps = followUps.Where(f => f.DoctorId == null).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
return Ok(followUps.Select(f => new
|
return Ok(followUps.Select(f => new
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ public class MedicationController(MedicationService medicationService) : Control
|
|||||||
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
private string Role => User.FindFirstValue(ClaimTypes.Role)!;
|
private string Role => User.FindFirstValue(ClaimTypes.Role)!;
|
||||||
|
|
||||||
|
[HttpGet("today-summary")]
|
||||||
|
public async Task<IActionResult> GetTodaySummary()
|
||||||
|
{
|
||||||
|
var summary = await medicationService.GetTodaySummaryAsync(UserId);
|
||||||
|
return Ok(summary);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetMedications()
|
public async Task<IActionResult> GetMedications()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ const SIDEBAR_ICONS: Record<string, React.ReactNode> = {
|
|||||||
<line x1="3" y1="10" x2="21" y2="10" />
|
<line x1="3" y1="10" x2="21" y2="10" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
|
visits: (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" 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>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@@ -49,6 +56,7 @@ const navItems = [
|
|||||||
{ to: '/consultations', label: '在线问诊', ikey: 'consultations' },
|
{ to: '/consultations', label: '在线问诊', ikey: 'consultations' },
|
||||||
{ to: '/reports', label: '报告审核', ikey: 'reports' },
|
{ to: '/reports', label: '报告审核', ikey: 'reports' },
|
||||||
{ to: '/follow-ups', label: '复查管理', ikey: 'followups' },
|
{ to: '/follow-ups', label: '复查管理', ikey: 'followups' },
|
||||||
|
{ to: '/visits', label: '随访管理', ikey: 'visits' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const sidebarStyles = {
|
const sidebarStyles = {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function FollowUpEditPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 28 }}>
|
<div style={{ padding: 28 }}>
|
||||||
<h2 style={{ marginBottom: 20, fontSize: 20, fontWeight: 700, color: '#1A1D28' }}>{isNew ? '新建随访' : '编辑随访'}</h2>
|
<h2 style={{ marginBottom: 20, fontSize: 20, fontWeight: 700, color: '#1A1D28' }}>{isNew ? '新建复查' : '编辑复查'}</h2>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} style={{ background: '#fff', padding: 28, borderRadius: 16, maxWidth: 520, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
|
<form onSubmit={handleSubmit} style={{ background: '#fff', padding: 28, borderRadius: 16, maxWidth: 520, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
|||||||
@@ -9,41 +9,66 @@ interface RawFollowUpItem {
|
|||||||
|
|
||||||
export function FollowUpListPage() {
|
export function FollowUpListPage() {
|
||||||
const [followUps, setFollowUps] = useState<RawFollowUpItem[]>([]);
|
const [followUps, setFollowUps] = useState<RawFollowUpItem[]>([]);
|
||||||
|
const [showCompleted, setShowCompleted] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const load = () => {
|
||||||
api.get<RawFollowUpItem[]>('/api/follow-ups').then((r) => setFollowUps(r.data)).catch(() => {});
|
api.get<RawFollowUpItem[]>('/api/follow-ups?type=recheck')
|
||||||
}, []);
|
.then((r) => setFollowUps(r.data)).catch(() => {});
|
||||||
|
|
||||||
const statusLabel = (s: string) => {
|
|
||||||
switch (s) {
|
|
||||||
case 'upcoming': return { text: '待随访', color: '#F59E0B', bg: '#FFF8E6' };
|
|
||||||
case 'completed': return { text: '已完成', color: '#20C997', bg: '#E6F9F2' };
|
|
||||||
case 'cancelled': return { text: '已取消', color: '#EF4444', bg: '#FEE9E9' };
|
|
||||||
default: return { text: s, color: '#9BA0B4', bg: '#F5F6F9' };
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
const handleComplete = async (e: React.MouseEvent, id: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
await api.put(`/api/follow-ups/${id}`, { status: 'completed' });
|
||||||
|
load();
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (e: React.MouseEvent, id: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!confirm('确定删除?')) return;
|
||||||
|
try {
|
||||||
|
await api.del(`/api/follow-ups/${id}`);
|
||||||
|
load();
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const statusLabel = (f: RawFollowUpItem) => {
|
||||||
|
if (f.status === 'completed') return { text: '已完成', color: '#20C997', bg: '#E6F9F2' };
|
||||||
|
if (f.status === 'cancelled') return { text: '已取消', color: '#EF4444', bg: '#FEE9E9' };
|
||||||
|
if (f.scheduledAt && new Date(f.scheduledAt) < now) return { text: '已过期', color: '#EF4444', bg: '#FEE9E9' };
|
||||||
|
return { text: '待复查', color: '#F59E0B', bg: '#FFF8E6' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const active = followUps.filter((f) => f.status !== 'completed');
|
||||||
|
const completed = followUps.filter((f) => f.status === 'completed');
|
||||||
|
const displayed = [...active, ...(showCompleted ? completed : [])];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 28 }}>
|
<div style={{ padding: 28 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||||
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1A1D28', margin: 0 }}>随访管理</h2>
|
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1A1D28', margin: 0 }}>复查管理</h2>
|
||||||
<Link to="/follow-ups/new/edit" style={{
|
<Link to="/follow-ups/new/edit" style={{
|
||||||
padding: '10px 20px', background: 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)', color: '#fff',
|
padding: '10px 20px', background: 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)', color: '#fff',
|
||||||
borderRadius: 10, textDecoration: 'none', fontSize: 13, fontWeight: 600,
|
borderRadius: 10, textDecoration: 'none', fontSize: 13, fontWeight: 600,
|
||||||
boxShadow: '0 4px 16px rgba(79,110,247,0.25)',
|
boxShadow: '0 4px 16px rgba(79,110,247,0.25)',
|
||||||
}}>
|
}}>
|
||||||
新建随访
|
+ 新建复查
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ marginBottom: 18, fontSize: 13, color: '#9BA0B4' }}>共 {followUps.length} 条随访记录</p>
|
<p style={{ marginBottom: 18, fontSize: 13, color: '#9BA0B4' }}>共 {followUps.length} 条复查记录</p>
|
||||||
|
|
||||||
<div style={{ background: '#fff', borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)', overflow: 'hidden' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
{followUps.map((f) => {
|
{displayed.map((f) => {
|
||||||
const s = statusLabel(f.status);
|
const s = statusLabel(f);
|
||||||
return (
|
return (
|
||||||
<div key={f.id} style={{
|
<div key={f.id} style={{
|
||||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
padding: '16px 22px', borderBottom: '1px solid #F5F6F9',
|
padding: '16px 22px', background: '#fff', borderRadius: 14,
|
||||||
|
boxShadow: '0 1px 4px rgba(0,0,0,0.04)', border: '1px solid #F0F2F5',
|
||||||
}}>
|
}}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 14, fontWeight: 600, color: '#1A1D28' }}>{f.title}</div>
|
<div style={{ fontSize: 14, fontWeight: 600, color: '#1A1D28' }}>{f.title}</div>
|
||||||
@@ -51,22 +76,48 @@ export function FollowUpListPage() {
|
|||||||
{f.patientName || '未知'} · {f.scheduledAt?.split('T')[0]}
|
{f.patientName || '未知'} · {f.scheduledAt?.split('T')[0]}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<span style={{ padding: '4px 12px', borderRadius: 10, fontSize: 11, fontWeight: 500, background: s.bg, color: s.color }}>
|
<span style={{ padding: '4px 12px', borderRadius: 10, fontSize: 11, fontWeight: 500, background: s.bg, color: s.color }}>
|
||||||
{s.text}
|
{s.text}
|
||||||
</span>
|
</span>
|
||||||
|
{f.status === 'upcoming' && (
|
||||||
|
<button onClick={(e) => handleComplete(e, f.id)} style={{
|
||||||
|
padding: '4px 10px', borderRadius: 6, fontSize: 11, fontWeight: 600,
|
||||||
|
color: '#4F6EF7', background: '#EDF0FD', border: '1px solid #D0D5FD',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}>标记完成</button>
|
||||||
|
)}
|
||||||
<Link to={`/follow-ups/${f.id}/edit`} style={{
|
<Link to={`/follow-ups/${f.id}/edit`} style={{
|
||||||
color: '#4F6EF7', fontSize: 12, fontWeight: 600,
|
color: '#4F6EF7', fontSize: 12, fontWeight: 600,
|
||||||
padding: '4px 12px', background: '#EDF0FD', borderRadius: 6,
|
padding: '4px 10px', background: '#EDF0FD', borderRadius: 6, textDecoration: 'none',
|
||||||
}}>编辑</Link>
|
}}>编辑</Link>
|
||||||
|
<button onClick={(e) => handleDelete(e, f.id)} style={{
|
||||||
|
width: 26, height: 26, borderRadius: 6, border: 'none',
|
||||||
|
background: '#FEF2F2', color: '#EF4444', cursor: 'pointer',
|
||||||
|
fontSize: 14, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}} title="删除">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{followUps.length === 0 && (
|
{followUps.length === 0 && (
|
||||||
<div style={{ padding: 40, textAlign: 'center', color: '#9BA0B4', fontSize: 13 }}>暂无随访记录</div>
|
<div style={{ padding: 40, textAlign: 'center', color: '#9BA0B4', fontSize: 13 }}>暂无复查记录</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{completed.length > 0 && (
|
||||||
|
<button onClick={() => setShowCompleted(!showCompleted)} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6, margin: '12px auto 0',
|
||||||
|
padding: '8px 20px', borderRadius: 20, border: '1px solid #E4E8EE',
|
||||||
|
background: '#fff', color: '#9BA0B4', fontSize: 12, cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
{showCompleted ? '收起已完成' : `查看已完成 (${completed.length})`}
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
style={{ transform: showCompleted ? 'rotate(180deg)' : '' }}>
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
82
frontend-doctor/src/pages/followups/VisitEditPage.tsx
Normal file
82
frontend-doctor/src/pages/followups/VisitEditPage.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { api } from '../../services/api-client';
|
||||||
|
|
||||||
|
export function VisitEditPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isNew = !id || id === 'new';
|
||||||
|
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [patientId, setPatientId] = useState('');
|
||||||
|
const [scheduledAt, setScheduledAt] = useState('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [patients, setPatients] = useState<{ id: string; name: string }[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<{ id: string; name: string }[]>('/api/patients').then((r) => setPatients(r.data));
|
||||||
|
if (!isNew) {
|
||||||
|
api.get<Record<string, unknown>>(`/api/follow-ups/${id}`).then((r) => {
|
||||||
|
setTitle(r.data.title as string);
|
||||||
|
setPatientId(r.data.patientId as string);
|
||||||
|
setScheduledAt((r.data.scheduledAt as string)?.slice(0, 16) || '');
|
||||||
|
setNotes((r.data.notes as string) || '');
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}, [id, isNew]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
if (isNew) {
|
||||||
|
await api.post('/api/follow-ups', { title, patientId, scheduledAt, notes });
|
||||||
|
} else {
|
||||||
|
await api.put(`/api/follow-ups/${id}`, { title, patientId, scheduledAt, notes });
|
||||||
|
}
|
||||||
|
navigate('/visits');
|
||||||
|
} catch { alert('操作失败'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: '100%', padding: '10px 14px', border: '1.5px solid #E1E5ED',
|
||||||
|
borderRadius: 10, fontSize: 13, outline: 'none', boxSizing: 'border-box',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 28 }}>
|
||||||
|
<h2 style={{ marginBottom: 20, fontSize: 20, fontWeight: 700, color: '#1A1D28' }}>
|
||||||
|
{isNew ? '新建随访' : '编辑随访'}
|
||||||
|
</h2>
|
||||||
|
<form onSubmit={handleSubmit} style={{ background: '#fff', padding: 28, borderRadius: 16, maxWidth: 520, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 5 }}>随访标题</label>
|
||||||
|
<input value={title} onChange={(e) => setTitle(e.target.value)} required style={inputStyle}
|
||||||
|
placeholder="如:PCI术后1个月随访" />
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 5 }}>随访患者</label>
|
||||||
|
<select value={patientId} onChange={(e) => setPatientId(e.target.value)} required style={inputStyle}>
|
||||||
|
<option value="">请选择</option>
|
||||||
|
{patients.map((p) => (<option key={p.id} value={p.id}>{p.name}</option>))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 5 }}>随访时间</label>
|
||||||
|
<input type="datetime-local" value={scheduledAt} onChange={(e) => setScheduledAt(e.target.value)} required style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 5 }}>随访内容 / 备注</label>
|
||||||
|
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={4}
|
||||||
|
style={{ ...inputStyle, resize: 'vertical', fontFamily: 'inherit' }} placeholder="告知患者本次随访的目的、需要准备的材料等" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" style={{
|
||||||
|
padding: '11px 28px', background: 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)', color: '#fff',
|
||||||
|
border: 'none', borderRadius: 10, fontSize: 14, fontWeight: 600,
|
||||||
|
boxShadow: '0 4px 16px rgba(79,110,247,0.25)',
|
||||||
|
}}>
|
||||||
|
{isNew ? '创建随访' : '保存'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
frontend-doctor/src/pages/followups/VisitListPage.tsx
Normal file
73
frontend-doctor/src/pages/followups/VisitListPage.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { api } from '../../services/api-client';
|
||||||
|
|
||||||
|
interface VisitItem {
|
||||||
|
id: string; patientId: string; patientName: string;
|
||||||
|
title: string; scheduledAt: string; status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VisitListPage() {
|
||||||
|
const [visits, setVisits] = useState<VisitItem[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<VisitItem[]>('/api/follow-ups?type=followup')
|
||||||
|
.then((r) => setVisits(r.data)).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const statusLabel = (s: string) => {
|
||||||
|
switch (s) {
|
||||||
|
case 'upcoming': return { text: '待随访', color: '#F59E0B', bg: '#FFF8E6' };
|
||||||
|
case 'completed': return { text: '已完成', color: '#20C997', bg: '#E6F9F2' };
|
||||||
|
case 'cancelled': return { text: '已取消', color: '#EF4444', bg: '#FEE9E9' };
|
||||||
|
default: return { text: s, color: '#9BA0B4', bg: '#F5F6F9' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 28 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||||
|
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1A1D28', margin: 0 }}>随访管理</h2>
|
||||||
|
<Link to="/visits/new/edit" style={{
|
||||||
|
padding: '10px 20px', background: 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)', color: '#fff',
|
||||||
|
borderRadius: 10, textDecoration: 'none', fontSize: 13, fontWeight: 600,
|
||||||
|
boxShadow: '0 4px 16px rgba(79,110,247,0.25)',
|
||||||
|
}}>
|
||||||
|
+ 新建随访
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<p style={{ marginBottom: 18, fontSize: 13, color: '#9BA0B4' }}>共 {visits.length} 条随访计划</p>
|
||||||
|
|
||||||
|
<div style={{ background: '#fff', borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)', overflow: 'hidden' }}>
|
||||||
|
{visits.map((v) => {
|
||||||
|
const s = statusLabel(v.status);
|
||||||
|
return (
|
||||||
|
<div key={v.id} style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
padding: '16px 22px', borderBottom: '1px solid #F5F6F9',
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, color: '#1A1D28' }}>{v.title}</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#9BA0B4', marginTop: 3 }}>
|
||||||
|
{v.patientName || '未知'} · {v.scheduledAt?.split('T')[0]}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
|
<span style={{ padding: '4px 12px', borderRadius: 10, fontSize: 11, fontWeight: 500, background: s.bg, color: s.color }}>
|
||||||
|
{s.text}
|
||||||
|
</span>
|
||||||
|
<Link to={`/visits/${v.id}/edit`} style={{
|
||||||
|
color: '#4F6EF7', fontSize: 12, fontWeight: 600,
|
||||||
|
padding: '4px 12px', background: '#EDF0FD', borderRadius: 6,
|
||||||
|
}}>详情</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{visits.length === 0 && (
|
||||||
|
<div style={{ padding: 40, textAlign: 'center', color: '#9BA0B4', fontSize: 13 }}>暂无随访计划</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { api } from '../../services/api-client';
|
|||||||
|
|
||||||
interface Patient {
|
interface Patient {
|
||||||
id: string; name: string; phone: string; gender: string;
|
id: string; name: string; phone: string; gender: string;
|
||||||
medicalHistory: string[]; stentDate: string;
|
medicalHistory: string[]; stentDate: string; stentType: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PatientListPage() {
|
export function PatientListPage() {
|
||||||
@@ -47,6 +47,7 @@ export function PatientListPage() {
|
|||||||
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>性别</th>
|
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>性别</th>
|
||||||
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>病史</th>
|
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>病史</th>
|
||||||
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>支架日期</th>
|
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>支架日期</th>
|
||||||
|
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>支架类型</th>
|
||||||
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>操作</th>
|
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -58,6 +59,7 @@ export function PatientListPage() {
|
|||||||
<td style={{ padding: '12px 20px' }}>{p.gender || '-'}</td>
|
<td style={{ padding: '12px 20px' }}>{p.gender || '-'}</td>
|
||||||
<td style={{ padding: '12px 20px', color: '#5A6072' }}>{(p.medicalHistory || []).slice(0, 3).join('、') || '-'}</td>
|
<td style={{ padding: '12px 20px', color: '#5A6072' }}>{(p.medicalHistory || []).slice(0, 3).join('、') || '-'}</td>
|
||||||
<td style={{ padding: '12px 20px', color: '#5A6072' }}>{p.stentDate || '-'}</td>
|
<td style={{ padding: '12px 20px', color: '#5A6072' }}>{p.stentDate || '-'}</td>
|
||||||
|
<td style={{ padding: '12px 20px', color: '#5A6072' }}>{p.stentType || '-'}</td>
|
||||||
<td style={{ padding: '12px 20px' }}>
|
<td style={{ padding: '12px 20px' }}>
|
||||||
<Link to={`/patients/${p.id}`} style={{
|
<Link to={`/patients/${p.id}`} style={{
|
||||||
color: '#4F6EF7', fontSize: 12, fontWeight: 600,
|
color: '#4F6EF7', fontSize: 12, fontWeight: 600,
|
||||||
@@ -67,7 +69,7 @@ export function PatientListPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<tr><td colSpan={6} style={{ padding: 32, textAlign: 'center', color: '#9BA0B4' }}>暂无患者数据</td></tr>
|
<tr><td colSpan={7} style={{ padding: 32, textAlign: 'center', color: '#9BA0B4' }}>暂无患者数据</td></tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ interface RawReport {
|
|||||||
imageUrls: string[]; status: string; riskLevel?: string;
|
imageUrls: string[]; status: string; riskLevel?: string;
|
||||||
summary?: string; suggestions?: string;
|
summary?: string; suggestions?: string;
|
||||||
patientName?: string; doctorName?: string;
|
patientName?: string; doctorName?: string;
|
||||||
createdAt: string; completedAt?: string;
|
uploadedAt: string; completedAt?: string;
|
||||||
items?: RawItem[];
|
items?: RawItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ export function ReportDetailPage() {
|
|||||||
<div style={{ marginTop: 8, fontSize: 13, color: '#888' }}>
|
<div style={{ marginTop: 8, fontSize: 13, color: '#888' }}>
|
||||||
患者:{report.patientName || '未知'} |
|
患者:{report.patientName || '未知'} |
|
||||||
分类:{categoryMap[report.category] || report.category} |
|
分类:{categoryMap[report.category] || report.category} |
|
||||||
日期:{report.createdAt?.split('T')[0]}
|
日期:{report.uploadedAt?.split('T')[0]}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span style={{
|
<span style={{
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { ReportListPage } from '../pages/reports/ReportListPage';
|
|||||||
import { ReportDetailPage } from '../pages/reports/ReportDetailPage';
|
import { ReportDetailPage } from '../pages/reports/ReportDetailPage';
|
||||||
import { FollowUpListPage } from '../pages/followups/FollowUpListPage';
|
import { FollowUpListPage } from '../pages/followups/FollowUpListPage';
|
||||||
import { FollowUpEditPage } from '../pages/followups/FollowUpEditPage';
|
import { FollowUpEditPage } from '../pages/followups/FollowUpEditPage';
|
||||||
|
import { VisitListPage } from '../pages/followups/VisitListPage';
|
||||||
|
import { VisitEditPage } from '../pages/followups/VisitEditPage';
|
||||||
import { ProfilePage } from '../pages/settings/ProfilePage';
|
import { ProfilePage } from '../pages/settings/ProfilePage';
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
@@ -35,6 +37,8 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'reports/:id', element: <ReportDetailPage /> },
|
{ path: 'reports/:id', element: <ReportDetailPage /> },
|
||||||
{ path: 'follow-ups', element: <FollowUpListPage /> },
|
{ path: 'follow-ups', element: <FollowUpListPage /> },
|
||||||
{ path: 'follow-ups/:id/edit', element: <FollowUpEditPage /> },
|
{ path: 'follow-ups/:id/edit', element: <FollowUpEditPage /> },
|
||||||
|
{ path: 'visits', element: <VisitListPage /> },
|
||||||
|
{ path: 'visits/:id/edit', element: <VisitEditPage /> },
|
||||||
{ path: 'profile', element: <ProfilePage /> },
|
{ path: 'profile', element: <ProfilePage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
.greetingBar {
|
.greetingBar {
|
||||||
padding: 8px 0 16px;
|
padding: 12px 0 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.greetingText {
|
.dateText {
|
||||||
font-size: 22px;
|
font-size: 15px;
|
||||||
font-weight: 800;
|
font-weight: 600;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notifyBtn {
|
.notifyBtn {
|
||||||
position: relative;
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -206,11 +210,143 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.quickLabel {
|
.quickLabel {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-primary);
|
||||||
font-weight: 600;
|
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 */
|
/* Health Tip */
|
||||||
.tipCard {
|
.tipCard {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Card } from '@/components/common/Card';
|
import { Card } from '@/components/common/Card';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useNotificationStore } from '@/stores/notification.store';
|
import { useNotificationStore } from '@/stores/notification.store';
|
||||||
|
import { api } from '@/services/api-client';
|
||||||
import * as healthService from '@/services/health.service';
|
import * as healthService from '@/services/health.service';
|
||||||
import type { HealthStats } from '@/types';
|
import type { HealthStats } from '@/types';
|
||||||
import styles from './HomePage.module.css';
|
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 = [
|
const QUICK_ACTIONS = [
|
||||||
{
|
{
|
||||||
key: 'bp',
|
key: 'bp',
|
||||||
@@ -117,10 +123,14 @@ export function HomePage() {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { unreadCount, fetchNotifications } = useNotificationStore();
|
const { unreadCount, fetchNotifications } = useNotificationStore();
|
||||||
const [stats, setStats] = useState<HealthStats[]>([]);
|
const [stats, setStats] = useState<HealthStats[]>([]);
|
||||||
|
const [meds, setMeds] = useState<MedSummary[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
healthService.getLatestStats().then(setStats);
|
healthService.getLatestStats().then(setStats);
|
||||||
fetchNotifications();
|
fetchNotifications();
|
||||||
|
api.get<MedSummary[]>('/api/medications/today-summary')
|
||||||
|
.then((r) => setMeds(r.data))
|
||||||
|
.catch(() => {});
|
||||||
}, [fetchNotifications]);
|
}, [fetchNotifications]);
|
||||||
|
|
||||||
const bpStats = stats.find((s) => s.type === 'blood_pressure');
|
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 systolic = typeof bpValue === 'object' ? bpValue.systolic : null;
|
||||||
const diastolic = typeof bpValue === 'object' ? bpValue.diastolic : 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
|
const bpAbnormal = systolic !== null && diastolic !== null
|
||||||
&& (systolic >= 120 || diastolic >= 80);
|
&& (systolic >= 120 || diastolic >= 80);
|
||||||
|
|
||||||
@@ -147,7 +162,7 @@ export function HomePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="page" style={{ paddingTop: 0 }}>
|
<div className="page" style={{ paddingTop: 0 }}>
|
||||||
<div className={styles.greetingBar}>
|
<div className={styles.greetingBar}>
|
||||||
<div className={styles.greetingText}>你好,{user?.nickname || '用户'}</div>
|
<div className={styles.dateText}>{todayDate}</div>
|
||||||
<button onClick={() => navigate('/notifications')} className={styles.notifyBtn}>
|
<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">
|
<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" />
|
<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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
.heroHeader {
|
||||||
|
|
||||||
.infoRow {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
align-items: flex-start;
|
||||||
padding: 8px 0;
|
gap: 16px;
|
||||||
font-size: var(--font-size-sm);
|
margin-bottom: 20px;
|
||||||
border-bottom: 1px solid var(--color-divider);
|
}
|
||||||
|
|
||||||
|
.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);
|
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; }
|
.infoLabel {
|
||||||
|
font-size: 11px;
|
||||||
.adherenceRate {
|
color: var(--color-text-tertiary);
|
||||||
font-size: var(--font-size-3xl);
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--color-success);
|
|
||||||
margin-bottom: 4px;
|
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); }
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ export function MedicationDetailPage() {
|
|||||||
|
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
const todayRecords = records.filter((r) => r.takenAt?.startsWith(today) || !r.takenAt);
|
const todayRecords = records.filter((r) => r.takenAt?.startsWith(today) || !r.takenAt);
|
||||||
const todayTaken = todayRecords.filter((r) => r.isTaken);
|
|
||||||
const todaySlots = med?.timeSlots || [];
|
const todaySlots = med?.timeSlots || [];
|
||||||
|
|
||||||
const handleMarkTaken = async (slot: string) => {
|
const handleMarkTaken = async (slot: string) => {
|
||||||
@@ -47,7 +46,6 @@ export function MedicationDetailPage() {
|
|||||||
|
|
||||||
const slotTaken = (slot: string) => todayRecords.some((r) => r.timeSlot === slot && r.isTaken);
|
const slotTaken = (slot: string) => todayRecords.some((r) => r.timeSlot === slot && r.isTaken);
|
||||||
|
|
||||||
// Recent 7 days adherence
|
|
||||||
const last7Days: { date: string; taken: number; total: number }[] = [];
|
const last7Days: { date: string; taken: number; total: number }[] = [];
|
||||||
for (let i = 6; i >= 0; i--) {
|
for (let i = 6; i >= 0; i--) {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
@@ -60,71 +58,116 @@ export function MedicationDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="page--no-tab">
|
<div className="page--no-tab">
|
||||||
<PageHeader title={med.drugName} />
|
<PageHeader title={med.drugName} />
|
||||||
|
|
||||||
<Card className={styles.infoCard}>
|
<Card className={styles.infoCard}>
|
||||||
<div className={styles.infoTitle}>{med.drugName}</div>
|
<div className={styles.heroHeader}>
|
||||||
<div className={styles.infoRow}><span>剂量</span><span>{med.dosage}</span></div>
|
<div className={styles.heroIcon}>
|
||||||
<div className={styles.infoRow}><span>频次</span><span>{med.frequency} · {med.timeSlots.join(', ')}</span></div>
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<div className={styles.infoRow}><span>日期</span><span>{med.startDate} ~ {med.endDate || '长期'}</span></div>
|
<rect x="4" y="5" width="16" height="14" rx="4" />
|
||||||
{med.notes && <div className={styles.infoRow}><span>备注</span><span>{med.notes}</span></div>}
|
<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>
|
</Card>
|
||||||
|
|
||||||
{/* Today's medication tracking */}
|
{med.notes && (
|
||||||
<Card className={styles.infoCard}>
|
<Card className={styles.infoCard}>
|
||||||
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>
|
<div className={styles.infoLabel} style={{ marginBottom: 4 }}>备注</div>
|
||||||
今日服药 <span style={{ color: '#9CA3AF', fontWeight: 400, fontSize: 13 }}>{today}</span>
|
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.6 }}>{med.notes}</div>
|
||||||
</div>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card className={styles.infoCard}>
|
||||||
|
<div className={styles.todayTitle}>今日服药</div>
|
||||||
|
<div className={styles.todayDate}>{today}</div>
|
||||||
|
|
||||||
{todaySlots.map((slot) => {
|
{todaySlots.map((slot) => {
|
||||||
const taken = slotTaken(slot);
|
const taken = slotTaken(slot);
|
||||||
return (
|
return (
|
||||||
<div key={slot} style={{
|
<div key={slot} className={`${styles.slotRow} ${taken ? styles.slotRowTaken : styles.slotRowPending}`}>
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
<div className={styles.slotLeft}>
|
||||||
padding: '10px 0', borderBottom: '1px solid #f0f0f0',
|
<div className={`${styles.slotCircle} ${taken ? styles.slotCircleTaken : styles.slotCirclePending}`}>
|
||||||
}}>
|
{taken ? '✓' : slot}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
</div>
|
||||||
<div style={{
|
<div>
|
||||||
width: 10, height: 10, borderRadius: 5,
|
<div className={styles.slotTime}>{slot}</div>
|
||||||
background: taken ? '#10B981' : '#E5E7EB',
|
<div className={styles.slotLabel}>{taken ? '已服用' : '待服用'}</div>
|
||||||
}} />
|
</div>
|
||||||
<span style={{ fontSize: 14 }}>{slot}</span>
|
|
||||||
<span style={{ fontSize: 12, color: taken ? '#10B981' : '#9CA3AF' }}>
|
|
||||||
{taken ? '已服用' : '未服用'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{!taken && med.status === 'active' && (
|
{!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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 7-day adherence */}
|
|
||||||
<Card className={styles.infoCard}>
|
<Card className={styles.infoCard}>
|
||||||
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>近7天记录</div>
|
<div className={styles.chartTitle}>近7天记录</div>
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
<div className={styles.chartBars}>
|
||||||
{last7Days.map((d) => {
|
{last7Days.map((d) => {
|
||||||
const pct = d.total > 0 ? (d.taken / d.total) * 100 : 0;
|
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 (
|
return (
|
||||||
<div key={d.date} style={{ flex: 1, textAlign: 'center' }}>
|
<div key={d.date} className={styles.chartBarWrap}>
|
||||||
<div style={{
|
<div
|
||||||
height: 40, borderRadius: 6, marginBottom: 4,
|
className={`${styles.chartBar} ${pct === 100 ? styles.chartBarFull : pct > 0 ? styles.chartBarPartial : styles.chartBarEmpty}`}
|
||||||
background: pct === 100 ? '#10B981' : pct > 0 ? '#F59E0B' : '#E5E7EB',
|
style={{ height }}
|
||||||
transition: 'background 0.3s',
|
/>
|
||||||
}} />
|
<div className={styles.chartDate}>{d.date.slice(5)}</div>
|
||||||
<div style={{ fontSize: 10, color: '#9CA3AF' }}>{d.date.slice(5)}</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 12, marginTop: 8, fontSize: 11, color: '#9CA3AF' }}>
|
<div className={styles.chartLegend}>
|
||||||
<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.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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,45 +1,182 @@
|
|||||||
.tabs {
|
.tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 16px;
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
padding: 6px 16px;
|
flex: 1;
|
||||||
border-radius: var(--radius-full);
|
padding: 10px 0;
|
||||||
font-size: var(--font-size-sm);
|
border-radius: 10px;
|
||||||
background: var(--color-bg-secondary);
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-weight: 500;
|
text-align: center;
|
||||||
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabActive {
|
.tabActive {
|
||||||
background: var(--color-primary);
|
background: var(--color-white);
|
||||||
color: var(--color-text-inverse);
|
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 {
|
.medHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
justify-content: space-between;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.medName { font-size: var(--font-size-base); font-weight: 600; }
|
.medName {
|
||||||
.medDosage { font-size: var(--font-size-sm); color: var(--color-text-secondary); }
|
font-size: 16px;
|
||||||
.medNote { font-size: var(--font-size-xs); color: var(--color-text-tertiary); margin-top: 4px; }
|
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 {
|
.fab {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 80px;
|
bottom: 80px;
|
||||||
right: max(16px, calc((100vw - var(--max-content-width)) / 2 + 16px));
|
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);
|
background: var(--color-primary-gradient);
|
||||||
color: var(--color-text-inverse);
|
color: var(--color-text-inverse);
|
||||||
border-radius: var(--radius-full);
|
font-size: 22px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
box-shadow: 0 4px 16px rgba(79,110,247,0.35);
|
box-shadow: 0 4px 16px rgba(79,110,247,0.35);
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:active {
|
||||||
|
transform: scale(0.92);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
import { PageHeader } from '@/components/layout/PageHeader';
|
||||||
import { Card } from '@/components/common/Card';
|
import { Card } from '@/components/common/Card';
|
||||||
import { Empty } from '@/components/common/Empty';
|
import { Empty } from '@/components/common/Empty';
|
||||||
|
import { ToastContainer, toast } from '@/components/common/Toast';
|
||||||
import * as medicationService from '@/services/medication.service';
|
import * as medicationService from '@/services/medication.service';
|
||||||
import type { Medication } from '@/types';
|
import type { Medication } from '@/types';
|
||||||
import styles from './MedicationListPage.module.css';
|
import styles from './MedicationListPage.module.css';
|
||||||
@@ -12,13 +13,20 @@ export function MedicationListPage() {
|
|||||||
const [medications, setMedications] = useState<Medication[]>([]);
|
const [medications, setMedications] = useState<Medication[]>([]);
|
||||||
const [tab, setTab] = useState<'active' | 'ended'>('active');
|
const [tab, setTab] = useState<'active' | 'ended'>('active');
|
||||||
|
|
||||||
useEffect(() => {
|
const load = () => { medicationService.getMedications().then(setMedications); };
|
||||||
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 (
|
return (
|
||||||
<div className="page--no-tab">
|
<div className="page--no-tab">
|
||||||
@@ -32,22 +40,54 @@ export function MedicationListPage() {
|
|||||||
<Empty message={tab === 'active' ? '暂无进行中的用药' : '暂无已结束的用药'} />
|
<Empty message={tab === 'active' ? '暂无进行中的用药' : '暂无已结束的用药'} />
|
||||||
) : (
|
) : (
|
||||||
filtered.map((med) => (
|
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}>
|
<div className={styles.medHeader}>
|
||||||
<span className={styles.medName}>{med.drugName}</span>
|
<span className={styles.medName}>{med.drugName}</span>
|
||||||
{med.status === 'active' && allTaken(med) && (
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#10B981" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
<span className={`${styles.medStatus} ${med.status === 'active' ? styles.medStatusActive : styles.medStatusEnded}`}>
|
||||||
<polyline points="20 6 9 17 4 12" />
|
{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>
|
</svg>
|
||||||
)}
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.medDosage}>{med.dosage} · {med.frequency}</div>
|
</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>
|
</Card>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button className={styles.fab} onClick={() => navigate('/health/medications/add')}>+ 添加用药</button>
|
<button className={styles.fab} onClick={() => navigate('/health/medications/add')}>+</button>
|
||||||
|
<ToastContainer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,18 +15,18 @@ export function EditProfilePage() {
|
|||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [gender, setGender] = useState('');
|
const [gender, setGender] = useState('');
|
||||||
const [birthday, setBirthday] = useState('');
|
const [birthday, setBirthday] = useState('');
|
||||||
const [height, setHeight] = useState('');
|
|
||||||
const [weight, setWeight] = useState('');
|
|
||||||
const [history, setHistory] = useState('');
|
const [history, setHistory] = useState('');
|
||||||
|
const [stentDate, setStentDate] = useState('');
|
||||||
|
const [stentType, setStentType] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
setName(user.nickname || '');
|
setName(user.nickname || '');
|
||||||
setGender(user.gender || '');
|
setGender(user.gender || '');
|
||||||
setBirthday(user.birthday || '');
|
setBirthday(user.birthday || '');
|
||||||
setHeight(user.height ? String(user.height) : '');
|
|
||||||
setWeight(user.weight ? String(user.weight) : '');
|
|
||||||
setHistory((user.medicalHistory || []).join('、'));
|
setHistory((user.medicalHistory || []).join('、'));
|
||||||
|
setStentDate(user.stentImplantDate || '');
|
||||||
|
setStentType(user.stentType || '');
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
@@ -37,18 +37,18 @@ export function EditProfilePage() {
|
|||||||
name: name || undefined,
|
name: name || undefined,
|
||||||
gender: gender || undefined,
|
gender: gender || undefined,
|
||||||
birthday: birthday || undefined,
|
birthday: birthday || undefined,
|
||||||
heightCm: height ? Number(height) : undefined,
|
|
||||||
weightKg: weight ? Number(weight) : undefined,
|
|
||||||
medicalHistory: history ? history.split(/[、,,]/).filter(Boolean) : undefined,
|
medicalHistory: history ? history.split(/[、,,]/).filter(Boolean) : undefined,
|
||||||
|
stentDate: stentDate || undefined,
|
||||||
|
stentType: stentType || undefined,
|
||||||
};
|
};
|
||||||
await authService.updateProfile(data);
|
await authService.updateProfile(data);
|
||||||
updateProfile({
|
updateProfile({
|
||||||
nickname: name,
|
nickname: name,
|
||||||
gender: gender as 'male' | 'female' | 'unknown',
|
gender: gender as 'male' | 'female' | 'unknown',
|
||||||
birthday,
|
birthday,
|
||||||
height: height ? Number(height) : 0,
|
|
||||||
weight: weight ? Number(weight) : 0,
|
|
||||||
medicalHistory: history ? history.split(/[、,,]/).filter(Boolean) : [],
|
medicalHistory: history ? history.split(/[、,,]/).filter(Boolean) : [],
|
||||||
|
stentImplantDate: stentDate,
|
||||||
|
stentType,
|
||||||
});
|
});
|
||||||
toast('保存成功');
|
toast('保存成功');
|
||||||
setTimeout(() => navigate(-1), 800);
|
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)} />
|
<input className={styles.input} type="date" value={birthday} onChange={(e) => setBirthday(e.target.value)} />
|
||||||
</div>
|
</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}>
|
<div className={styles.field}>
|
||||||
<label className={styles.label}>既往病史(用顿号分隔)</label>
|
<label className={styles.label}>既往病史(用顿号分隔)</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -114,6 +103,16 @@ export function EditProfilePage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 variant="primary" size="lg" fullWidth loading={loading} onClick={handleSave}>
|
||||||
保存
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
191
frontend-patient/src/pages/profile/HealthRecordPage.module.css
Normal file
191
frontend-patient/src/pages/profile/HealthRecordPage.module.css
Normal 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;
|
||||||
|
}
|
||||||
125
frontend-patient/src/pages/profile/HealthRecordPage.tsx
Normal file
125
frontend-patient/src/pages/profile/HealthRecordPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,83 +2,131 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 20px;
|
||||||
background: var(--color-primary-gradient);
|
background: var(--color-primary-gradient);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
padding: 20px;
|
padding: 24px 20px;
|
||||||
box-shadow: 0 6px 24px rgba(79,110,247,0.3);
|
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 {
|
.avatar {
|
||||||
width: 56px;
|
width: 60px;
|
||||||
height: 56px;
|
height: 60px;
|
||||||
border-radius: 18px;
|
border-radius: 20px;
|
||||||
background: rgba(255,255,255,0.25);
|
background: rgba(255,255,255,0.2);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: var(--font-size-xl);
|
font-size: 24px;
|
||||||
font-weight: 800;
|
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; }
|
.nickname { font-size: 20px; font-weight: 800; }
|
||||||
.phone { font-size: 12px; opacity: 0.7; margin-top: 2px; }
|
.phone { font-size: 13px; opacity: 0.7; margin-top: 3px; }
|
||||||
.editHint { color: rgba(255,255,255,0.8); }
|
|
||||||
|
|
||||||
.statsCard {
|
.editBadge {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-around;
|
gap: 2px;
|
||||||
margin-bottom: 16px;
|
margin-top: 8px;
|
||||||
padding: 16px 0;
|
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; }
|
.menuSection {
|
||||||
.statValue { font-size: 18px; font-weight: 800; display: block; color: var(--color-text-primary); }
|
margin-bottom: 16px;
|
||||||
.statLabel { font-size: 11px; color: var(--color-text-tertiary); font-weight: 500; }
|
}
|
||||||
.statDivider { width: 1px; height: 32px; background: var(--color-divider); }
|
|
||||||
|
.menuSectionTitle {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
padding: 0 4px 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
.menuList {
|
.menuList {
|
||||||
background: var(--color-white);
|
background: var(--color-white);
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.menuItem {
|
.menuItem {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 15px 18px;
|
padding: 16px 18px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: var(--font-size-base);
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
color: var(--color-text-primary);
|
||||||
border-bottom: 1px solid var(--color-divider);
|
border-bottom: 1px solid var(--color-divider);
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menuItem:last-child { border-bottom: none; }
|
.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 {
|
.logoutBtn {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 14px;
|
padding: 15px;
|
||||||
background: var(--color-white);
|
background: var(--color-white);
|
||||||
color: var(--color-danger);
|
color: var(--color-danger);
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
font-size: var(--font-size-base);
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
border: 1.5px solid #FDD;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logoutBtn:active { background: #FFF5F5; }
|
.logoutBtn:active { background: var(--color-danger-bg); }
|
||||||
|
|||||||
@@ -30,77 +30,67 @@ export function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</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}>
|
<div className={styles.menuList}>
|
||||||
<button className={styles.menuItem} onClick={() => navigate('/health/medications')}>
|
<button className={styles.menuItem} onClick={() => navigate('/profile/health-record')}>
|
||||||
<span>
|
<span className={styles.menuItemLeft}>
|
||||||
<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 }}>
|
<span className={styles.menuIcon} style={{ background: 'var(--color-primary-bg)' }}>
|
||||||
<rect x="4" y="5" width="16" height="14" rx="4" />
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M10 9v6M14 9v6M8 12h8" />
|
<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>
|
</svg>
|
||||||
我的用药
|
|
||||||
</span>
|
</span>
|
||||||
<span>→</span>
|
健康档案
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button className={styles.menuItem} onClick={() => navigate('/notifications')}>
|
<button className={styles.menuItem} onClick={() => navigate('/notifications')}>
|
||||||
<span>
|
<span className={styles.menuItemLeft}>
|
||||||
<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 }}>
|
<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="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" />
|
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||||
</svg>
|
</svg>
|
||||||
|
</span>
|
||||||
消息通知
|
消息通知
|
||||||
</span>
|
</span>
|
||||||
<div className={styles.menuRight}>
|
<span className={styles.menuArrow}>
|
||||||
{unreadCount > 0 && <Badge count={unreadCount} />}
|
{unreadCount > 0 && <Badge count={unreadCount} />}
|
||||||
<span>→</span>
|
</span>
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
<button className={styles.menuItem} onClick={() => navigate('/home/device-binding')}>
|
<button className={styles.menuItem} onClick={() => navigate('/home/device-binding')}>
|
||||||
<span>
|
<span className={styles.menuItemLeft}>
|
||||||
<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 }}>
|
<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" />
|
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
|
||||||
<line x1="12" y1="18" x2="12.01" y2="18" />
|
<line x1="12" y1="18" x2="12.01" y2="18" />
|
||||||
</svg>
|
</svg>
|
||||||
|
</span>
|
||||||
设备管理
|
设备管理
|
||||||
</span>
|
</span>
|
||||||
<span>→</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button className={styles.menuItem} onClick={() => navigate('/profile/settings')}>
|
<button className={styles.menuItem} onClick={() => navigate('/profile/settings')}>
|
||||||
<span>
|
<span className={styles.menuItemLeft}>
|
||||||
<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 }}>
|
<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" />
|
<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" />
|
<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>
|
</svg>
|
||||||
|
</span>
|
||||||
设置
|
设置
|
||||||
</span>
|
</span>
|
||||||
<span>→</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button className={styles.menuItem} onClick={() => navigate('/profile/settings/about')}>
|
<button className={styles.menuItem} onClick={() => navigate('/profile/settings/about')}>
|
||||||
<span>
|
<span className={styles.menuItemLeft}>
|
||||||
<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 }}>
|
<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" />
|
<circle cx="12" cy="12" r="10" />
|
||||||
<line x1="12" y1="16" x2="12" y2="12" />
|
<line x1="12" y1="16" x2="12" y2="12" />
|
||||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||||
</svg>
|
</svg>
|
||||||
|
</span>
|
||||||
关于
|
关于
|
||||||
</span>
|
</span>
|
||||||
<span>→</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function FollowUpListPage() {
|
|||||||
const [tab, setTab] = useState<'upcoming' | 'completed'>('upcoming');
|
const [tab, setTab] = useState<'upcoming' | 'completed'>('upcoming');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
followupService.getFollowUps().then(setFollowups);
|
followupService.getFollowUps('recheck').then(setFollowups);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filtered = followups.filter((f) => tab === 'upcoming' ? f.status === 'upcoming' : f.status === 'completed');
|
const filtered = followups.filter((f) => tab === 'upcoming' ? f.status === 'upcoming' : f.status === 'completed');
|
||||||
|
|||||||
@@ -41,6 +41,19 @@ const SERVICES = [
|
|||||||
),
|
),
|
||||||
bg: '#FEE9E9',
|
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() {
|
export function ServicesHubPage() {
|
||||||
|
|||||||
58
frontend-patient/src/pages/services/VisitListPage.module.css
Normal file
58
frontend-patient/src/pages/services/VisitListPage.module.css
Normal 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;
|
||||||
|
}
|
||||||
45
frontend-patient/src/pages/services/VisitListPage.tsx
Normal file
45
frontend-patient/src/pages/services/VisitListPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,9 +22,11 @@ import { ReportUploadPage } from '@/pages/services/ReportUploadPage';
|
|||||||
import { ReportDetailPage } from '@/pages/services/ReportDetailPage';
|
import { ReportDetailPage } from '@/pages/services/ReportDetailPage';
|
||||||
import { FollowUpListPage } from '@/pages/services/FollowUpListPage';
|
import { FollowUpListPage } from '@/pages/services/FollowUpListPage';
|
||||||
import { FollowUpEditPage } from '@/pages/services/FollowUpEditPage';
|
import { FollowUpEditPage } from '@/pages/services/FollowUpEditPage';
|
||||||
|
import { VisitListPage } from '@/pages/services/VisitListPage';
|
||||||
import { ExerciseDietPage } from '@/pages/exercise-diet/ExerciseDietPage';
|
import { ExerciseDietPage } from '@/pages/exercise-diet/ExerciseDietPage';
|
||||||
import { ProfilePage } from '@/pages/profile/ProfilePage';
|
import { ProfilePage } from '@/pages/profile/ProfilePage';
|
||||||
import { EditProfilePage } from '@/pages/profile/EditProfilePage';
|
import { EditProfilePage } from '@/pages/profile/EditProfilePage';
|
||||||
|
import { HealthRecordPage } from '@/pages/profile/HealthRecordPage';
|
||||||
import { SettingsPage } from '@/pages/profile/SettingsPage';
|
import { SettingsPage } from '@/pages/profile/SettingsPage';
|
||||||
import {
|
import {
|
||||||
NotificationSettingsPage,
|
NotificationSettingsPage,
|
||||||
@@ -81,7 +83,9 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'services/reports/:id', element: <ReportDetailPage /> },
|
{ path: 'services/reports/:id', element: <ReportDetailPage /> },
|
||||||
{ path: 'services/follow-ups', element: <FollowUpListPage /> },
|
{ path: 'services/follow-ups', element: <FollowUpListPage /> },
|
||||||
{ path: 'services/follow-ups/add', element: <FollowUpEditPage /> },
|
{ path: 'services/follow-ups/add', element: <FollowUpEditPage /> },
|
||||||
|
{ path: 'services/visits', element: <VisitListPage /> },
|
||||||
{ path: 'profile/edit', element: <EditProfilePage /> },
|
{ path: 'profile/edit', element: <EditProfilePage /> },
|
||||||
|
{ path: 'profile/health-record', element: <HealthRecordPage /> },
|
||||||
{ path: 'profile/settings', element: <SettingsPage /> },
|
{ path: 'profile/settings', element: <SettingsPage /> },
|
||||||
{ path: 'profile/settings/notifications', element: <NotificationSettingsPage /> },
|
{ path: 'profile/settings/notifications', element: <NotificationSettingsPage /> },
|
||||||
{ path: 'profile/settings/privacy', element: <PrivacyPage /> },
|
{ path: 'profile/settings/privacy', element: <PrivacyPage /> },
|
||||||
|
|||||||
@@ -72,13 +72,27 @@ export async function getProfile(): Promise<User> {
|
|||||||
weight: res.data.weightKg || 0,
|
weight: res.data.weightKg || 0,
|
||||||
medicalHistory: res.data.medicalHistory || [],
|
medicalHistory: res.data.medicalHistory || [],
|
||||||
stentImplantDate: res.data.stentDate || '',
|
stentImplantDate: res.data.stentDate || '',
|
||||||
|
stentType: res.data.stentType || '',
|
||||||
};
|
};
|
||||||
localStorage.setItem('hrt_auth', JSON.stringify(state));
|
localStorage.setItem('hrt_auth', JSON.stringify(state));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} 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> {
|
export async function updateProfile(data: Record<string, unknown>): Promise<void> {
|
||||||
|
|||||||
@@ -33,8 +33,9 @@ function mapFollowUp(f: RawFollowUp): FollowUp {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFollowUps(): Promise<FollowUp[]> {
|
export async function getFollowUps(type?: string): Promise<FollowUp[]> {
|
||||||
const res = await api.get<RawFollowUp[]>('/api/follow-ups');
|
const path = type ? `/api/follow-ups?type=${type}` : '/api/follow-ups';
|
||||||
|
const res = await api.get<RawFollowUp[]>(path);
|
||||||
return res.data.map(mapFollowUp);
|
return res.data.map(mapFollowUp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface User {
|
|||||||
weight: number;
|
weight: number;
|
||||||
medicalHistory: string[];
|
medicalHistory: string[];
|
||||||
stentImplantDate: string;
|
stentImplantDate: string;
|
||||||
|
stentType: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user