Compare commits

..

18 Commits

Author SHA1 Message Date
MingNian
f412a474cd 重构健康中心页面:合并指标入口、独立记录页面、多指标趋势图
- 健康中心合并血压/心率/血糖/血氧/体重为统一入口卡片
- 记录页面每个指标独立日期+保存,紧凑设计
- 趋势图支持多指标切换显示,同时显示收缩压和舒张压
- 首页健康概览修复返回页面后数据不更新的问题
- Vite 添加代理,支持手机通过局域网 IP 访问
- 医生端患者详情新增健康趋势图及指标切换
- 运动饮食页面支持删除记录
- 修复复查完成后患者端消失的问题
2026-05-26 15:56:06 +08:00
MingNian
d5f167167a feat: replace Redis with PostgreSQL for caching, rate limiting, SMS codes, and token blacklist
- Add 4 PG entities: VerificationCode, RateLimitEntry, TokenBlacklistEntry, CacheEntry
- Add 4 services: VerificationService, RateLimitService, TokenBlacklistService, CacheService
- Add CleanupBackgroundService for periodic expired data cleanup
- Add MigrationHelper for safe schema migration without data loss
- Update AuthController: real SMS code generation, rate limiting, logout endpoint with JWT blacklist
- Update JwtProvider: add JTI claim for token revocation
- Update Program.cs: register new services, JWT blacklist validation, DB migration
- Remove StackExchange.Redis NuGet package and all Redis config references
- Update start-dev.bat: 6→5 services, remove Redis startup
- Update docs: remove Redis references from all documentation
- Fix: logout button spacing on profile page
- Fix: .gitignore data/→/data/ to not ignore Infrastructure/Data/
2026-05-26 13:48:53 +08:00
MingNian
39ab6062b5 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
2026-05-25 14:48:05 +08:00
MingNian
db443b258e revert: remove .env loading, restore hardcoded config
- appsettings.json: restored hardcoded secrets
- Program.cs: removed .env file loader
- Frontend api-clients: restored hardcoded localhost:5000
- Removed .env, .env.example, vite-env.d.ts files
- Kept all audit fixes (endpoints, DTOs, field names, status labels)
2026-05-24 13:38:45 +08:00
MingNian
ede4a8d29e fix: audit issues - field mismatches, missing endpoints, data loss
- Report frontends: createdAt→uploadedAt field alignment with backend
- Dashboard: fix pending reports endpoint /api/reports/pending
- FollowUpListPage: status labels upcoming/cancelled
- MedicationController: add PUT/DELETE endpoints + service methods
- FollowUpController: add DELETE endpoint, Notes to CreateRequest
- Auth: UpdateProfileRequest includes doctor fields
- Auth: login restores soft-deleted users instead of crashing
2026-05-24 13:24:21 +08:00
MingNian
d6a432aec4 feat: extract secrets to .env, remove hardcoded credentials
- Backend: .env file for DB/JWT/Redis/MinIO config, appsettings.json cleared
- Backend: Program.cs loads .env at startup (no extra NuGet packages)
- Frontend: .env files for VITE_API_URL, api-clients use import.meta.env
- Added vite-env.d.ts type declarations for both frontends
- All hardcoded localhost:5000 replaced with env variable
- Added .env.example template for onboarding
2026-05-22 22:02:08 +08:00
MingNian
722ee76d93 refactor: patient frontend UI overhaul
- Reworked design system (variables, global styles, component CSS)
- Updated TabBar with icon-based navigation
- Redesigned HomePage, HealthHub, ServicesHub layouts
- Improved Exercise/Diet, Medication, Profile pages styling
- Simplified constants (removed emoji icons, streamlined data)
- Fixed launch.json cwd paths for frontend projects
2026-05-22 17:48:18 +08:00
MingNian
94da24572e fix: doctor can see patient-created follow-ups, camelCase JSON support
- FollowUpService: doctor query includes unassigned (DoctorId=null) follow-ups
- FollowUpController: doctor creates follow-up with correct patientId and sets DoctorId
- FollowUpCreateRequest/UpdateRequest: changed from positional record to class for System.Text.Json compat
- Program.cs: added PropertyNameCaseInsensitive for camelCase JSON deserialization
2026-05-22 15:38:08 +08:00
MingNian
9d384dc6fb feat: add SignalR real-time chat to doctor and patient frontends 2026-05-22 14:51:02 +08:00
MingNian
90615a6cb3 fix: prevent duplicate consultations with db unique constraint, frontend init once guard, chat history preserved 2026-05-22 11:20:48 +08:00
MingNian
8caa374699 fix: prevent duplicate consultations in backend, health calendar shows medication dots 2026-05-22 10:59:46 +08:00
MingNian
4a525124c5 fix: chat history persists, reuse active consultation, load messages properly 2026-05-22 10:48:14 +08:00
MingNian
a9d70aa130 fix: medication time slot picker, auto-expire, red dot logic, home greeting position 2026-05-21 16:43:43 +08:00
MingNian
4c85cd50be fix: patient report shows interpretation, medication daily tracking, followup bugs, home overview restored, doctor renamed 2026-05-21 16:32:20 +08:00
MingNian
0df75c35e9 refactor: single doctor, direct chat, any phone login, UI polish, fix animations 2026-05-21 16:05:21 +08:00
MingNian
bec65959a7 fix: notification click navigates to related page, report interpretation notifies patient 2026-05-21 15:54:16 +08:00
MingNian
204bc19ce5 fix: consultation notifications, profile edit hint, doctor report in Chinese, chat UI polish, tabbar badge 2026-05-21 15:40:17 +08:00
MingNian
3ef25e734f fix: bat uses dotnet directly, doctor frontend gets port 5174 2026-05-21 15:20:55 +08:00
130 changed files with 7389 additions and 1509 deletions

View File

@@ -2,10 +2,18 @@
"version": "0.0.1",
"configurations": [
{
"name": "健康管家 Web Demo",
"name": "健康管家-患者端",
"runtimeExecutable": "cmd.exe",
"runtimeArgs": ["/c", "D:\\nodejs\\npm.cmd", "run", "dev"],
"port": 5175
"cwd": "D:\\APP\\frontend-patient",
"port": 5173
},
{
"name": "健康管家-医生端",
"runtimeExecutable": "cmd.exe",
"runtimeArgs": ["/c", "D:\\nodejs\\npm.cmd", "run", "dev"],
"cwd": "D:\\APP\\frontend-doctor",
"port": 5174
}
]
}

2
.gitignore vendored
View File

@@ -7,7 +7,7 @@ bin/
obj/
# Data (large files)
data/
/data/
# Environment
.env

View File

@@ -14,6 +14,18 @@ public record UserProfileResponse(
List<string>? MedicalHistory, DateOnly? StentDate, string? StentType,
string? Department, string? Title, List<string>? Specialty, string? Introduction);
public record UpdateProfileRequest(
string? Name, string? Gender, DateOnly? Birthday,
decimal? HeightCm, decimal? WeightKg, List<string>? MedicalHistory);
public class UpdateProfileRequest
{
public string? Name { get; set; }
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; }
}

View File

@@ -0,0 +1,54 @@
using System.Text.Json;
using HealthManager.Domain.Entities;
using HealthManager.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace HealthManager.Application.Services;
public class CacheService(AppDbContext db)
{
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
public async Task<T?> GetAsync<T>(string key) where T : class
{
var entry = await db.CacheEntries
.FirstOrDefaultAsync(c => c.Key == key && c.ExpiresAt > DateTime.UtcNow);
if (entry == null) return default;
return JsonSerializer.Deserialize<T>(entry.Value.RootElement.GetRawText(), JsonOptions);
}
public async Task SetAsync<T>(string key, T value, TimeSpan ttl)
{
var json = JsonSerializer.SerializeToDocument(value);
var existing = await db.CacheEntries.FirstOrDefaultAsync(c => c.Key == key);
if (existing != null)
{
existing.Value = json;
existing.ExpiresAt = DateTime.UtcNow.Add(ttl);
}
else
{
db.CacheEntries.Add(new CacheEntry
{
Key = key,
Value = json,
ExpiresAt = DateTime.UtcNow.Add(ttl),
});
}
await db.SaveChangesAsync();
}
public async Task<T> GetOrSetAsync<T>(string key, Func<Task<T>> factory, TimeSpan ttl) where T : class
{
var cached = await GetAsync<T>(key);
if (cached != null) return cached;
var value = await factory();
await SetAsync(key, value, ttl);
return value;
}
public async Task RemoveAsync(string key)
{
await db.CacheEntries.Where(c => c.Key == key).ExecuteDeleteAsync();
}
}

View File

@@ -28,6 +28,12 @@ public class ConsultationService(AppDbContext db)
public async Task<Consultation> StartAsync(Guid patientId, Guid doctorId, string subject)
{
// Reuse existing active consultation between this patient and doctor
var existing = await db.Consultations
.FirstOrDefaultAsync(c => c.PatientId == patientId && c.DoctorId == doctorId && c.Status == "active");
if (existing != null)
return existing;
var consultation = new Consultation
{
PatientId = patientId,
@@ -35,7 +41,19 @@ public class ConsultationService(AppDbContext db)
Subject = subject,
};
db.Consultations.Add(consultation);
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateException)
{
// Race condition: another request created one between our check and save
// The unique index on (PatientId, DoctorId) where Status='active' caught it
db.ChangeTracker.Clear();
var retry = await db.Consultations
.FirstOrDefaultAsync(c => c.PatientId == patientId && c.DoctorId == doctorId && c.Status == "active");
return retry!;
}
return consultation;
}
@@ -59,6 +77,30 @@ public class ConsultationService(AppDbContext db)
ImageUrl = imageUrl,
};
db.ConsultationMessages.Add(message);
// Create notification for the recipient
var consultation = await db.Consultations
.Include(c => c.Patient)
.Include(c => c.Doctor)
.FirstOrDefaultAsync(c => c.Id == consultationId);
if (consultation != null)
{
var targetUserId = senderRole == "patient" ? consultation.DoctorId : consultation.PatientId;
var senderName = senderRole == "patient" ? consultation.Patient?.Name : consultation.Doctor?.Name;
var notifyTitle = senderRole == "patient" ? "新患者消息" : "医生已回复";
var notifyContent = $"{senderName ?? ""}{TruncateContent(content)}";
db.Notifications.Add(new Notification
{
UserId = targetUserId,
Type = "consultation",
Title = notifyTitle,
Content = notifyContent,
RelatedId = consultationId,
});
}
await db.SaveChangesAsync();
return message;
}
@@ -70,4 +112,7 @@ public class ConsultationService(AppDbContext db)
query = query.Where(u => u.Department == department);
return await query.ToListAsync();
}
private static string TruncateContent(string content) =>
content.Length > 50 ? content[..50] + "..." : content;
}

View File

@@ -14,13 +14,20 @@ public class FollowUpService(AppDbContext db)
.ToListAsync();
public async Task<List<FollowUp>> GetDoctorFollowUpsAsync(Guid doctorId)
=> await db.FollowUps
.Include(f => f.Patient)
.Where(f => f.DoctorId == doctorId || f.DoctorId == null)
.OrderBy(f => f.ScheduledAt)
.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)
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
{
@@ -30,6 +37,7 @@ public class FollowUpService(AppDbContext db)
Description = description,
ScheduledAt = DateTime.SpecifyKind(scheduledAt, DateTimeKind.Utc),
ReminderEnabled = reminderEnabled,
Notes = notes,
};
db.FollowUps.Add(followUp);
await db.SaveChangesAsync();
@@ -53,10 +61,18 @@ public class FollowUpService(AppDbContext db)
if (scheduledAt.HasValue) followUp.ScheduledAt = DateTime.SpecifyKind(scheduledAt.Value, DateTimeKind.Utc);
if (status != null) followUp.Status = status;
if (notes != null) followUp.Notes = notes;
followUp.DoctorId = doctorId;
followUp.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return followUp;
}
public async Task<bool> DeleteAsync(Guid id)
{
var followUp = await db.FollowUps.FindAsync(id);
if (followUp == null) return false;
db.FollowUps.Remove(followUp);
await db.SaveChangesAsync();
return true;
}
}

View File

@@ -43,6 +43,15 @@ public class HealthService(AppDbContext db)
return record;
}
public async Task<bool> DeleteAsync(Guid id, Guid userId)
{
var record = await db.HealthRecords.FirstOrDefaultAsync(hr => hr.Id == id && hr.UserId == userId);
if (record == null) return false;
db.HealthRecords.Remove(record);
await db.SaveChangesAsync();
return true;
}
public async Task<Dictionary<string, object>> GetStatsAsync(Guid userId)
{
var types = new[] { "blood_pressure", "heart_rate", "blood_sugar", "spo2", "weight", "steps" };

View File

@@ -90,4 +90,83 @@ public class MedicationService(AppDbContext db)
return totalCount > 0 ? Math.Round((decimal)takenCount / totalCount * 100, 1) : 0;
}
public async Task<Medication?> UpdateAsync(Guid medicationId, Guid userId, string? drugName,
string? dosage, string? frequency, List<string>? timeSlots,
DateOnly? startDate, DateOnly? endDate, string? notes, string? status)
{
var med = await db.Medications.FindAsync(medicationId);
if (med == null || med.UserId != userId) return null;
if (drugName != null) med.DrugName = drugName;
if (dosage != null) med.Dosage = dosage;
if (frequency != null) med.Frequency = frequency;
if (timeSlots != null) med.TimeSlots = timeSlots;
if (startDate.HasValue) med.StartDate = startDate.Value;
if (endDate.HasValue) med.EndDate = endDate;
if (notes != null) med.Notes = notes;
if (status != null) med.Status = status;
med.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return med;
}
public async Task<bool> DeleteAsync(Guid medicationId, Guid userId)
{
var med = await db.Medications.FindAsync(medicationId);
if (med == null || med.UserId != userId) return false;
db.Medications.Remove(med);
await db.SaveChangesAsync();
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();
}
}

View File

@@ -0,0 +1,50 @@
using HealthManager.Domain.Entities;
using HealthManager.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace HealthManager.Application.Services;
public class RateLimitService(AppDbContext db)
{
public async Task<bool> CheckAsync(string key, int limit, int windowSeconds)
{
var windowStart = DateTime.UtcNow.AddSeconds(-windowSeconds);
var count = await db.RateLimitEntries
.Where(r => r.Key == key && r.WindowStart >= windowStart)
.SumAsync(r => r.Count);
return count < limit;
}
public async Task IncrementAsync(string key, int windowSeconds)
{
var now = DateTime.UtcNow;
var windowStart = new DateTime(
now.Year, now.Month, now.Day, now.Hour, now.Minute,
now.Second - now.Second % windowSeconds, DateTimeKind.Utc);
var entry = await db.RateLimitEntries
.FirstOrDefaultAsync(r => r.Key == key && r.WindowStart == windowStart);
if (entry != null)
{
entry.Count++;
entry.ExpiresAt = now.AddSeconds(windowSeconds * 2);
}
else
{
db.RateLimitEntries.Add(new RateLimitEntry
{
Key = key,
Count = 1,
WindowStart = windowStart,
ExpiresAt = now.AddSeconds(windowSeconds * 2),
});
}
await db.SaveChangesAsync();
}
public async Task ResetAsync(string key)
{
await db.RateLimitEntries.Where(r => r.Key == key).ExecuteDeleteAsync();
}
}

View File

@@ -1,6 +1,7 @@
using HealthManager.Domain.Entities;
using HealthManager.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Notification = HealthManager.Domain.Entities.Notification;
namespace HealthManager.Application.Services;
@@ -78,6 +79,16 @@ public class ReportService(AppDbContext db)
});
}
// Notify patient
db.Notifications.Add(new Notification
{
UserId = report.PatientId,
Type = "report",
Title = "报告已解读",
Content = $"您的报告「{report.Title}」已有解读结果",
RelatedId = reportId,
});
await db.SaveChangesAsync();
return report;
}

View File

@@ -0,0 +1,33 @@
using HealthManager.Domain.Entities;
using HealthManager.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace HealthManager.Application.Services;
public class TokenBlacklistService(AppDbContext db)
{
public async Task AddAsync(string jti, Guid userId, DateTime expiresAt)
{
db.TokenBlacklistEntries.Add(new TokenBlacklistEntry
{
Jti = jti,
UserId = userId,
ExpiresAt = expiresAt,
});
await db.SaveChangesAsync();
}
public async Task<bool> IsBlacklistedAsync(string jti)
{
if (string.IsNullOrEmpty(jti)) return false;
return await db.TokenBlacklistEntries
.AnyAsync(t => t.Jti == jti && t.ExpiresAt > DateTime.UtcNow);
}
public async Task CleanupExpiredAsync()
{
await db.TokenBlacklistEntries
.Where(t => t.ExpiresAt < DateTime.UtcNow)
.ExecuteDeleteAsync();
}
}

View File

@@ -0,0 +1,43 @@
using HealthManager.Domain.Entities;
using HealthManager.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace HealthManager.Application.Services;
public class VerificationService(AppDbContext db)
{
public async Task<string> GenerateAsync(string phone, string type)
{
var code = Random.Shared.Next(100000, 999999).ToString();
db.VerificationCodes.Add(new VerificationCode
{
Phone = phone,
Code = code,
Type = type,
ExpiresAt = DateTime.UtcNow.AddMinutes(5),
});
await db.SaveChangesAsync();
return code;
}
public async Task<bool> VerifyAsync(string phone, string code, string type)
{
var entry = await db.VerificationCodes
.Where(v => v.Phone == phone && v.Type == type && v.Code == code
&& v.ExpiresAt > DateTime.UtcNow && !v.IsUsed)
.OrderByDescending(v => v.CreatedAt)
.FirstOrDefaultAsync();
if (entry == null) return false;
entry.IsUsed = true;
await db.SaveChangesAsync();
return true;
}
public async Task CleanupExpiredAsync()
{
await db.VerificationCodes
.Where(v => v.ExpiresAt < DateTime.UtcNow)
.ExecuteDeleteAsync();
}
}

View File

@@ -0,0 +1,12 @@
using System.Text.Json;
namespace HealthManager.Domain.Entities;
public class CacheEntry
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Key { get; set; } = string.Empty;
public JsonDocument Value { get; set; } = null!;
public DateTime ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,10 @@
namespace HealthManager.Domain.Entities;
public class RateLimitEntry
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Key { get; set; } = string.Empty;
public int Count { get; set; }
public DateTime WindowStart { get; set; }
public DateTime ExpiresAt { get; set; }
}

View File

@@ -0,0 +1,10 @@
namespace HealthManager.Domain.Entities;
public class TokenBlacklistEntry
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Jti { get; set; } = string.Empty;
public Guid UserId { get; set; }
public DateTime ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,12 @@
namespace HealthManager.Domain.Entities;
public class VerificationCode
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Phone { get; set; } = string.Empty;
public string Code { get; set; } = string.Empty;
public string Type { get; set; } = "login";
public DateTime ExpiresAt { get; set; }
public bool IsUsed { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,193 @@
using HealthManager.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using System.Text.Json;
namespace HealthManager.Infrastructure.Data;
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<User> Users => Set<User>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<HealthRecord> HealthRecords => Set<HealthRecord>();
public DbSet<Device> Devices => Set<Device>();
public DbSet<Medication> Medications => Set<Medication>();
public DbSet<MedicationRecord> MedicationRecords => Set<MedicationRecord>();
public DbSet<Consultation> Consultations => Set<Consultation>();
public DbSet<ConsultationMessage> ConsultationMessages => Set<ConsultationMessage>();
public DbSet<QuickReplyTemplate> QuickReplyTemplates => Set<QuickReplyTemplate>();
public DbSet<Report> Reports => Set<Report>();
public DbSet<ReportItem> ReportItems => Set<ReportItem>();
public DbSet<FollowUp> FollowUps => Set<FollowUp>();
public DbSet<ExerciseRecord> ExerciseRecords => Set<ExerciseRecord>();
public DbSet<DietRecord> DietRecords => Set<DietRecord>();
public DbSet<Notification> Notifications => Set<Notification>();
public DbSet<VerificationCode> VerificationCodes => Set<VerificationCode>();
public DbSet<RateLimitEntry> RateLimitEntries => Set<RateLimitEntry>();
public DbSet<TokenBlacklistEntry> TokenBlacklistEntries => Set<TokenBlacklistEntry>();
public DbSet<CacheEntry> CacheEntries => Set<CacheEntry>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// User
modelBuilder.Entity<User>(e =>
{
e.HasIndex(u => u.Role);
e.HasIndex(u => u.Phone).IsUnique();
e.Property(u => u.MedicalHistory).HasColumnType("text[]");
e.Property(u => u.Specialty).HasColumnType("text[]");
});
// RefreshToken
modelBuilder.Entity<RefreshToken>(e =>
{
e.HasIndex(rt => rt.Token).IsUnique();
e.HasOne(rt => rt.User).WithMany(u => u.RefreshTokens).HasForeignKey(rt => rt.UserId);
});
// HealthRecord
modelBuilder.Entity<HealthRecord>(e =>
{
e.HasIndex(hr => new { hr.UserId, hr.Type });
e.HasIndex(hr => hr.RecordedAt);
e.Property(hr => hr.Value)
.HasColumnType("jsonb")
.HasConversion(
v => v.RootElement.GetRawText(),
v => JsonDocument.Parse(v, default));
e.HasOne(hr => hr.User).WithMany(u => u.HealthRecords).HasForeignKey(hr => hr.UserId);
});
// Device
modelBuilder.Entity<Device>(e =>
{
e.HasIndex(d => d.UserId);
});
// Medication
modelBuilder.Entity<Medication>(e =>
{
e.HasIndex(m => m.UserId);
e.HasIndex(m => m.Status);
e.Property(m => m.TimeSlots).HasColumnType("text[]");
e.HasOne(m => m.User).WithMany(u => u.Medications).HasForeignKey(m => m.UserId);
e.HasOne(m => m.Doctor).WithMany().HasForeignKey(m => m.DoctorId);
});
// MedicationRecord
modelBuilder.Entity<MedicationRecord>(e =>
{
e.HasIndex(mr => new { mr.UserId, mr.CreatedAt });
e.HasOne(mr => mr.Medication).WithMany(m => m.Records).HasForeignKey(mr => mr.MedicationId);
e.HasOne(mr => mr.User).WithMany(u => u.MedicationRecords).HasForeignKey(mr => mr.UserId);
});
// Consultation
modelBuilder.Entity<Consultation>(e =>
{
e.HasIndex(c => c.PatientId);
e.HasIndex(c => c.DoctorId);
e.HasIndex(c => c.Status);
// Prevent duplicate active consultations between same patient+doctor
e.HasIndex(c => new { c.PatientId, c.DoctorId }).IsUnique()
.HasFilter("\"Status\" = 'active'");
e.HasOne(c => c.Patient).WithMany().HasForeignKey(c => c.PatientId);
e.HasOne(c => c.Doctor).WithMany().HasForeignKey(c => c.DoctorId);
});
// ConsultationMessage
modelBuilder.Entity<ConsultationMessage>(e =>
{
e.HasIndex(cm => cm.ConsultationId);
e.HasOne(cm => cm.Consultation).WithMany(c => c.Messages).HasForeignKey(cm => cm.ConsultationId);
e.HasOne(cm => cm.Sender).WithMany().HasForeignKey(cm => cm.SenderId);
});
// QuickReplyTemplate
modelBuilder.Entity<QuickReplyTemplate>(e =>
{
e.HasIndex(t => t.DoctorId);
e.HasOne(t => t.Doctor).WithMany().HasForeignKey(t => t.DoctorId);
});
// Report
modelBuilder.Entity<Report>(e =>
{
e.HasIndex(r => r.PatientId);
e.HasIndex(r => r.Status);
e.Property(r => r.ImageUrls).HasColumnType("text[]");
e.HasOne(r => r.Patient).WithMany().HasForeignKey(r => r.PatientId);
e.HasOne(r => r.Doctor).WithMany().HasForeignKey(r => r.DoctorId);
});
// ReportItem
modelBuilder.Entity<ReportItem>(e =>
{
e.HasOne(ri => ri.Report).WithMany(r => r.Items).HasForeignKey(ri => ri.ReportId);
});
// FollowUp
modelBuilder.Entity<FollowUp>(e =>
{
e.HasIndex(f => f.PatientId);
e.HasIndex(f => f.DoctorId);
e.HasIndex(f => f.ScheduledAt);
e.HasOne(f => f.Patient).WithMany().HasForeignKey(f => f.PatientId);
e.HasOne(f => f.Doctor).WithMany().HasForeignKey(f => f.DoctorId);
});
// ExerciseRecord
modelBuilder.Entity<ExerciseRecord>(e =>
{
e.HasIndex(er => new { er.UserId, er.RecordedAt });
e.HasOne(er => er.User).WithMany(u => u.ExerciseRecords).HasForeignKey(er => er.UserId);
});
// DietRecord
modelBuilder.Entity<DietRecord>(e =>
{
e.HasIndex(dr => new { dr.UserId, dr.RecordedAt });
e.HasOne(dr => dr.User).WithMany(u => u.DietRecords).HasForeignKey(dr => dr.UserId);
});
// Notification
modelBuilder.Entity<Notification>(e =>
{
e.HasIndex(n => new { n.UserId, n.IsRead });
e.HasOne(n => n.User).WithMany(u => u.Notifications).HasForeignKey(n => n.UserId);
});
// VerificationCode
modelBuilder.Entity<VerificationCode>(e =>
{
e.HasIndex(vc => vc.ExpiresAt);
e.HasIndex(vc => new { vc.Phone, vc.Type });
});
// RateLimitEntry
modelBuilder.Entity<RateLimitEntry>(e =>
{
e.HasIndex(rl => rl.ExpiresAt);
e.HasIndex(rl => new { rl.Key, rl.WindowStart }).IsUnique();
});
// TokenBlacklistEntry
modelBuilder.Entity<TokenBlacklistEntry>(e =>
{
e.HasIndex(tb => tb.Jti).IsUnique();
e.HasIndex(tb => tb.ExpiresAt);
});
// CacheEntry
modelBuilder.Entity<CacheEntry>(e =>
{
e.HasIndex(ce => ce.Key).IsUnique();
e.HasIndex(ce => ce.ExpiresAt);
e.Property(ce => ce.Value)
.HasColumnType("jsonb")
.HasConversion(
v => v.RootElement.GetRawText(),
v => JsonDocument.Parse(v, default));
});
}
}

View File

@@ -0,0 +1,41 @@
using System.Security.Cryptography;
using System.Text;
using HealthManager.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace HealthManager.Infrastructure.Data;
public static class DataSeeder
{
public static async Task SeedAsync(AppDbContext db)
{
if (await db.Users.AnyAsync()) return;
var demoPassword = HashPassword("demo123");
var doctor = new User
{
Id = Guid.NewGuid(),
Role = "doctor",
Phone = "13700137000",
PasswordHash = demoPassword,
Name = "王建国",
Gender = "男",
Department = "心血管内科",
Title = "主任医师",
Specialty = ["冠心病", "高血压", "介入治疗"],
Introduction = "从事心血管内科临床工作30年擅长冠心病介入治疗及术后管理。",
IsAvailable = true,
CreatedAt = DateTime.UtcNow,
};
db.Users.Add(doctor);
await db.SaveChangesAsync();
}
private static string HashPassword(string password)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(password));
return Convert.ToHexStringLower(bytes);
}
}

View File

@@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore;
namespace HealthManager.Infrastructure.Data;
public static class MigrationHelper
{
public static async Task EnsureNewTablesAsync(AppDbContext db)
{
var sql = """
CREATE TABLE IF NOT EXISTS "VerificationCodes" (
"Id" uuid PRIMARY KEY,
"Phone" text NOT NULL,
"Code" text NOT NULL,
"Type" text NOT NULL DEFAULT 'login',
"ExpiresAt" timestamptz NOT NULL,
"IsUsed" boolean NOT NULL DEFAULT FALSE,
"CreatedAt" timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "IX_VerificationCodes_ExpiresAt" ON "VerificationCodes" ("ExpiresAt");
CREATE INDEX IF NOT EXISTS "IX_VerificationCodes_Phone_Type" ON "VerificationCodes" ("Phone", "Type");
CREATE TABLE IF NOT EXISTS "RateLimitEntries" (
"Id" uuid PRIMARY KEY,
"Key" text NOT NULL,
"Count" integer NOT NULL,
"WindowStart" timestamptz NOT NULL,
"ExpiresAt" timestamptz NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "IX_RateLimitEntries_Key_WindowStart" ON "RateLimitEntries" ("Key", "WindowStart");
CREATE INDEX IF NOT EXISTS "IX_RateLimitEntries_ExpiresAt" ON "RateLimitEntries" ("ExpiresAt");
CREATE TABLE IF NOT EXISTS "TokenBlacklistEntries" (
"Id" uuid PRIMARY KEY,
"Jti" text NOT NULL,
"UserId" uuid NOT NULL,
"ExpiresAt" timestamptz NOT NULL,
"CreatedAt" timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS "IX_TokenBlacklistEntries_Jti" ON "TokenBlacklistEntries" ("Jti");
CREATE INDEX IF NOT EXISTS "IX_TokenBlacklistEntries_ExpiresAt" ON "TokenBlacklistEntries" ("ExpiresAt");
CREATE TABLE IF NOT EXISTS "CacheEntries" (
"Id" uuid PRIMARY KEY,
"Key" text NOT NULL,
"Value" jsonb NOT NULL,
"ExpiresAt" timestamptz NOT NULL,
"CreatedAt" timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS "IX_CacheEntries_Key" ON "CacheEntries" ("Key");
CREATE INDEX IF NOT EXISTS "IX_CacheEntries_ExpiresAt" ON "CacheEntries" ("ExpiresAt");
""";
await db.Database.ExecuteSqlRawAsync(sql);
}
}

View File

@@ -8,7 +8,6 @@
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.18.0" />
<PackageReference Include="Minio" Version="7.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<PackageReference Include="StackExchange.Redis" Version="2.13.1" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
</ItemGroup>

View File

@@ -19,6 +19,7 @@ public class JwtProvider(IConfiguration configuration) : IJwtProvider
new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
new Claim(ClaimTypes.Name, name),
new Claim(ClaimTypes.Role, role),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
var token = new JwtSecurityToken(

View File

@@ -1,3 +1,4 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using HealthManager.Application.DTOs.Auth;
using HealthManager.Domain.Interfaces;
@@ -5,6 +6,7 @@ using HealthManager.Application.Services;
using HealthManager.Domain.Entities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace HealthManager.WebApi.Controllers;
@@ -12,23 +14,57 @@ namespace HealthManager.WebApi.Controllers;
[Route("api/auth")]
public class AuthController(
AuthService authService,
IJwtProvider jwtProvider) : ControllerBase
IJwtProvider jwtProvider,
VerificationService verificationService,
RateLimitService rateLimit,
TokenBlacklistService tokenBlacklist) : ControllerBase
{
[HttpPost("send-sms")]
public IActionResult SendSms([FromBody] SendSmsRequest request)
public async Task<IActionResult> SendSms([FromBody] SendSmsRequest request)
{
// Demo: always succeed
if (!await rateLimit.CheckAsync($"sms:{request.Phone}", 1, 60))
return StatusCode(429, new { message = "发送过于频繁请60秒后重试" });
var code = await verificationService.GenerateAsync(request.Phone, "login");
await rateLimit.IncrementAsync($"sms:{request.Phone}", 60);
// Demo: log code to console since no real SMS gateway
Console.WriteLine($"[SMS] Phone: {request.Phone}, Code: {code}");
return Ok(new { message = "验证码已发送" });
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
// Demo: skip SMS verification, accept any code
var user = await authService.GetUserByPhoneAsync(request.Phone);
if (user == null)
return Unauthorized(new { message = "用户不存在" });
{
var db = HttpContext.RequestServices.GetRequiredService<Infrastructure.Data.AppDbContext>();
var deleted = await db.Users.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.Phone == request.Phone && u.IsDeleted);
if (deleted != null)
{
deleted.IsDeleted = false;
deleted.DeletedAt = null;
deleted.UpdatedAt = DateTime.UtcNow;
user = deleted;
}
else
{
user = new User
{
Phone = request.Phone,
Name = "用户" + request.Phone[^4..],
Role = "patient",
PasswordHash = AuthService.HashPassword("demo123"),
};
db.Users.Add(user);
}
await db.SaveChangesAsync();
}
// Demo: accept any SMS code
var accessToken = jwtProvider.GenerateAccessToken(user.Id, user.Name, user.Role);
var refreshToken = jwtProvider.GenerateRefreshToken();
await authService.SaveRefreshTokenAsync(user.Id, refreshToken, DateTime.UtcNow.AddDays(7));
@@ -51,7 +87,6 @@ public class AuthController(
PasswordHash = AuthService.HashPassword("demo123"),
};
// Access DbContext via DI
var db = HttpContext.RequestServices.GetRequiredService<Infrastructure.Data.AppDbContext>();
db.Users.Add(user);
await db.SaveChangesAsync();
@@ -63,6 +98,24 @@ public class AuthController(
return Ok(new AuthResponse(user.Id, user.Name, user.Role, accessToken, refreshToken));
}
[HttpPost("logout")]
[Authorize]
public async Task<IActionResult> Logout()
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var jti = User.FindFirstValue(JwtRegisteredClaimNames.Jti);
var expClaim = User.FindFirstValue(JwtRegisteredClaimNames.Exp);
if (!string.IsNullOrEmpty(jti) && !string.IsNullOrEmpty(expClaim))
{
var exp = DateTimeOffset.FromUnixTimeSeconds(long.Parse(expClaim)).UtcDateTime;
await tokenBlacklist.AddAsync(jti, userId, exp);
}
await authService.RevokeRefreshTokenAsync(userId);
return Ok(new { message = "已登出" });
}
[HttpPost("refresh")]
public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequest request)
{
@@ -110,6 +163,12 @@ public class AuthController(
if (request.HeightCm.HasValue) user.HeightCm = request.HeightCm;
if (request.WeightKg.HasValue) user.WeightKg = request.WeightKg;
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.Title != null) user.Title = request.Title;
if (request.Introduction != null) user.Introduction = request.Introduction;
if (request.Specialty != null) user.Specialty = request.Specialty;
user.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();

View File

@@ -0,0 +1,104 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace HealthManager.WebApi.Controllers;
[ApiController]
[Route("api/files")]
[Authorize]
public class FileController : ControllerBase
{
private static readonly string UploadDir = Path.Combine(
Directory.GetCurrentDirectory(), "..", "..", "..", "data", "uploads");
public FileController()
{
if (!Directory.Exists(UploadDir))
Directory.CreateDirectory(UploadDir);
}
[HttpPost("upload")]
public async Task<IActionResult> Upload(IFormFile file)
{
if (file == null || file.Length == 0)
return BadRequest(new { message = "No file selected" });
if (file.Length > 10 * 1024 * 1024)
return BadRequest(new { message = "File too large (max 10MB)" });
var allowedTypes = new[] { "image/jpeg", "image/png", "image/gif", "image/webp", "application/pdf" };
if (!allowedTypes.Contains(file.ContentType.ToLower()))
return BadRequest(new { message = "Unsupported file type" });
var fileName = $"{Guid.NewGuid()}{Path.GetExtension(file.FileName)}";
var filePath = Path.Combine(UploadDir, fileName);
using (var stream = new FileStream(filePath, FileMode.Create))
{
await file.CopyToAsync(stream);
}
var url = $"/api/files/{fileName}";
return Ok(new { url, fileName });
}
[HttpPost("upload-multiple")]
public async Task<IActionResult> UploadMultiple(List<IFormFile> files)
{
if (files == null || files.Count == 0)
return BadRequest(new { message = "No files selected" });
var results = new List<object>();
foreach (var file in files)
{
if (file.Length == 0) continue;
if (file.Length > 10 * 1024 * 1024) continue;
var allowedTypes = new[] { "image/jpeg", "image/png", "image/gif", "image/webp", "application/pdf" };
if (!allowedTypes.Contains(file.ContentType.ToLower())) continue;
var fileName = $"{Guid.NewGuid()}{Path.GetExtension(file.FileName)}";
var filePath = Path.Combine(UploadDir, fileName);
using (var stream = new FileStream(filePath, FileMode.Create))
{
await file.CopyToAsync(stream);
}
results.Add(new { url = $"/api/files/{fileName}", fileName });
}
return Ok(results);
}
[HttpGet("{fileName}")]
[AllowAnonymous]
public IActionResult Download(string fileName)
{
var filePath = Path.Combine(UploadDir, fileName);
// Security: prevent path traversal
if (!filePath.StartsWith(UploadDir))
return NotFound();
if (!System.IO.File.Exists(filePath))
return NotFound();
var contentType = GetContentType(fileName);
return PhysicalFile(filePath, contentType);
}
private static string GetContentType(string fileName)
{
var ext = Path.GetExtension(fileName).ToLower();
return ext switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
".pdf" => "application/pdf",
_ => "application/octet-stream",
};
}
}

View File

@@ -14,11 +14,22 @@ public class FollowUpController(FollowUpService followUpService) : ControllerBas
private string Role => User.FindFirstValue(ClaimTypes.Role)!;
[HttpGet]
public async Task<IActionResult> GetFollowUps()
public async Task<IActionResult> GetFollowUps([FromQuery] string? type)
{
var followUps = Role == "doctor"
? await followUpService.GetDoctorFollowUpsAsync(UserId)
: await followUpService.GetPatientFollowUpsAsync(UserId);
List<HealthManager.Domain.Entities.FollowUp> followUps;
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
{
@@ -45,11 +56,29 @@ public class FollowUpController(FollowUpService followUpService) : ControllerBas
[HttpPost]
public async Task<IActionResult> AddFollowUp([FromBody] FollowUpCreateRequest request)
{
var followUp = await followUpService.AddAsync(UserId, request.Title, request.Description,
request.ScheduledAt, request.ReminderEnabled);
var patientId = UserId;
Guid? doctorId = null;
if (Role == "doctor" && request.PatientId.HasValue)
{
patientId = request.PatientId.Value;
doctorId = UserId;
}
var followUp = await followUpService.AddAsync(patientId, request.Title, request.Description,
request.ScheduledAt, request.ReminderEnabled, doctorId, request.Notes);
return Ok(new { followUp.Id, followUp.Title, followUp.Status });
}
[HttpDelete("{id:guid}")]
[Authorize(Roles = "doctor")]
public async Task<IActionResult> DeleteFollowUp(Guid id)
{
var ok = await followUpService.DeleteAsync(id);
if (!ok) return NotFound(new { message = "复查不存在" });
return Ok(new { message = "删除成功" });
}
[HttpPut("{id:guid}")]
[Authorize(Roles = "doctor")]
public async Task<IActionResult> UpdateFollowUp(Guid id, [FromBody] FollowUpUpdateRequest request)
@@ -61,7 +90,21 @@ public class FollowUpController(FollowUpService followUpService) : ControllerBas
}
}
public record FollowUpCreateRequest(string Title, string? Description, DateTime ScheduledAt, bool ReminderEnabled = true);
public class FollowUpCreateRequest
{
public string Title { get; set; } = string.Empty;
public string? Description { get; set; }
public DateTime ScheduledAt { get; set; }
public bool ReminderEnabled { get; set; } = true;
public Guid? PatientId { get; set; }
public string? Notes { get; set; }
}
public record FollowUpUpdateRequest(
string? Title, string? Description, DateTime? ScheduledAt, string? Status, string? Notes);
public class FollowUpUpdateRequest
{
public string? Title { get; set; }
public string? Description { get; set; }
public DateTime? ScheduledAt { get; set; }
public string? Status { get; set; }
public string? Notes { get; set; }
}

View File

@@ -56,6 +56,14 @@ public class HealthController(HealthService healthService) : ControllerBase
var record = await healthService.AddRecordAsync(UserId, request.Type, request.ValueJson, request.Unit, request.RecordedAt, request.Notes);
return Ok(new { record.Id, record.Type, Value = record.Value.RootElement.GetRawText(), record.Unit, record.RecordedAt, record.Source });
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> DeleteRecord(Guid id)
{
var ok = await healthService.DeleteAsync(id, UserId);
if (!ok) return NotFound(new { message = "记录不存在" });
return Ok(new { message = "删除成功" });
}
}
public record HealthRecordCreateRequest(string Type, string ValueJson, string Unit, DateTime RecordedAt, string? Notes);

View File

@@ -13,6 +13,13 @@ public class MedicationController(MedicationService medicationService) : Control
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
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]
public async Task<IActionResult> GetMedications()
{
@@ -71,10 +78,32 @@ public class MedicationController(MedicationService medicationService) : Control
var rate = await medicationService.GetAdherenceRateAsync(id);
return Ok(new { medicationId = id, rate });
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> UpdateMedication(Guid id, [FromBody] MedicationUpdateRequest request)
{
var med = await medicationService.UpdateAsync(id, UserId, request.DrugName, request.Dosage,
request.Frequency, request.TimeSlots, request.StartDate, request.EndDate, request.Notes, request.Status);
if (med == null) return NotFound(new { message = "药品不存在" });
return Ok(new { med.Id, med.DrugName, med.Dosage, med.Status });
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> DeleteMedication(Guid id)
{
var ok = await medicationService.DeleteAsync(id, UserId);
if (!ok) return NotFound(new { message = "药品不存在" });
return Ok(new { message = "删除成功" });
}
}
public record MedicationCreateRequest(
string DrugName, string Dosage, string Frequency,
List<string> TimeSlots, DateOnly StartDate, DateOnly? EndDate, string? Notes);
public record MedicationUpdateRequest(
string? DrugName, string? Dosage, string? Frequency,
List<string>? TimeSlots, DateOnly? StartDate, DateOnly? EndDate,
string? Notes, string? Status);
public record MarkTakenRequest(string TimeSlot);

View File

@@ -10,6 +10,7 @@
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
<PackageReference Include="Microsoft.OpenApi" Version="2.4.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup>

View File

@@ -1,9 +1,11 @@
using System.IdentityModel.Tokens.Jwt;
using System.Text;
using HealthManager.Domain.Interfaces;
using HealthManager.Application.Services;
using HealthManager.Infrastructure.Data;
using HealthManager.Infrastructure.Services;
using HealthManager.WebApi.Hubs;
using HealthManager.WebApi.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
@@ -39,6 +41,14 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
context.Token = accessToken;
return Task.CompletedTask;
},
OnTokenValidated = async context =>
{
var blacklist = context.HttpContext.RequestServices
.GetRequiredService<TokenBlacklistService>();
var jti = context.Principal!.FindFirst(JwtRegisteredClaimNames.Jti)?.Value;
if (!string.IsNullOrEmpty(jti) && await blacklist.IsBlacklistedAsync(jti))
context.Fail("token已注销");
}
};
});
@@ -55,6 +65,13 @@ builder.Services.AddScoped<FollowUpService>();
builder.Services.AddScoped<PatientService>();
builder.Services.AddScoped<NotificationService>();
// PG-based replacements for Redis
builder.Services.AddScoped<VerificationService>();
builder.Services.AddScoped<RateLimitService>();
builder.Services.AddScoped<TokenBlacklistService>();
builder.Services.AddScoped<CacheService>();
builder.Services.AddHostedService<CleanupBackgroundService>();
// SignalR
builder.Services.AddSignalR();
@@ -74,7 +91,11 @@ builder.Services.AddCors(options =>
});
});
builder.Services.AddControllers();
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
});
var app = builder.Build();
@@ -95,6 +116,7 @@ using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.EnsureCreatedAsync();
await MigrationHelper.EnsureNewTablesAsync(db);
await DataSeeder.SeedAsync(db);
}

View File

@@ -0,0 +1,31 @@
using HealthManager.Application.Services;
using Microsoft.EntityFrameworkCore;
namespace HealthManager.WebApi.Services;
public class CleanupBackgroundService(
IServiceScopeFactory scopeFactory) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = scopeFactory.CreateScope();
var verification = scope.ServiceProvider.GetRequiredService<VerificationService>();
var tokenBlacklist = scope.ServiceProvider.GetRequiredService<TokenBlacklistService>();
await verification.CleanupExpiredAsync();
await tokenBlacklist.CleanupExpiredAsync();
var db = scope.ServiceProvider.GetRequiredService<Infrastructure.Data.AppDbContext>();
await db.RateLimitEntries.Where(r => r.ExpiresAt < DateTime.UtcNow).ExecuteDeleteAsync(stoppingToken);
await db.CacheEntries.Where(c => c.ExpiresAt < DateTime.UtcNow).ExecuteDeleteAsync(stoppingToken);
}
catch { /* skip cleanup errors */ }
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
}
}

View File

@@ -14,9 +14,6 @@
"Issuer": "HealthManager",
"Audience": "HealthManagerApp"
},
"Redis": {
"Connection": "localhost:6379"
},
"MinIO": {
"Endpoint": "localhost:9000",
"AccessKey": "minioadmin",

View File

@@ -43,7 +43,6 @@
| **SignalR** | 微软的实时通信框架 | 实现医生和患者之间的实时聊天 |
| **Swagger** | API 文档工具 | 自动生成 API 文档页面,可以直接在浏览器里测试接口 |
| **MinIO** | S3 兼容的对象存储 | 存储图片(报告照片、头像等) |
| **Redis** | 内存缓存数据库 | 缓存常用数据,加速访问 |
### 1.3 项目文件结构
@@ -385,7 +384,7 @@ DTO = Data Transfer Object。用于前后端之间传输数据而不是直接
| 文件 | 内容 |
|------|------|
| `appsettings.json` | PostgreSQL 连接串JWT 密钥/签发者Redis 连接MinIO 连接 |
| `appsettings.json` | PostgreSQL 连接串JWT 密钥/签发者MinIO 连接 |
| `appsettings.Development.json` | 开发环境覆盖配置 |
| `Properties/launchSettings.json` | 启动配置端口 5000Development 环境自动开 Swagger |

View File

@@ -8,6 +8,7 @@
"name": "frontend-doctor",
"version": "0.0.0",
"dependencies": {
"@microsoft/signalr": "^10.0.0",
"dayjs": "^1.11.20",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
@@ -574,6 +575,19 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@microsoft/signalr": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.0.tgz",
"integrity": "sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"eventsource": "^2.0.2",
"fetch-cookie": "^2.0.3",
"node-fetch": "^2.6.7",
"ws": "^7.5.10"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
@@ -1224,6 +1238,18 @@
}
}
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -1674,6 +1700,24 @@
"node": ">=0.10.0"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/eventsource": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz",
"integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -1712,6 +1756,16 @@
}
}
},
"node_modules/fetch-cookie": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz",
"integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==",
"license": "Unlicense",
"dependencies": {
"set-cookie-parser": "^2.4.8",
"tough-cookie": "^4.0.0"
}
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -2355,6 +2409,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-releases": {
"version": "2.0.44",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz",
@@ -2491,16 +2565,33 @@
"node": ">= 0.8.0"
}
},
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"funding": {
"url": "https://github.com/sponsors/lupomontero"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"license": "MIT"
},
"node_modules/react": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
@@ -2560,6 +2651,12 @@
"react-dom": ">=18"
}
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"license": "MIT"
},
"node_modules/rolldown": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
@@ -2672,6 +2769,27 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tough-cookie": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
"license": "BSD-3-Clause",
"dependencies": {
"psl": "^1.1.33",
"punycode": "^2.1.1",
"universalify": "^0.2.0",
"url-parse": "^1.5.3"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -2749,6 +2867,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -2790,6 +2917,16 @@
"punycode": "^2.1.0"
}
},
"node_modules/url-parse": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"license": "MIT",
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
}
},
"node_modules/vite": {
"version": "8.0.13",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
@@ -2868,6 +3005,22 @@
}
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -2894,6 +3047,27 @@
"node": ">=0.10.0"
}
},
"node_modules/ws": {
"version": "7.5.10",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
"license": "MIT",
"engines": {
"node": ">=8.3.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@microsoft/signalr": "^10.0.0",
"dayjs": "^1.11.20",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",

View File

@@ -1,4 +1,20 @@
@import './variables.css';
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #333; }
a { color: inherit; }
button { cursor: pointer; }
body {
font-family: var(--font-family);
color: var(--color-text-primary);
background: var(--color-bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a { color: inherit; text-decoration: none; }
button { cursor: pointer; font-family: inherit; }
input, select, textarea { font-family: inherit; }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}

View File

@@ -0,0 +1,51 @@
:root {
--color-primary: #4F6EF7;
--color-primary-hover: #3D56D6;
--color-primary-bg: #EDF0FD;
--color-primary-gradient: linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%);
--color-danger: #EF4444;
--color-danger-bg: #FEE9E9;
--color-success: #20C997;
--color-success-bg: #E6F9F2;
--color-warning: #F59E0B;
--color-warning-bg: #FFF8E6;
--color-info: #339AF0;
--color-info-bg: #EFF6FF;
--color-purple: #845EF7;
--color-purple-bg: #F3E8FF;
--color-pink: #F06595;
--color-pink-bg: #FFF0F5;
--color-white: #FFFFFF;
--color-bg: #F5F7FB;
--color-bg-secondary: #EDF0F7;
--color-text-primary: #1A1D28;
--color-text-secondary: #5A6072;
--color-text-tertiary: #9BA0B4;
--color-text-inverse: #FFFFFF;
--color-border: #E1E5ED;
--color-divider: #EEF0F5;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.08);
--shadow-primary: 0 4px 16px rgba(79, 110, 247, 0.25);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--radius-full: 9999px;
--font-size-xs: 11px;
--font-size-sm: 13px;
--font-size-base: 14px;
--font-size-md: 15px;
--font-size-lg: 17px;
--font-size-xl: 20px;
--font-size-2xl: 24px;
--font-size-3xl: 30px;
--font-family: 'PingFang SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
}

View File

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

View File

@@ -1,21 +1,81 @@
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../stores/auth.store';
const SIDEBAR_ICONS: Record<string, React.ReactNode> = {
dashboard: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
),
patients: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
),
consultations: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
<line x1="9" y1="10" x2="15" y2="10" />
<line x1="12" y1="7" x2="12" y2="13" />
</svg>
),
reports: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
),
followups: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
),
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 = [
{ to: '/dashboard', label: '工作台', icon: '📊' },
{ to: '/patients', label: '患者管理', icon: '👥' },
{ to: '/consultations', label: '在线问诊', icon: '💬' },
{ to: '/reports', label: '报告审核', icon: '📋' },
{ to: '/follow-ups', label: '随访管理', icon: '📅' },
{ to: '/dashboard', label: '工作台', ikey: 'dashboard' },
{ to: '/patients', label: '患者管理', ikey: 'patients' },
{ to: '/consultations', label: '在线问诊', ikey: 'consultations' },
{ to: '/reports', label: '报告审核', ikey: 'reports' },
{ to: '/follow-ups', label: '复查管理', ikey: 'followups' },
{ to: '/visits', label: '随访管理', ikey: 'visits' },
];
const sidebarBg = '#0F1D3D';
const accentColor = '#4D8FFF';
const textMuted = '#8E9DB5';
const sidebarStyles = {
bg: '#FFFFFF',
cardBg: 'linear-gradient(145deg, #4F6EF7 0%, #6988FF 100%)',
accentColor: '#4F6EF7',
textMuted: '#9BA0B4',
textPrimary: '#1A1D28',
borderColor: '#EEF0F5',
hoverBg: '#F5F7FB',
activeBg: '#EDF0FD',
};
const { accentColor, textMuted, textPrimary } = sidebarStyles;
export function DoctorLayout() {
const { user, logout } = useAuthStore();
const navigate = useNavigate();
const location = useLocation();
const handleLogout = () => {
logout();
@@ -23,60 +83,87 @@ export function DoctorLayout() {
};
return (
<div style={{ display: 'flex', minHeight: '100vh', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif' }}>
{/* Sidebar */}
<div style={{ display: 'flex', minHeight: '100vh' }}>
<aside style={{
width: 220, background: sidebarBg, color: '#fff',
width: 224, background: sidebarStyles.bg, color: textPrimary,
display: 'flex', flexDirection: 'column', flexShrink: 0,
boxShadow: '2px 0 24px rgba(0,0,0,0.04)',
borderRight: '1px solid #F0F2F5',
}}>
<div style={{ padding: '24px 20px 20px', borderBottom: '1px solid rgba(255,255,255,0.08)' }}>
<h1 style={{ fontSize: 17, margin: 0, fontWeight: 600, color: '#fff', letterSpacing: 1 }}>
<span style={{ color: accentColor }}></span>
</h1>
<p style={{ fontSize: 12, margin: '6px 0 0', color: textMuted }}></p>
<div style={{ padding: '24px 20px 20px', borderBottom: `1px solid ${sidebarStyles.borderColor}` }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 38, height: 38, borderRadius: 12,
background: sidebarStyles.cardBg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="#fff" stroke="none">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
</div>
<div>
<h1 style={{ fontSize: 17, margin: 0, fontWeight: 700, color: textPrimary, letterSpacing: 0.5 }}></h1>
<p style={{ fontSize: 11, margin: '4px 0 0', color: textMuted }}></p>
</div>
</div>
</div>
<nav style={{ flex: 1, padding: '12px 0' }}>
<nav style={{ flex: 1, padding: '8px 0' }}>
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
style={({ isActive }) => ({
display: 'flex', alignItems: 'center', gap: 10,
padding: '11px 20px', margin: '2px 8px',
borderRadius: 8,
color: isActive ? '#fff' : textMuted,
background: isActive ? accentColor : 'transparent',
padding: '11px 16px', margin: '2px 10px',
borderRadius: 10,
color: isActive ? accentColor : textMuted,
background: isActive ? sidebarStyles.activeBg : 'transparent',
textDecoration: 'none', fontSize: 14,
fontWeight: isActive ? 500 : 400,
transition: 'all 0.15s',
fontWeight: isActive ? 600 : 400,
transition: 'all 0.2s',
})}
>
<span style={{ fontSize: 16 }}>{item.icon}</span>
{SIDEBAR_ICONS[item.ikey]}
<span>{item.label}</span>
</NavLink>
))}
</nav>
<div style={{ padding: '16px 20px', borderTop: '1px solid rgba(255,255,255,0.08)' }}>
<div style={{ fontSize: 13, color: '#fff', fontWeight: 500 }}>{user?.name}</div>
<div style={{ fontSize: 11, color: textMuted, marginTop: 2 }}>{user?.department} · {user?.title}</div>
<div style={{ padding: '16px 16px', borderTop: `1px solid ${sidebarStyles.borderColor}`, background: '#FAFBFD' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<div style={{
width: 38, height: 38, borderRadius: 12,
background: sidebarStyles.cardBg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 15, fontWeight: 700, color: '#fff',
}}>
{user?.name?.charAt(0) || 'D'}
</div>
<div>
<div style={{ fontSize: 13, color: textPrimary, fontWeight: 600 }}>{user?.name}</div>
<div style={{ fontSize: 11, color: textMuted, marginTop: 1 }}>{user?.department} · {user?.title}</div>
</div>
</div>
<button onClick={handleLogout}
style={{
marginTop: 10, padding: '6px 14px', fontSize: 12,
background: 'transparent', color: textMuted, border: '1px solid rgba(255,255,255,0.15)',
borderRadius: 6, cursor: 'pointer', transition: 'all 0.15s',
width: '100%', padding: '8px 0', fontSize: 12,
background: 'transparent', color: '#EF4444',
border: '1px solid #FEE9E9', borderRadius: 8,
cursor: 'pointer', transition: 'all 0.2s',
fontWeight: 500,
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; e.currentTarget.style.borderColor = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = textMuted; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.15)'; }}>
onMouseEnter={(e) => { e.currentTarget.style.background = '#FEF2F2'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}>
退
</button>
</div>
</aside>
{/* Main content */}
<main style={{ flex: 1, background: '#F2F5FA', overflow: 'auto' }}>
<div key={location.pathname} style={{ animation: 'fadeIn 0.25s ease-out' }}>
<Outlet />
</div>
</main>
</div>
);

View File

@@ -4,108 +4,26 @@
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--accent: #4F6EF7;
--accent-bg: rgba(79, 110, 247, 0.1);
--accent-border: rgba(79, 110, 247, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--shadow: rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
#root {
width: 1126px;
width: 100%;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
margin: 0;
text-align: left;
min-height: 100vh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
body { margin: 0; }

View File

@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { router } from './router';
import './assets/styles/global.css';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>

View File

@@ -27,37 +27,48 @@ export function LoginPage() {
return (
<div style={{
display: 'flex', justifyContent: 'center', alignItems: 'center',
minHeight: '100vh', background: '#f0f2f5',
minHeight: '100vh', background: 'linear-gradient(135deg, #EBF0FD 0%, #F5F7FB 50%, #EDF0FD 100%)',
}}>
<form onSubmit={handleLogin} style={{
width: 400, padding: 40, background: '#fff', borderRadius: 8,
boxShadow: '0 2px 12px rgba(0,0,0,0.1)',
width: 400, padding: 40, background: '#fff', borderRadius: 20,
boxShadow: '0 8px 30px rgba(0,0,0,0.08)',
}}>
<h2 style={{ textAlign: 'center', marginBottom: 24 }}></h2>
<div style={{ textAlign: 'center', marginBottom: 28 }}>
<svg width="40" height="40" viewBox="0 0 24 24" fill="#4F6EF7" stroke="none" style={{ marginBottom: 12 }}>
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
<h2 style={{ margin: 0, fontSize: 22, fontWeight: 700, color: '#1A1D28' }}></h2>
<p style={{ margin: '6px 0 0', fontSize: 13, color: '#9BA0B4' }}> · </p>
</div>
{error && <div style={{ color: '#f44336', marginBottom: 12, fontSize: 13 }}>{error}</div>}
{error && <div style={{ color: '#EF4444', marginBottom: 12, fontSize: 13, background: '#FEE9E9', padding: '8px 12px', borderRadius: 8 }}>{error}</div>}
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 4, fontSize: 13 }}></label>
<label style={{ display: 'block', marginBottom: 4, fontSize: 13, fontWeight: 500, color: '#5A6072' }}></label>
<input value={phone} onChange={(e) => setPhone(e.target.value)}
style={{ width: '100%', padding: '10px 12px', border: '1px solid #ddd', borderRadius: 4, fontSize: 14 }} />
style={{ width: '100%', padding: '12px 14px', border: '1.5px solid #E1E5ED', borderRadius: 10, fontSize: 14, outline: 'none', boxSizing: 'border-box', transition: 'border-color 0.2s' }}
onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
</div>
<div style={{ marginBottom: 20 }}>
<label style={{ display: 'block', marginBottom: 4, fontSize: 13 }}> ()</label>
<label style={{ display: 'block', marginBottom: 4, fontSize: 13, fontWeight: 500, color: '#5A6072' }}> ()</label>
<input value={code} onChange={(e) => setCode(e.target.value)}
placeholder="输入任意验证码"
style={{ width: '100%', padding: '10px 12px', border: '1px solid #ddd', borderRadius: 4, fontSize: 14 }} />
style={{ width: '100%', padding: '12px 14px', border: '1.5px solid #E1E5ED', borderRadius: 10, fontSize: 14, outline: 'none', boxSizing: 'border-box', transition: 'border-color 0.2s' }}
onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
</div>
<button type="submit" disabled={loading} style={{
width: '100%', padding: '12px', background: '#1976d2', color: '#fff',
border: 'none', borderRadius: 4, fontSize: 15, opacity: loading ? 0.7 : 1,
width: '100%', padding: '13px', background: 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)', color: '#fff',
border: 'none', borderRadius: 10, fontSize: 15, fontWeight: 600,
opacity: loading ? 0.7 : 1, boxShadow: '0 4px 16px rgba(79,110,247,0.3)',
}}>
{loading ? '登录中...' : '登录'}
</button>
<p style={{ marginTop: 16, fontSize: 12, color: '#999', textAlign: 'center' }}>
<p style={{ marginTop: 16, fontSize: 12, color: '#9BA0B4', textAlign: 'center' }}>
13700137000 ( )
</p>
</form>

View File

@@ -1,5 +1,6 @@
import { useEffect, useState, useRef } from 'react';
import { useEffect, useState, useRef, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { HubConnectionBuilder, HubConnection, HubConnectionState } from '@microsoft/signalr';
import { api } from '../../services/api-client';
interface Message {
@@ -7,12 +8,24 @@ interface Message {
content: string; contentType: string; createdAt: string;
}
function getToken(): string {
try {
const raw = localStorage.getItem('doc_auth');
if (!raw) return '';
const state = JSON.parse(raw);
return state?.state?.token ?? '';
} catch { return ''; }
}
export function ChatPage() {
const { id } = useParams<{ id: string }>();
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [connected, setConnected] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const connRef = useRef<HubConnection | null>(null);
// Load initial messages via HTTP
useEffect(() => {
if (!id) return;
api.get<Message[]>(`/api/consultations/${id}/messages`)
@@ -20,23 +33,69 @@ export function ChatPage() {
.catch(() => {});
}, [id]);
// Set up SignalR connection
useEffect(() => {
if (!id) return;
const conn = new HubConnectionBuilder()
.withUrl('http://localhost:5000/hubs/chat', {
accessTokenFactory: () => getToken(),
})
.withAutomaticReconnect()
.build();
conn.on('ReceiveMessage', (msg: Message) => {
setMessages((prev) => {
// Dedup — guard against reconnection replay
if (prev.some((m) => m.id === msg.id)) return prev;
return [...prev, msg];
});
});
conn.onreconnected(() => {
conn.invoke('JoinConsultation', id).catch(() => {});
});
conn.start()
.then(() => {
setConnected(true);
return conn.invoke('JoinConsultation', id);
})
.catch(() => {});
connRef.current = conn;
return () => {
if (conn.state === HubConnectionState.Connected) {
conn.invoke('LeaveConsultation', id).catch(() => {});
}
conn.stop();
};
}, [id]);
// Auto-scroll on new messages
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const handleSend = async () => {
if (!input.trim() || !id) return;
try {
const res = await api.post<Message>(`/api/consultations/${id}/messages`, { content: input });
setMessages((prev) => [...prev, res.data]);
const handleSend = useCallback(async () => {
if (!input.trim() || !id || !connRef.current) return;
const text = input;
setInput('');
try {
await connRef.current.invoke('SendMessage', id, text);
} catch { /* ignore */ }
};
}, [input, id]);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 0px)' }}>
<div style={{ padding: '14px 20px', background: '#fff', borderBottom: '1px solid #eee', fontSize: 15, fontWeight: 500 }}>
<div style={{ padding: '14px 20px', background: '#fff', borderBottom: '1px solid #eee', fontSize: 15, fontWeight: 500, display: 'flex', alignItems: 'center', gap: 8 }}>
线
<span style={{
width: 8, height: 8, borderRadius: '50%',
background: connected ? '#4caf50' : '#ccc',
display: 'inline-block',
}} />
</div>
<div style={{ flex: 1, overflow: 'auto', padding: 20, background: '#fafafa' }}>

View File

@@ -2,64 +2,54 @@ import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../../services/api-client';
interface ConsultationItem {
id: string; patientId: string; patientName: string; subject: string;
status: string; startedAt: string;
}
interface RawConsultation {
id: string; patientId: string; patientName?: string; subject?: string;
status: string; startedAt: string;
}
export function ConsultationListPage() {
const [consultations, setConsultations] = useState<ConsultationItem[]>([]);
const [consultations, setConsultations] = useState<RawConsultation[]>([]);
useEffect(() => {
api.get<RawConsultation[]>('/api/consultations').then((r) => {
const mapped = r.data.map((c) => ({
id: c.id,
patientId: c.patientId,
patientName: c.patientName || 'unknown',
subject: c.subject || 'online consult',
status: c.status,
startedAt: c.startedAt,
}));
setConsultations(mapped);
setConsultations(r.data);
}).catch(() => {});
}, []);
return (
<div style={{ padding: 24 }}>
<h2 style={{ marginBottom: 16 }}>线</h2>
<div style={{ padding: 28 }}>
<h2 style={{ marginBottom: 6, fontSize: 20, fontWeight: 700, color: '#1A1D28' }}>线</h2>
<p style={{ marginBottom: 18, fontSize: 13, color: '#9BA0B4' }}> {consultations.length} </p>
<div style={{ background: '#fff', borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}>
<div style={{ background: '#fff', borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)', overflow: 'hidden' }}>
{consultations.map((c) => (
<Link key={c.id} to={`/consultations/${c.id}`} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '14px 20px', borderBottom: '1px solid #f5f5f5',
textDecoration: 'none', color: 'inherit',
}}>
padding: '16px 22px', borderBottom: '1px solid #F5F6F9',
textDecoration: 'none', color: 'inherit', transition: 'background 0.15s',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = '#F9FAFC'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = ''; }}>
<div>
<div style={{ fontSize: 14, fontWeight: 500 }}>{c.patientName}</div>
<div style={{ fontSize: 12, color: '#888', marginTop: 2 }}>{c.subject}</div>
<div style={{ fontSize: 14, fontWeight: 600, color: '#1A1D28' }}>{c.patientName || '未知'}</div>
<div style={{ fontSize: 12, color: '#9BA0B4', marginTop: 3 }}>{c.subject || '在线问诊'}</div>
</div>
<div style={{ textAlign: 'right' }}>
<span style={{
padding: '2px 8px', borderRadius: 10, fontSize: 11,
background: c.status === 'active' ? '#e8f5e9' : '#f5f5f5',
color: c.status === 'active' ? '#2e7d32' : '#999',
padding: '4px 12px', borderRadius: 10, fontSize: 11, fontWeight: 500,
background: c.status === 'active' ? '#E6F9F2' : '#F5F6F9',
color: c.status === 'active' ? '#20C997' : '#9BA0B4',
}}>
{c.status === 'active' ? '进行中' : '已结束'}
</span>
<div style={{ fontSize: 11, color: '#bbb', marginTop: 4 }}>
<div style={{ fontSize: 11, color: '#C0C5D2', marginTop: 4 }}>
{c.startedAt?.split('T')[0]}
</div>
</div>
</Link>
))}
{consultations.length === 0 && (
<div style={{ padding: 40, textAlign: 'center', color: '#999' }}></div>
<div style={{ padding: 40, textAlign: 'center', color: '#9BA0B4', fontSize: 13 }}></div>
)}
</div>
</div>

View File

@@ -9,6 +9,41 @@ interface RawConsultation { id: string; status: string; patientName: string; sub
interface RawFollowUp { id: string; scheduledAt: string; title: string; status: string; }
interface RawReport { id: string; title: string; status: string; }
const statCardStyle: React.CSSProperties = {
background: '#fff', padding: 22, borderRadius: 16,
boxShadow: '0 2px 12px rgba(0,0,0,0.04)',
position: 'relative', overflow: 'hidden',
};
const statColorBar = (color: string): React.CSSProperties => ({
position: 'absolute', top: 0, left: 0, width: 4, height: '100%',
background: color, borderRadius: '4px 0 0 4px',
});
const todoIcons: Record<string, React.ReactNode> = {
reports: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#F59E0B" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
),
consultations: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#4F6EF7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
),
followups: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#845EF7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
),
};
export function DashboardPage() {
const user = useAuthStore((s) => s.user);
const [stats, setStats] = useState<DashboardStats>({
@@ -22,7 +57,7 @@ export function DashboardPage() {
const [patients, consultations, reports, followUps] = await Promise.all([
api.get<RawPatient[]>('/api/patients'),
api.get<RawConsultation[]>('/api/consultations'),
api.get<RawReport[]>('/api/reports?status=pending'),
api.get<RawReport[]>('/api/reports/pending'),
api.get<RawFollowUp[]>('/api/follow-ups'),
]);
setStats({
@@ -41,58 +76,69 @@ export function DashboardPage() {
loadStats();
}, []);
return (
<div style={{ padding: 24 }}>
<h2 style={{ marginBottom: 20 }}>{user?.name}</h2>
const statItems = [
{ label: '患者总数', value: stats.totalPatients, color: '#4F6EF7', bg: '#EDF0FD' },
{ label: '进行中问诊', value: stats.activeConsultations, color: '#20C997', bg: '#E6F9F2' },
{ label: '待审核报告', value: stats.pendingReports, color: '#F59E0B', bg: '#FFF8E6' },
{ label: '今日随访', value: stats.todayFollowUps, color: '#845EF7', bg: '#F3E8FF' },
];
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, marginBottom: 32 }}>
{[
{ label: '患者总数', value: stats.totalPatients, color: '#1976d2' },
{ label: '进行中问诊', value: stats.activeConsultations, color: '#388e3c' },
{ label: '待审核报告', value: stats.pendingReports, color: '#f57c00' },
{ label: '今日随访', value: stats.todayFollowUps, color: '#7b1fa2' },
].map((item) => (
<div key={item.label} style={{
background: '#fff', padding: 20, borderRadius: 8,
borderLeft: `4px solid ${item.color}`, boxShadow: '0 1px 4px rgba(0,0,0,0.08)',
}}>
<div style={{ fontSize: 28, fontWeight: 700, color: item.color }}>{item.value}</div>
<div style={{ fontSize: 13, color: '#888', marginTop: 4 }}>{item.label}</div>
const quickActions = [
{ label: '患者列表', href: '/patients', color: '#4F6EF7', bg: '#EDF0FD' },
{ label: '在线问诊', href: '/consultations', color: '#20C997', bg: '#E6F9F2' },
{ label: '报告审核', href: '/reports', color: '#F59E0B', bg: '#FFF8E6' },
{ label: '随访管理', href: '/follow-ups', color: '#845EF7', bg: '#F3E8FF' },
];
return (
<div style={{ padding: 28 }}>
<h2 style={{ marginBottom: 4, fontSize: 22, fontWeight: 700, color: '#1A1D28' }}>{user?.name}</h2>
<p style={{ marginBottom: 24, fontSize: 13, color: '#9BA0B4' }}>{user?.department} · {user?.title}</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, marginBottom: 28 }}>
{statItems.map((item) => (
<div key={item.label} style={statCardStyle}>
<div style={statColorBar(item.color)} />
<div style={{ paddingLeft: 8 }}>
<div style={{ fontSize: 30, fontWeight: 800, color: item.color, lineHeight: 1.1 }}>{item.value}</div>
<div style={{ fontSize: 13, color: '#5A6072', marginTop: 6, fontWeight: 500 }}>{item.label}</div>
</div>
</div>
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 16 }}>
<div style={{ background: '#fff', padding: 20, borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}>
<h3 style={{ marginBottom: 12, fontSize: 15 }}></h3>
<div style={{ background: '#fff', padding: 22, borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
<h3 style={{ marginBottom: 16, fontSize: 16, fontWeight: 600, color: '#1A1D28' }}></h3>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{[
{ label: '患者列表', href: '/patients' },
{ label: '在线问诊', href: '/consultations' },
{ label: '报告审核', href: '/reports' },
{ label: '随访管理', href: '/follow-ups' },
].map((action) => (
{quickActions.map((action) => (
<Link key={action.label} to={action.href} style={{
padding: '8px 16px', background: '#f0f2f5', borderRadius: 4,
textDecoration: 'none', color: '#1976d2', fontSize: 13,
}}>
padding: '10px 18px', background: action.bg, borderRadius: 10,
textDecoration: 'none', color: action.color, fontSize: 13,
fontWeight: 600, transition: 'all 0.2s',
}}
onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)'; }}
onMouseLeave={(e) => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = ''; }}>
{action.label}
</Link>
))}
</div>
</div>
<div style={{ background: '#fff', padding: 20, borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}>
<h3 style={{ marginBottom: 12, fontSize: 15 }}></h3>
<ul style={{ fontSize: 13, color: '#666', listStyle: 'none', padding: 0 }}>
<li style={{ padding: '6px 0', borderBottom: '1px solid #f0f0f0' }}>
📋 : {stats.pendingReports}
<div style={{ background: '#fff', padding: 22, borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
<h3 style={{ marginBottom: 14, fontSize: 16, fontWeight: 600, color: '#1A1D28' }}></h3>
<ul style={{ fontSize: 13, color: '#5A6072', listStyle: 'none', padding: 0 }}>
<li style={{ padding: '10px 0', borderBottom: '1px solid #F0F2F5', display: 'flex', alignItems: 'center', gap: 8 }}>
{todoIcons.reports}
: <strong style={{ color: '#F59E0B' }}>{stats.pendingReports}</strong>
</li>
<li style={{ padding: '6px 0', borderBottom: '1px solid #f0f0f0' }}>
💬 : {stats.activeConsultations}
<li style={{ padding: '10px 0', borderBottom: '1px solid #F0F2F5', display: 'flex', alignItems: 'center', gap: 8 }}>
{todoIcons.consultations}
: <strong style={{ color: '#4F6EF7' }}>{stats.activeConsultations}</strong>
</li>
<li style={{ padding: '6px 0' }}>
📅 访: {stats.todayFollowUps}
<li style={{ padding: '10px 0', display: 'flex', alignItems: 'center', gap: 8 }}>
{todoIcons.followups}
访: <strong style={{ color: '#845EF7' }}>{stats.todayFollowUps}</strong>
</li>
</ul>
</div>

View File

@@ -29,30 +29,35 @@ export function FollowUpEditPage() {
e.preventDefault();
const body = { title, patientId, scheduledAt, notes };
try {
if (isNew) {
await api.post('/api/follow-ups', body);
} else {
await api.put(`/api/follow-ups/${id}`, body);
}
if (isNew) { await api.post('/api/follow-ups', body); }
else { await api.put(`/api/follow-ups/${id}`, body); }
navigate('/follow-ups');
} catch { alert('操作失败'); }
};
return (
<div style={{ padding: 24 }}>
<h2 style={{ marginBottom: 16 }}>{isNew ? '新建随访' : '编辑随访'}</h2>
const inputStyle: React.CSSProperties = {
width: '100%', padding: '10px 14px', border: '1.5px solid #E1E5ED',
borderRadius: 10, fontSize: 13, outline: 'none', boxSizing: 'border-box',
};
const labelStyle: React.CSSProperties = {
display: 'block', fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 5,
};
<form onSubmit={handleSubmit} style={{ background: '#fff', padding: 24, borderRadius: 8, maxWidth: 500 }}>
<div style={{ marginBottom: 14 }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}></label>
<input value={title} onChange={(e) => setTitle(e.target.value)} required
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4 }} />
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={labelStyle}></label>
<input value={title} onChange={(e) => setTitle(e.target.value)} required style={inputStyle}
onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
</div>
<div style={{ marginBottom: 14 }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}></label>
<select value={patientId} onChange={(e) => setPatientId(e.target.value)} required
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4 }}>
<div style={{ marginBottom: 16 }}>
<label style={labelStyle}></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>
@@ -60,21 +65,23 @@ export function FollowUpEditPage() {
</select>
</div>
<div style={{ marginBottom: 14 }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}></label>
<input type="datetime-local" value={scheduledAt} onChange={(e) => setScheduledAt(e.target.value)} required
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4 }} />
<div style={{ marginBottom: 16 }}>
<label style={labelStyle}></label>
<input type="datetime-local" value={scheduledAt} onChange={(e) => setScheduledAt(e.target.value)} required style={inputStyle}
onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
</div>
<div style={{ marginBottom: 18 }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}></label>
<div style={{ marginBottom: 20 }}>
<label style={labelStyle}></label>
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={3}
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4, resize: 'vertical' }} />
style={{ ...inputStyle, resize: 'vertical', fontFamily: 'inherit' }} />
</div>
<button type="submit" style={{
padding: '10px 24px', background: '#1976d2', color: '#fff',
border: 'none', borderRadius: 4, fontSize: 14,
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>

View File

@@ -2,81 +2,122 @@ import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../../services/api-client';
interface FollowUpItem {
id: string; patientId: string; patientName: string;
title: string; scheduledAt: string; status: string;
}
interface RawFollowUpItem {
id: string; patientId: string; patientName?: string;
title: string; scheduledAt: string; status: string;
}
export function FollowUpListPage() {
const [followUps, setFollowUps] = useState<FollowUpItem[]>([]);
const [followUps, setFollowUps] = useState<RawFollowUpItem[]>([]);
const [showCompleted, setShowCompleted] = useState(false);
useEffect(() => {
api.get<RawFollowUpItem[]>('/api/follow-ups').then((r) => {
const mapped = r.data.map((f) => ({
id: f.id,
patientId: f.patientId,
patientName: f.patientName || 'unknown',
title: f.title,
scheduledAt: f.scheduledAt,
status: f.status,
}));
setFollowUps(mapped);
}).catch(() => {});
}, []);
const statusLabel = (s: string) => {
switch (s) {
case 'pending': return { text: '待随访', color: '#f57c00', bg: '#fff3e0' };
case 'completed': return { text: '已完成', color: '#2e7d32', bg: '#e8f5e9' };
case 'missed': return { text: '已错过', color: '#c62828', bg: '#ffebee' };
default: return { text: s, color: '#666', bg: '#f5f5f5' };
}
const load = () => {
api.get<RawFollowUpItem[]>('/api/follow-ups?type=recheck')
.then((r) => setFollowUps(r.data)).catch(() => {});
};
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 (
<div style={{ padding: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2>访</h2>
<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="/follow-ups/new/edit" style={{
padding: '8px 16px', background: '#1976d2', color: '#fff',
borderRadius: 4, textDecoration: 'none', fontSize: 13,
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' }}> {followUps.length} </p>
<div style={{ background: '#fff', borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}>
{followUps.map((f) => {
const s = statusLabel(f.status);
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{displayed.map((f) => {
const s = statusLabel(f);
return (
<div key={f.id} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '14px 20px', borderBottom: '1px solid #f5f5f5',
padding: '16px 22px', background: '#fff', borderRadius: 14,
boxShadow: '0 1px 4px rgba(0,0,0,0.04)', border: '1px solid #F0F2F5',
}}>
<div>
<div style={{ fontSize: 14, fontWeight: 500 }}>{f.title}</div>
<div style={{ fontSize: 12, color: '#888', marginTop: 2 }}>
{f.patientName} · {f.scheduledAt?.split('T')[0]}
<div style={{ fontSize: 14, fontWeight: 600, color: '#1A1D28' }}>{f.title}</div>
<div style={{ fontSize: 12, color: '#9BA0B4', marginTop: 3 }}>
{f.patientName || '未知'} · {f.scheduledAt?.split('T')[0]}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ padding: '2px 8px', borderRadius: 10, fontSize: 11, background: s.bg, color: s.color }}>
<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 }}>
{s.text}
</span>
<Link to={`/follow-ups/${f.id}/edit`} style={{ color: '#1976d2', fontSize: 13 }}></Link>
{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={{
color: '#4F6EF7', fontSize: 12, fontWeight: 600,
padding: '4px 10px', background: '#EDF0FD', borderRadius: 6, textDecoration: 'none',
}}></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>
);
})}
{followUps.length === 0 && (
<div style={{ padding: 40, textAlign: 'center', color: '#999' }}>访</div>
<div style={{ padding: 40, textAlign: 'center', color: '#9BA0B4', fontSize: 13 }}></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>
);
}

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

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

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useMemo } from 'react';
import { useParams, Link } from 'react-router-dom';
import { api } from '../../services/api-client';
import { MultiLineChart, type SeriesData } from '../../components/charts/MultiLineChart';
interface PatientDetail {
id: string; name: string; phone: string; gender: string; birthday: string;
@@ -12,21 +13,76 @@ interface HealthRecord {
id: string; type: string; value: string; unit: string; recordedAt: string;
}
interface ExerciseEntry {
type: string; duration: number; intensity: string; caloriesBurned: number; date: string;
}
interface DietEntry {
foods: { name: string; amount?: string; calories?: number }[];
mealType: string; totalCalories: number; date: string;
}
const typeLabels: Record<string, string> = {
blood_pressure: '血压', heart_rate: '心率', blood_sugar: '血糖', spo2: '血氧',
};
const typeColors: Record<string, string> = {
blood_pressure: '#EF4444', heart_rate: '#F59E0B', blood_sugar: '#4F6EF7', spo2: '#20C997',
};
const typeBgs: Record<string, string> = {
blood_pressure: '#FEE9E9', heart_rate: '#FFF8E6', blood_sugar: '#EDF0FD', spo2: '#E6F9F2',
};
const exerciseIcons: Record<string, string> = {
'散步': '🚶', '慢跑': '🏃', '太极拳': '🤸', '游泳': '🏊', '骑自行车': '🚴', '八段锦': '🧘',
};
const mealLabels: Record<string, string> = {
breakfast: '早餐', lunch: '午餐', dinner: '晚餐', snack: '加餐',
};
const mealIcons: Record<string, string> = {
breakfast: '🌅', lunch: '☀️', dinner: '🌙', snack: '🍎',
};
export function PatientDetailPage() {
const { id } = useParams<{ id: string }>();
const [patient, setPatient] = useState<PatientDetail | null>(null);
const [records, setRecords] = useState<HealthRecord[]>([]);
const [exercises, setExercises] = useState<ExerciseEntry[]>([]);
const [diets, setDiets] = useState<DietEntry[]>([]);
useEffect(() => {
if (!id) return;
// Fetch patient detail directly by ID + health records
api.get<PatientDetail>(`/api/patients/${id}`).then((r) => {
if (r.data) setPatient(r.data);
}).catch(() => {});
api.get<HealthRecord[]>(`/api/health-records?patientId=${id}&days=30`).then((r) => setRecords(r.data));
api.get<HealthRecord[]>(`/api/health-records?patientId=${id}&days=30`).then((r) => {
const all = r.data || [];
setRecords(all.filter((x) => ['blood_pressure', 'heart_rate', 'blood_sugar', 'spo2'].includes(x.type)));
// Parse exercise records
const exList: ExerciseEntry[] = [];
const dietList: DietEntry[] = [];
all.filter((x) => x.type === 'exercise' || x.type === 'diet').forEach((r) => {
try {
const v = JSON.parse(r.value);
const date = r.recordedAt?.split('T')[0] || '';
if (r.type === 'exercise') {
exList.push({ type: v.type, duration: v.duration, intensity: v.intensity, caloriesBurned: v.caloriesBurned || v.calories, date });
} else {
dietList.push({ mealType: v.mealType || v.meal, foods: v.foods || [], totalCalories: v.totalCalories, date });
}
} catch { /* skip */ }
});
exList.sort((a, b) => b.date.localeCompare(a.date));
dietList.sort((a, b) => b.date.localeCompare(a.date));
setExercises(exList.slice(0, 10));
setDiets(dietList.slice(0, 10));
}).catch(() => {});
}, [id]);
if (!patient) return <div style={{ padding: 24 }}>...</div>;
if (!patient) return <div style={{ padding: 28, color: '#9BA0B4' }}>...</div>;
const latestByType: Record<string, HealthRecord> = {};
records.forEach((r) => {
@@ -44,38 +100,209 @@ export function PatientDetailPage() {
};
return (
<div style={{ padding: 24 }}>
<Link to="/patients" style={{ fontSize: 13, color: '#1976d2' }}> </Link>
<div style={{ padding: 28, maxWidth: 1100 }}>
<Link to="/patients" style={{ fontSize: 13, color: '#4F6EF7', fontWeight: 500 }}> </Link>
<div style={{ background: '#fff', marginTop: 16, padding: 24, borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}>
<h2>{patient.name}</h2>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px 24px', marginTop: 12, fontSize: 14 }}>
<div>{patient.phone}</div>
<div>{patient.gender || '-'}</div>
<div>{patient.birthday || '-'}</div>
<div>{patient.heightCm}cm / {patient.weightKg}kg</div>
<div>{(patient.medicalHistory || []).join('、') || '-'}</div>
<div>{patient.stentDate || '-'}</div>
<div>{patient.stentType || '-'}</div>
{/* Patient info card */}
<div style={{ background: '#fff', marginTop: 16, padding: 28, borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 20 }}>
<div style={{
width: 52, height: 52, borderRadius: 16,
background: 'linear-gradient(135deg, #4F6EF7, #6C8AFF)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, fontWeight: 700, color: '#fff',
}}>
{patient.name?.charAt(0) || '?'}
</div>
<div>
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>{patient.name}</h2>
<p style={{ margin: '4px 0 0', fontSize: 12, color: '#9BA0B4' }}>{patient.phone}</p>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px 32px', fontSize: 13 }}>
<InfoRow label="手机号" value={patient.phone} />
<InfoRow label="性别" value={patient.gender || '-'} />
<InfoRow label="出生日期" value={patient.birthday || '-'} />
<InfoRow label="身高/体重" value={`${patient.heightCm}cm / ${patient.weightKg}kg`} />
<InfoRow label="病史" value={(patient.medicalHistory || []).join('、') || '-'} />
<InfoRow label="支架日期" value={patient.stentDate || '-'} />
<InfoRow label="支架类型" value={patient.stentType || '-'} />
</div>
</div>
<h3 style={{ marginTop: 24, marginBottom: 12 }}></h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
{/* Health vitals */}
<h3 style={{ marginTop: 28, marginBottom: 14, fontSize: 17, fontWeight: 700, color: '#1A1D28' }}></h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 14 }}>
{Object.entries(latestByType).map(([type, record]) => (
<div key={type} style={{ background: '#fff', padding: 16, borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}>
<div style={{ fontSize: 12, color: '#888' }}>
{type === 'blood_pressure' ? '血压' : type === 'heart_rate' ? '心率' : type}
<div key={type} style={{
background: '#fff', padding: 20, borderRadius: 16,
boxShadow: '0 2px 12px rgba(0,0,0,0.04)', position: 'relative',
}}>
<div style={{ position: 'absolute', top: 0, left: 0, width: 4, height: '100%', background: typeColors[type] || '#4F6EF7', borderRadius: '4px 0 0 4px' }} />
<div style={{ paddingLeft: 8 }}>
<div style={{
fontSize: 11, fontWeight: 600, color: typeColors[type] || '#4F6EF7',
background: typeBgs[type] || '#EDF0FD', display: 'inline-block',
padding: '3px 10px', borderRadius: 6, marginBottom: 10,
}}>
{typeLabels[type] || type}
</div>
<div style={{ fontSize: 20, fontWeight: 600, marginTop: 4 }}>
{parseValueDisplay(record)} {record.unit}
<div style={{ fontSize: 22, fontWeight: 800, color: '#1A1D28' }}>
{parseValueDisplay(record)} <span style={{ fontSize: 13, fontWeight: 500, color: '#9BA0B4' }}>{record.unit}</span>
</div>
<div style={{ fontSize: 11, color: '#bbb', marginTop: 4 }}>
<div style={{ fontSize: 11, color: '#C0C5D2', marginTop: 6 }}>
{record.recordedAt?.split('T')[0]}
</div>
</div>
</div>
))}
</div>
{/* Trend chart */}
<ChartSection records={records} />
{/* Exercise + Diet side by side */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20, marginTop: 28 }}>
{/* Exercise */}
<div>
<h3 style={{ margin: '0 0 14px', fontSize: 17, fontWeight: 700, color: '#1A1D28' }}>
🏃
<span style={{ fontSize: 12, fontWeight: 500, color: '#9BA0B4', marginLeft: 8 }}>
7 · {exercises.reduce((s, e) => s + (e.duration || 0), 0)}
</span>
</h3>
{exercises.length === 0 ? (
<div style={{ padding: 40, textAlign: 'center', color: '#C0C5D2', background: '#fff', borderRadius: 14, fontSize: 13 }}></div>
) : (
exercises.slice(0, 7).map((e, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px',
background: '#fff', borderRadius: 12, marginBottom: 8,
boxShadow: '0 1px 6px rgba(0,0,0,0.03)',
borderLeft: `4px solid ${e.intensity === '高' || e.intensity === 'high' ? '#EF4444' : e.intensity === '中' || e.intensity === 'moderate' ? '#F59E0B' : '#20C997'}`,
}}>
<span style={{ fontSize: 22 }}>{exerciseIcons[e.type] || '💪'}</span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: '#1A1D28' }}>{e.type}</div>
<div style={{ fontSize: 11, color: '#9BA0B4' }}>{e.duration} · {e.caloriesBurned}kcal</div>
</div>
<div style={{ fontSize: 11, color: '#C0C5D2' }}>{e.date?.slice(5)}</div>
</div>
))
)}
</div>
{/* Diet */}
<div>
<h3 style={{ margin: '0 0 14px', fontSize: 17, fontWeight: 700, color: '#1A1D28' }}>
🥗
<span style={{ fontSize: 12, fontWeight: 500, color: '#9BA0B4', marginLeft: 8 }}>
7 · {diets.reduce((s, d) => s + (d.totalCalories || 0), 0)}kcal
</span>
</h3>
{diets.length === 0 ? (
<div style={{ padding: 40, textAlign: 'center', color: '#C0C5D2', background: '#fff', borderRadius: 14, fontSize: 13 }}></div>
) : (
diets.slice(0, 7).map((d, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px',
background: '#fff', borderRadius: 12, marginBottom: 8,
boxShadow: '0 1px 6px rgba(0,0,0,0.03)',
borderLeft: '4px solid #20C997',
}}>
<span style={{ fontSize: 22 }}>{mealIcons[d.mealType] || '🍽️'}</span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: '#1A1D28' }}>
{d.foods?.map(f => f.name).join('、') || '-'}
</div>
<div style={{ fontSize: 11, color: '#9BA0B4' }}>
{mealLabels[d.mealType] || d.mealType} · {d.totalCalories}kcal
</div>
</div>
<div style={{ fontSize: 11, color: '#C0C5D2' }}>{d.date?.slice(5)}</div>
</div>
))
)}
</div>
</div>
</div>
);
}
const CHART_INDICATORS = [
{ type: 'bp_sys', label: '收缩压', color: '#DC4A4A', unit: 'mmHg', source: 'blood_pressure', field: 'systolic' as const },
{ type: 'bp_dia', label: '舒张压', color: '#E0558A', unit: 'mmHg', source: 'blood_pressure', field: 'diastolic' as const },
{ type: 'heart_rate', label: '心率', color: '#D68B20', unit: 'bpm' },
{ type: 'blood_sugar', label: '血糖', color: '#7C5CE7', unit: 'mmol/L' },
{ type: 'spo2', label: '血氧', color: '#3B8ED4', unit: '%' },
{ type: 'weight', label: '体重', color: '#3DAF86', unit: 'kg' },
];
function ChartSection({ records }: { records: HealthRecord[] }) {
const [visible, setVisible] = useState<Set<string>>(new Set(CHART_INDICATORS.map((i) => i.type)));
const series: SeriesData[] = useMemo(() => {
return CHART_INDICATORS
.filter((ind) => visible.has(ind.type))
.map((ind) => {
const source = (ind as Record<string, string>).source || ind.type;
const field = (ind as Record<string, string>).field;
const raw = records
.filter((r) => r.type === source)
.sort((a, b) => a.recordedAt.localeCompare(b.recordedAt));
const data = raw.map((r) => {
try {
const v = JSON.parse(r.value);
const val = field ? (v[field] ?? 0) : (v.value ?? v);
return { date: r.recordedAt.split('T')[0], value: Number(val) || 0 };
} catch { return { date: r.recordedAt.split('T')[0], value: 0 }; }
});
return { name: ind.label, color: ind.color, data, unit: ind.unit };
});
}, [records, visible]);
const toggle = (type: string) => {
const next = new Set(visible);
if (next.has(type)) next.delete(type); else next.add(type);
setVisible(next);
};
const hasData = series.some((s) => s.data.length > 0);
if (!hasData) return null;
return (
<div style={{ marginTop: 28 }}>
<h3 style={{ margin: '0 0 10px', fontSize: 17, fontWeight: 700, color: '#1A1D28' }}>📈 </h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
{CHART_INDICATORS.map((ind) => (
<button
key={ind.type}
onClick={() => toggle(ind.type)}
style={{
padding: '5px 12px', borderRadius: 16, border: '1.5px solid',
borderColor: ind.color, fontSize: 12, fontWeight: 600, cursor: 'pointer',
...(visible.has(ind.type)
? { background: ind.color, color: '#fff' }
: { background: '#fff', color: ind.color }),
}}
>
{ind.label}
</button>
))}
</div>
<div style={{ background: '#fff', padding: 16, borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
<MultiLineChart series={series} />
</div>
</div>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}>
<span style={{ color: '#9BA0B4' }}>{label}</span>
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{value}</span>
</div>
);
}

View File

@@ -4,7 +4,7 @@ import { api } from '../../services/api-client';
interface Patient {
id: string; name: string; phone: string; gender: string;
medicalHistory: string[]; stentDate: string;
medicalHistory: string[]; stentDate: string; stentType: string;
}
export function PatientListPage() {
@@ -24,40 +24,52 @@ export function PatientListPage() {
);
return (
<div style={{ padding: 24 }}>
<h2 style={{ marginBottom: 16 }}></h2>
<div style={{ padding: 28 }}>
<h2 style={{ marginBottom: 6, fontSize: 20, fontWeight: 700, color: '#1A1D28' }}></h2>
<p style={{ marginBottom: 18, fontSize: 13, color: '#9BA0B4' }}> {patients.length} </p>
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="搜索姓名或手机号..."
style={{ width: 300, padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4, marginBottom: 16 }} />
style={{
width: 280, padding: '10px 14px', border: '1.5px solid #E1E5ED', borderRadius: 10,
fontSize: 13, marginBottom: 18, outline: 'none', boxSizing: 'border-box',
transition: 'border-color 0.2s',
}}
onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
{loading ? <div>...</div> : (
<div style={{ background: '#fff', borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
{loading ? <div style={{ color: '#9BA0B4' }}>...</div> : (
<div style={{ background: '#fff', borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)', overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '2px solid #f0f0f0', textAlign: 'left' }}>
<th style={{ padding: '12px 16px' }}></th>
<th style={{ padding: '12px 16px' }}></th>
<th style={{ padding: '12px 16px' }}></th>
<th style={{ padding: '12px 16px' }}></th>
<th style={{ padding: '12px 16px' }}></th>
<th style={{ padding: '12px 16px' }}></th>
<tr style={{ borderBottom: '2px solid #F0F2F5', textAlign: 'left', background: '#F9FAFC' }}>
<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>
</thead>
<tbody>
{filtered.map((p) => (
<tr key={p.id} style={{ borderBottom: '1px solid #f5f5f5' }}>
<td style={{ padding: '10px 16px' }}>{p.name}</td>
<td style={{ padding: '10px 16px', color: '#888' }}>{p.phone}</td>
<td style={{ padding: '10px 16px' }}>{p.gender || '-'}</td>
<td style={{ padding: '10px 16px' }}>{(p.medicalHistory || []).slice(0, 3).join('、') || '-'}</td>
<td style={{ padding: '10px 16px' }}>{p.stentDate || '-'}</td>
<td style={{ padding: '10px 16px' }}>
<Link to={`/patients/${p.id}`} style={{ color: '#1976d2', fontSize: 13 }}></Link>
<tr key={p.id} style={{ borderBottom: '1px solid #F5F6F9' }}>
<td style={{ padding: '12px 20px', fontWeight: 500 }}>{p.name}</td>
<td style={{ padding: '12px 20px', color: '#9BA0B4' }}>{p.phone}</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.stentDate || '-'}</td>
<td style={{ padding: '12px 20px', color: '#5A6072' }}>{p.stentType || '-'}</td>
<td style={{ padding: '12px 20px' }}>
<Link to={`/patients/${p.id}`} style={{
color: '#4F6EF7', fontSize: 12, fontWeight: 600,
padding: '4px 12px', background: '#EDF0FD', borderRadius: 6,
}}></Link>
</td>
</tr>
))}
{filtered.length === 0 && (
<tr><td colSpan={6} style={{ padding: 24, textAlign: 'center', color: '#999' }}></td></tr>
<tr><td colSpan={7} style={{ padding: 32, textAlign: 'center', color: '#9BA0B4' }}></td></tr>
)}
</tbody>
</table>

View File

@@ -7,14 +7,29 @@ interface RawReport {
imageUrls: string[]; status: string; riskLevel?: string;
summary?: string; suggestions?: string;
patientName?: string; doctorName?: string;
createdAt: string; completedAt?: string;
items?: { id: string; itemName: string; resultValue: string; unit?: string; referenceRange?: string; isAbnormal: boolean }[];
uploadedAt: string; completedAt?: string;
items?: RawItem[];
}
interface RawItem {
id: string; itemName: string; resultValue: string;
unit?: string; referenceRange?: string; isAbnormal: boolean;
}
const categoryMap: Record<string, string> = {
'血液检查': '血液检查', '心电图': '心电图', '影像学': '影像学', '尿液检查': '尿液检查', '其他': '其他',
'Blood Test': '血液检查', 'ECG': '心电图', 'Imaging': '影像学',
};
export function ReportDetailPage() {
const { id } = useParams<{ id: string }>();
const [report, setReport] = useState<RawReport | null>(null);
const [interpretation, setInterpretation] = useState('');
const [lightbox, setLightbox] = useState<string | null>(null);
const [summary, setSummary] = useState('');
const [riskLevel, setRiskLevel] = useState('normal');
const [suggestions, setSuggestions] = useState('');
const [items, setItems] = useState([{ itemName: '', resultValue: '', unit: '', referenceRange: '', isAbnormal: false }]);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
@@ -22,69 +37,118 @@ export function ReportDetailPage() {
api.get<RawReport>(`/api/reports/${id}`).then((r) => setReport(r.data)).catch(() => {});
}, [id]);
const addItem = () => setItems((prev) => [...prev, { itemName: '', resultValue: '', unit: '', referenceRange: '', isAbnormal: false }]);
const updateItem = (i: number, field: string, value: string | boolean) =>
setItems((prev) => prev.map((it, idx) => idx === i ? { ...it, [field]: value } : it));
const removeItem = (i: number) => { if (items.length > 1) setItems((prev) => prev.filter((_, idx) => idx !== i)); };
const handleInterpret = async () => {
if (!interpretation.trim() || !id) return;
if (!summary.trim() || !id) return;
setSubmitting(true);
try {
await api.post(`/api/reports/${id}/interpret`, {
summary: interpretation,
items: [],
riskLevel: 'normal',
suggestions: null,
summary, items: items.filter((it) => it.itemName.trim()), riskLevel,
suggestions: suggestions || null,
});
// Refetch report to show updated status
const updated = await api.get<RawReport>(`/api/reports/${id}`);
setReport(updated.data);
setInterpretation('');
alert('interpretation submitted');
} catch { alert('submit failed'); }
} catch { alert('提交失败'); }
finally { setSubmitting(false); }
};
if (!report) return <div style={{ padding: 24 }}>loading...</div>;
if (!report) return <div style={{ padding: 24 }}>...</div>;
const isCompleted = report.status === 'completed';
const riskMap: Record<string, { text: string; color: string }> = {
normal: { text: '正常', color: '#2e7d32' },
attention: { text: '关注', color: '#f57c00' },
abnormal: { text: '异常', color: '#c62828' },
};
return (
<div style={{ padding: 24 }}>
<Link to="/reports" style={{ fontSize: 13, color: '#1976d2' }}>&larr; back to reports</Link>
<Link to="/reports" style={{ fontSize: 13, color: '#1976d2' }}> </Link>
<div style={{ background: '#fff', marginTop: 16, padding: 24, borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}>
<h2>{report.title}</h2>
<div style={{ marginTop: 12, fontSize: 14, color: '#666' }}>
<div>Patient: {report.patientName || 'unknown'}</div>
<div>Category: {report.category}</div>
<div>Status: {report.status}</div>
<div>Submitted: {report.createdAt?.split('T')[0]}</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h2 style={{ margin: 0 }}>{report.title}</h2>
<div style={{ marginTop: 8, fontSize: 13, color: '#888' }}>
{report.patientName || '未知'} &nbsp;|&nbsp;
{categoryMap[report.category] || report.category} &nbsp;|&nbsp;
{report.uploadedAt?.split('T')[0]}
</div>
</div>
<span style={{
padding: '4px 12px', borderRadius: 12, fontSize: 12, fontWeight: 500,
background: isCompleted ? '#e8f5e9' : '#fff3e0',
color: isCompleted ? '#2e7d32' : '#f57c00',
}}>
{isCompleted ? '已完成' : '待审核'}
</span>
</div>
{/* 图片 */}
{report.imageUrls && report.imageUrls.length > 0 && (
<div style={{ marginTop: 16 }}>
<h4 style={{ fontSize: 14, marginBottom: 8 }}>Report Images</h4>
<div style={{ marginTop: 20 }}>
<h4 style={{ fontSize: 14, marginBottom: 8 }}>{report.imageUrls.length}</h4>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{report.imageUrls.map((url, i) => (
<div key={i} style={{ padding: '8px 12px', background: '#f5f5f5', borderRadius: 4, marginBottom: 4, fontSize: 12 }}>
{url}
<div key={i} onClick={() => setLightbox(url)} style={{
width: 120, height: 120, borderRadius: 8, overflow: 'hidden',
cursor: 'pointer', border: '2px solid #eee', background: '#f5f5f5',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<img src={`http://localhost:5000${url}`} alt={`图片${i}`}
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'cover' }}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
</div>
))}
</div>
</div>
)}
{report.status === 'completed' && (
<div style={{ marginTop: 16, padding: 16, background: '#e8f5e9', borderRadius: 8 }}>
<h4 style={{ fontSize: 14, marginBottom: 4 }}>Interpretation Result</h4>
<p style={{ fontSize: 13, color: '#555' }}>{report.summary || 'No summary'}</p>
{report.riskLevel && <p style={{ fontSize: 12, color: '#888' }}>Risk: {report.riskLevel}</p>}
{report.suggestions && <p style={{ fontSize: 12, color: '#888' }}>Suggestions: {report.suggestions}</p>}
{/* 灯箱 */}
{lightbox && (
<div onClick={() => setLightbox(null)} style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 999, cursor: 'pointer',
}}>
<img src={`http://localhost:5000${lightbox}`} alt="预览" style={{ maxWidth: '90vw', maxHeight: '90vh', borderRadius: 8 }} />
</div>
)}
{/* 已完成解读 */}
{isCompleted && (
<div style={{ marginTop: 20, padding: 16, background: '#e8f5e9', borderRadius: 8 }}>
<h4 style={{ fontSize: 14, marginBottom: 8 }}></h4>
<div style={{ fontSize: 13 }}>
<p><strong></strong>
<span style={{ color: riskMap[report.riskLevel || '']?.color, fontWeight: 600 }}>
{riskMap[report.riskLevel || '']?.text || report.riskLevel || '-'}
</span>
</p>
<p><strong></strong>{report.summary || '-'}</p>
{report.suggestions && <p><strong></strong>{report.suggestions}</p>}
</div>
{report.items && report.items.length > 0 && (
<table style={{ width: '100%', marginTop: 8, borderCollapse: 'collapse', fontSize: 12 }}>
<thead><tr style={{ textAlign: 'left', borderBottom: '1px solid #ddd' }}>
<th style={{ padding: 4 }}>Item</th><th style={{ padding: 4 }}>Value</th><th style={{ padding: 4 }}>Range</th><th style={{ padding: 4 }}>Abnormal</th>
<table style={{ width: '100%', marginTop: 12, borderCollapse: 'collapse', fontSize: 12 }}>
<thead><tr style={{ textAlign: 'left', borderBottom: '2px solid #c8e6c9' }}>
<th style={{ padding: '6px 8px' }}></th>
<th style={{ padding: '6px 8px' }}></th>
<th style={{ padding: '6px 8px' }}></th>
<th style={{ padding: '6px 8px' }}></th>
</tr></thead>
<tbody>
{report.items.map((item) => (
<tr key={item.id} style={{ borderBottom: '1px solid #f0f0f0' }}>
<td style={{ padding: 4 }}>{item.itemName}</td>
<td style={{ padding: 4 }}>{item.resultValue} {item.unit || ''}</td>
<td style={{ padding: 4 }}>{item.referenceRange || '-'}</td>
<td style={{ padding: 4, color: item.isAbnormal ? '#c62828' : '#2e7d32' }}>{item.isAbnormal ? 'Yes' : 'No'}</td>
<tr key={item.id} style={{ borderBottom: '1px solid #e8f5e9' }}>
<td style={{ padding: '6px 8px' }}>{item.itemName}</td>
<td style={{ padding: '6px 8px' }}>{item.resultValue} {item.unit || ''}</td>
<td style={{ padding: '6px 8px' }}>{item.referenceRange || '-'}</td>
<td style={{ padding: '6px 8px', color: item.isAbnormal ? '#c62828' : '#2e7d32', fontWeight: 500 }}>
{item.isAbnormal ? '是' : '否'}
</td>
</tr>
))}
</tbody>
@@ -93,18 +157,70 @@ export function ReportDetailPage() {
</div>
)}
{report.status !== 'completed' && (
<div style={{ marginTop: 20 }}>
<h4 style={{ fontSize: 14, marginBottom: 8 }}>Doctor Interpretation</h4>
<textarea value={interpretation} onChange={(e) => setInterpretation(e.target.value)}
placeholder="Enter your interpretation..."
rows={5}
style={{ width: '100%', padding: 12, border: '1px solid #ddd', borderRadius: 4, fontSize: 14, resize: 'vertical' }} />
{/* 解读表单 */}
{!isCompleted && (
<div style={{ marginTop: 24, borderTop: '1px solid #eee', paddingTop: 20 }}>
<h3 style={{ fontSize: 15, marginBottom: 16 }}></h3>
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 4 }}></label>
<textarea value={summary} onChange={(e) => setSummary(e.target.value)}
placeholder="请输入您的专业解读总结..."
rows={4}
style={{ width: '100%', padding: '10px 12px', border: '1px solid #ddd', borderRadius: 6, fontSize: 13, resize: 'vertical', fontFamily: 'inherit', boxSizing: 'border-box' }} />
</div>
<div style={{ display: 'flex', gap: 16, marginBottom: 12 }}>
<div style={{ flex: 1 }}>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 4 }}></label>
<select value={riskLevel} onChange={(e) => setRiskLevel(e.target.value)}
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 6, fontSize: 13, fontFamily: 'inherit' }}>
<option value="normal"></option>
<option value="attention"></option>
<option value="abnormal"></option>
</select>
</div>
<div style={{ flex: 1 }}>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 4 }}></label>
<input value={suggestions} onChange={(e) => setSuggestions(e.target.value)}
placeholder="如:继续当前用药方案"
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 6, fontSize: 13, fontFamily: 'inherit', boxSizing: 'border-box' }} />
</div>
</div>
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}></label>
{items.map((item, i) => (
<div key={i} style={{ display: 'flex', gap: 8, marginBottom: 6, alignItems: 'center' }}>
<input placeholder="项目名称" value={item.itemName} onChange={(e) => updateItem(i, 'itemName', e.target.value)}
style={{ flex: 2, padding: '6px 10px', border: '1px solid #ddd', borderRadius: 4, fontSize: 12, fontFamily: 'inherit' }} />
<input placeholder="结果" value={item.resultValue} onChange={(e) => updateItem(i, 'resultValue', e.target.value)}
style={{ flex: 1, padding: '6px 10px', border: '1px solid #ddd', borderRadius: 4, fontSize: 12, fontFamily: 'inherit' }} />
<input placeholder="单位" value={item.unit} onChange={(e) => updateItem(i, 'unit', e.target.value)}
style={{ width: 70, padding: '6px 10px', border: '1px solid #ddd', borderRadius: 4, fontSize: 12, fontFamily: 'inherit' }} />
<input placeholder="参考范围" value={item.referenceRange} onChange={(e) => updateItem(i, 'referenceRange', e.target.value)}
style={{ flex: 1, padding: '6px 10px', border: '1px solid #ddd', borderRadius: 4, fontSize: 12, fontFamily: 'inherit' }} />
<label style={{ fontSize: 12, whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: 3 }}>
<input type="checkbox" checked={item.isAbnormal} onChange={(e) => updateItem(i, 'isAbnormal', e.target.checked)} />
</label>
<button onClick={() => removeItem(i)}
style={{ background: 'none', border: 'none', color: '#c62828', cursor: 'pointer', fontSize: 16 }}
disabled={items.length <= 1}></button>
</div>
))}
<button onClick={addItem} style={{
padding: '4px 12px', border: '1px dashed #1976d2', borderRadius: 4,
background: 'none', color: '#1976d2', cursor: 'pointer', fontSize: 12,
}}>+ </button>
</div>
<button onClick={handleInterpret} disabled={submitting} style={{
marginTop: 8, padding: '10px 24px', background: '#1976d2', color: '#fff',
border: 'none', borderRadius: 4, fontSize: 14, opacity: submitting ? 0.7 : 1,
padding: '10px 28px', background: '#1976d2', color: '#fff',
border: 'none', borderRadius: 6, fontSize: 14, cursor: 'pointer',
opacity: submitting ? 0.7 : 1, marginTop: 8,
}}>
{submitting ? 'Submitting...' : 'Submit Interpretation'}
{submitting ? '提交中...' : '提交解读'}
</button>
</div>
)}

View File

@@ -2,80 +2,68 @@ import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../../services/api-client';
interface ReportItem {
id: string; patientId: string; patientName: string;
title: string; category: string; status: string; createdAt: string;
}
interface RawReportItem {
id: string; patientId: string; patientName?: string;
title: string; category: string; status: string; createdAt: string;
title: string; category: string; status: string; uploadedAt: string;
}
export function ReportListPage() {
const [reports, setReports] = useState<ReportItem[]>([]);
const [reports, setReports] = useState<RawReportItem[]>([]);
useEffect(() => {
api.get<RawReportItem[]>('/api/reports').then((r) => {
const mapped = r.data.map((rp) => ({
id: rp.id,
patientId: rp.patientId,
patientName: rp.patientName || 'unknown',
title: rp.title,
category: rp.category,
status: rp.status,
createdAt: rp.createdAt,
}));
setReports(mapped);
}).catch(() => {});
api.get<RawReportItem[]>('/api/reports').then((r) => setReports(r.data)).catch(() => {});
}, []);
const statusLabel = (s: string) => {
switch (s) {
case 'pending': return { text: '待审核', color: '#f57c00', bg: '#fff3e0' };
case 'completed': return { text: '已完成', color: '#2e7d32', bg: '#e8f5e9' };
default: return { text: s, color: '#666', bg: '#f5f5f5' };
case 'pending': return { text: '待审核', color: '#F59E0B', bg: '#FFF8E6' };
case 'completed': return { text: '已完成', color: '#20C997', bg: '#E6F9F2' };
default: return { text: s, color: '#9BA0B4', bg: '#F5F6F9' };
}
};
return (
<div style={{ padding: 24 }}>
<h2 style={{ marginBottom: 16 }}></h2>
<div style={{ padding: 28 }}>
<h2 style={{ marginBottom: 6, fontSize: 20, fontWeight: 700, color: '#1A1D28' }}></h2>
<p style={{ marginBottom: 18, fontSize: 13, color: '#9BA0B4' }}> {reports.length} </p>
<div style={{ background: '#fff', borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<div style={{ background: '#fff', borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)', overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '2px solid #f0f0f0', textAlign: 'left' }}>
<th style={{ padding: '12px 16px' }}></th>
<th style={{ padding: '12px 16px' }}></th>
<th style={{ padding: '12px 16px' }}></th>
<th style={{ padding: '12px 16px' }}></th>
<th style={{ padding: '12px 16px' }}></th>
<th style={{ padding: '12px 16px' }}></th>
<tr style={{ borderBottom: '2px solid #F0F2F5', textAlign: 'left', background: '#F9FAFC' }}>
<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>
</thead>
<tbody>
{reports.map((r) => {
const s = statusLabel(r.status);
return (
<tr key={r.id} style={{ borderBottom: '1px solid #f5f5f5' }}>
<td style={{ padding: '10px 16px' }}>{r.patientName}</td>
<td style={{ padding: '10px 16px' }}>{r.title}</td>
<td style={{ padding: '10px 16px', color: '#888' }}>{r.category}</td>
<td style={{ padding: '10px 16px' }}>
<span style={{ padding: '2px 8px', borderRadius: 10, fontSize: 11, background: s.bg, color: s.color }}>
<tr key={r.id} style={{ borderBottom: '1px solid #F5F6F9' }}>
<td style={{ padding: '12px 20px', fontWeight: 500 }}>{r.patientName || '未知'}</td>
<td style={{ padding: '12px 20px' }}>{r.title}</td>
<td style={{ padding: '12px 20px', color: '#9BA0B4' }}>{r.category}</td>
<td style={{ padding: '12px 20px' }}>
<span style={{ padding: '4px 12px', borderRadius: 10, fontSize: 11, fontWeight: 500, background: s.bg, color: s.color }}>
{s.text}
</span>
</td>
<td style={{ padding: '10px 16px', color: '#888' }}>{r.createdAt?.split('T')[0]}</td>
<td style={{ padding: '10px 16px' }}>
<Link to={`/reports/${r.id}`} style={{ color: '#1976d2', fontSize: 13 }}></Link>
<td style={{ padding: '12px 20px', color: '#9BA0B4' }}>{r.uploadedAt?.split('T')[0]}</td>
<td style={{ padding: '12px 20px' }}>
<Link to={`/reports/${r.id}`} style={{
color: '#4F6EF7', fontSize: 12, fontWeight: 600,
padding: '4px 12px', background: '#EDF0FD', borderRadius: 6,
}}></Link>
</td>
</tr>
);
})}
{reports.length === 0 && (
<tr><td colSpan={6} style={{ padding: 24, textAlign: 'center', color: '#999' }}></td></tr>
<tr><td colSpan={6} style={{ padding: 32, textAlign: 'center', color: '#9BA0B4' }}></td></tr>
)}
</tbody>
</table>

View File

@@ -24,10 +24,8 @@ export function ProfilePage() {
e.preventDefault();
try {
await api.put('/api/auth/me', {
name: form.name,
department: form.department,
title: form.title,
introduction: form.introduction,
name: form.name, department: form.department,
title: form.title, introduction: form.introduction,
});
updateProfile(form);
alert('保存成功');
@@ -36,44 +34,71 @@ export function ProfilePage() {
if (!user) return null;
const inputStyle: React.CSSProperties = {
width: '100%', padding: '10px 14px', border: '1.5px solid #E1E5ED',
borderRadius: 10, fontSize: 13, outline: 'none', boxSizing: 'border-box',
};
const labelStyle: React.CSSProperties = {
display: 'block', fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 5,
};
return (
<div style={{ padding: 24 }}>
<h2 style={{ marginBottom: 16 }}></h2>
<div style={{ padding: 28 }}>
<h2 style={{ marginBottom: 20, fontSize: 20, fontWeight: 700, color: '#1A1D28' }}></h2>
<form onSubmit={handleSubmit} style={{ background: '#fff', padding: 24, borderRadius: 8, maxWidth: 500 }}>
<div style={{ marginBottom: 14 }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}></label>
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })}
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4 }} />
<form onSubmit={handleSubmit} style={{ background: '#fff', padding: 28, borderRadius: 16, maxWidth: 520, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 24, paddingBottom: 20, borderBottom: '1px solid #F0F2F5' }}>
<div style={{
width: 56, height: 56, borderRadius: 18,
background: 'linear-gradient(135deg, #4F6EF7, #6C8AFF)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 22, fontWeight: 700, color: '#fff',
}}>
{user.name?.charAt(0) || 'D'}
</div>
<div>
<div style={{ fontSize: 16, fontWeight: 700, color: '#1A1D28' }}>{user.name}</div>
<div style={{ fontSize: 12, color: '#9BA0B4', marginTop: 2 }}>{user.department} · {user.title}</div>
</div>
</div>
<div style={{ marginBottom: 14 }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}></label>
<div style={{ marginBottom: 16 }}>
<label style={labelStyle}></label>
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} style={inputStyle}
onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
</div>
<div style={{ marginBottom: 16 }}>
<label style={labelStyle}></label>
<input value={form.phone} disabled
style={{ width: '100%', padding: '8px 12px', border: '1px solid #eee', borderRadius: 4, background: '#f9f9f9' }} />
style={{ ...inputStyle, background: '#F5F7FB', color: '#9BA0B4', border: '1.5px solid #EEF0F5' }} />
</div>
<div style={{ marginBottom: 14 }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}></label>
<input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })}
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4 }} />
<div style={{ marginBottom: 16 }}>
<label style={labelStyle}></label>
<input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} style={inputStyle}
onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
</div>
<div style={{ marginBottom: 14 }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}></label>
<input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })}
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4 }} />
<div style={{ marginBottom: 16 }}>
<label style={labelStyle}></label>
<input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} style={inputStyle}
onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
</div>
<div style={{ marginBottom: 18 }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}></label>
<div style={{ marginBottom: 20 }}>
<label style={labelStyle}></label>
<textarea value={form.introduction} onChange={(e) => setForm({ ...form, introduction: e.target.value })} rows={4}
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4, resize: 'vertical' }} />
style={{ ...inputStyle, resize: 'vertical', fontFamily: 'inherit' }} />
</div>
<button type="submit" style={{
padding: '10px 24px', background: '#1976d2', color: '#fff',
border: 'none', borderRadius: 4, fontSize: 14,
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)',
}}>
</button>

View File

@@ -11,6 +11,8 @@ import { ReportListPage } from '../pages/reports/ReportListPage';
import { ReportDetailPage } from '../pages/reports/ReportDetailPage';
import { FollowUpListPage } from '../pages/followups/FollowUpListPage';
import { FollowUpEditPage } from '../pages/followups/FollowUpEditPage';
import { VisitListPage } from '../pages/followups/VisitListPage';
import { VisitEditPage } from '../pages/followups/VisitEditPage';
import { ProfilePage } from '../pages/settings/ProfilePage';
export const router = createBrowserRouter([
@@ -35,6 +37,8 @@ export const router = createBrowserRouter([
{ path: 'reports/:id', element: <ReportDetailPage /> },
{ path: 'follow-ups', element: <FollowUpListPage /> },
{ path: 'follow-ups/:id/edit', element: <FollowUpEditPage /> },
{ path: 'visits', element: <VisitListPage /> },
{ path: 'visits/:id/edit', element: <VisitEditPage /> },
{ path: 'profile', element: <ProfilePage /> },
],
},

View File

@@ -4,4 +4,7 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 5174,
},
})

View File

@@ -1,13 +1,14 @@
{
"name": "haruite-medical-demo",
"name": "health-manager-demo",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "haruite-medical-demo",
"name": "health-manager-demo",
"version": "0.0.0",
"dependencies": {
"@microsoft/signalr": "^10.0.0",
"dayjs": "^1.11.20",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
@@ -550,6 +551,19 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@microsoft/signalr": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.0.tgz",
"integrity": "sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"eventsource": "^2.0.2",
"fetch-cookie": "^2.0.3",
"node-fetch": "^2.6.7",
"ws": "^7.5.10"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
@@ -1192,6 +1206,18 @@
}
}
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -1648,6 +1674,24 @@
"node": ">=0.10.0"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/eventsource": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz",
"integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -1686,6 +1730,16 @@
}
}
},
"node_modules/fetch-cookie": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz",
"integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==",
"license": "Unlicense",
"dependencies": {
"set-cookie-parser": "^2.4.8",
"tough-cookie": "^4.0.0"
}
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -2323,6 +2377,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-releases": {
"version": "2.0.44",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz",
@@ -2459,16 +2533,33 @@
"node": ">= 0.8.0"
}
},
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"funding": {
"url": "https://github.com/sponsors/lupomontero"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"license": "MIT"
},
"node_modules/react": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
@@ -2528,6 +2619,12 @@
"react-dom": ">=18"
}
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"license": "MIT"
},
"node_modules/rolldown": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
@@ -2640,6 +2737,27 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tough-cookie": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
"license": "BSD-3-Clause",
"dependencies": {
"psl": "^1.1.33",
"punycode": "^2.1.1",
"universalify": "^0.2.0",
"url-parse": "^1.5.3"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -2717,6 +2835,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -2758,6 +2885,16 @@
"punycode": "^2.1.0"
}
},
"node_modules/url-parse": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"license": "MIT",
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
}
},
"node_modules/vite": {
"version": "8.0.13",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
@@ -2836,6 +2973,22 @@
}
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -2862,6 +3015,27 @@
"node": ">=0.10.0"
}
},
"node_modules/ws": {
"version": "7.5.10",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
"license": "MIT",
"engines": {
"node": ">=8.3.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@microsoft/signalr": "^10.0.0",
"dayjs": "^1.11.20",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",

View File

@@ -36,7 +36,51 @@
white-space: nowrap;
}
/* Section Title */
.section-title {
font-size: var(--font-size-md);
font-weight: 700;
color: var(--color-text-primary);
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.section-title::before {
content: '';
width: 4px;
height: 18px;
border-radius: 2px;
background: var(--color-primary);
}
/* Tag */
.tag {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
}
.tag-success { background: var(--color-success-bg); color: #0D8A5E; }
.tag-warning { background: var(--color-warning-bg); color: #D67E0B; }
.tag-danger { background: var(--color-danger-bg); color: #D53131; }
.tag-info { background: var(--color-primary-bg); color: var(--color-primary); }
.tag-primary { background: var(--color-primary-bg); color: var(--color-primary); }
/* Transitions */
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.06); }
}
.page-enter {
animation: slideInRight 0.3s ease-out;
}

View File

@@ -1,35 +1,39 @@
:root {
/* Primary - Medical Blue */
--color-primary: #1E6BFF;
--color-primary-light: #4D8FFF;
--color-primary-dark: #1055E0;
--color-primary-bg: #EBF3FF;
--color-primary-gradient: linear-gradient(135deg, #1E6BFF, #4D8FFF);
/* Primary - Modern Indigo Blue */
--color-primary: #4F6EF7;
--color-primary-light: #6C8CFF;
--color-primary-dark: #3D56D4;
--color-primary-bg: #EEF1FE;
--color-primary-gradient: linear-gradient(135deg, #4F6EF7, #6C8CFF);
/* Accent */
--color-accent-red: #FF6B6B;
--color-accent-orange: #FFA94D;
--color-accent-green: #20C997;
--color-accent-purple: #845EF7;
--color-accent-sky: #339AF0;
--color-accent-pink: #F06595;
/* Status */
--color-success: #10B981;
--color-success-bg: #ECFDF5;
--color-success: #20C997;
--color-success-bg: #E6F9F2;
--color-warning: #F59E0B;
--color-warning-bg: #FFFBEB;
--color-danger: #EF4444;
--color-danger-bg: #FEF2F2;
/* Risk */
--color-risk-normal: #10B981;
--color-risk-attention: #F59E0B;
--color-risk-abnormal: #EF4444;
--color-warning-bg: #FFF4E5;
--color-danger: #FF6B6B;
--color-danger-bg: #FEE9E9;
/* Neutral */
--color-white: #FFFFFF;
--color-bg: #F2F5FA;
--color-bg: #F0F4F8;
--color-bg-secondary: #E8ECF2;
--color-border: #E2E8F0;
--color-border-light: #F0F2F5;
--color-border: #E4E8EE;
--color-border-light: #EEF1F6;
--color-divider: #EDF0F5;
/* Text */
--color-text-primary: #1A1D28;
--color-text-secondary: #6B7280;
--color-text-tertiary: #9CA3AF;
--color-text-secondary: #5A5F72;
--color-text-tertiary: #9BA0B4;
--color-text-inverse: #FFFFFF;
/* Spacing */
@@ -42,20 +46,21 @@
--spacing-3xl: 32px;
/* Border radius */
--radius-sm: 8px;
--radius-md: 12px;
--radius-sm: 10px;
--radius-md: 14px;
--radius-lg: 16px;
--radius-xl: 20px;
--radius-2xl: 24px;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04);
--shadow-md: 0 2px 12px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 4px 24px rgba(0, 0, 0, 0.08);
--shadow-xs: 0 1px 3px rgba(0,0,0,0.03);
--shadow-sm: 0 2px 12px rgba(0,0,0,0.04);
--shadow-md: 0 4px 20px rgba(0,0,0,0.06);
--shadow-lg: 0 8px 30px rgba(0,0,0,0.08);
/* Font */
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
--font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
--font-size-xs: 11px;
--font-size-sm: 12px;
--font-size-base: 14px;
@@ -66,8 +71,8 @@
--font-size-3xl: 32px;
/* Layout */
--tab-bar-height: 56px;
--header-height: 48px;
--tab-bar-height: 64px;
--header-height: 50px;
--max-content-width: 414px;
/* Z-index */

View File

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

View File

@@ -2,14 +2,14 @@
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 10px;
background: var(--color-danger);
background: var(--color-accent-red);
color: white;
font-size: 10px;
font-weight: 600;
font-weight: 700;
line-height: 1;
}
@@ -18,5 +18,5 @@
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-danger);
background: var(--color-accent-red);
}

View File

@@ -4,7 +4,7 @@
justify-content: center;
gap: var(--spacing-sm);
border-radius: var(--radius-md);
font-weight: 500;
font-weight: 600;
transition: all 0.2s;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
@@ -21,11 +21,13 @@
.lg { padding: 12px 24px; font-size: var(--font-size-md); }
.primary {
background: var(--color-primary);
background: var(--color-primary-gradient);
color: var(--color-text-inverse);
box-shadow: 0 4px 14px rgba(79,110,247,0.3);
}
.primary:hover:not(:disabled) {
background: var(--color-primary-dark);
box-shadow: 0 6px 20px rgba(79,110,247,0.35);
transform: translateY(-1px);
}
.secondary {
@@ -48,24 +50,4 @@
.text {
background: transparent;
color: var(--color-primary);
padding-left: 4px;
padding-right: 4px;
}
.text:hover:not(:disabled) {
opacity: 0.8;
}
.fullWidth { width: 100%; }
.spinner {
width: 16px;
height: 16px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}

View File

@@ -1,17 +1,17 @@
.card {
background: var(--color-white);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
border-radius: var(--radius-xl);
padding: 18px;
box-shadow: var(--shadow-sm);
transition: transform 0.2s, box-shadow 0.2s;
}
.clickable {
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: transform 0.15s, box-shadow 0.15s;
}
.clickable:active {
transform: scale(0.98);
transform: scale(0.985);
box-shadow: var(--shadow-md);
}

View File

@@ -7,11 +7,24 @@
}
.icon {
font-size: 48px;
margin-bottom: 12px;
width: 64px;
height: 64px;
border-radius: 20px;
background: var(--color-bg-secondary);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 14px;
}
.icon svg {
width: 32px;
height: 32px;
stroke: var(--color-text-tertiary);
}
.message {
font-size: var(--font-size-sm);
color: var(--color-text-tertiary);
font-weight: 500;
}

View File

@@ -1,14 +1,23 @@
import styles from './Empty.module.css';
interface EmptyProps {
icon?: string;
icon?: React.ReactNode;
message?: string;
}
export function Empty({ icon = '📭', message = '暂无数据' }: EmptyProps) {
const DEFAULT_ICON = (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
);
export function Empty({ icon = DEFAULT_ICON, message = '暂无数据' }: EmptyProps) {
return (
<div className={styles.empty}>
<span className={styles.icon}>{icon}</span>
<div className={styles.icon}>{icon}</div>
<p className={styles.message}>{message}</p>
</div>
);

View File

@@ -7,7 +7,7 @@
.label {
font-size: var(--font-size-sm);
font-weight: 500;
font-weight: 600;
color: var(--color-text-secondary);
}
@@ -16,15 +16,16 @@
padding: 10px 14px;
background: var(--color-bg);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
border-radius: var(--radius-sm);
font-size: var(--font-size-base);
color: var(--color-text-primary);
transition: border-color 0.2s;
transition: border-color 0.2s, box-shadow 0.2s;
}
.input:focus {
border-color: var(--color-primary);
background: var(--color-white);
box-shadow: 0 0 0 3px rgba(79,110,247,0.1);
}
.input::placeholder {

View File

@@ -19,6 +19,7 @@
min-width: 160px;
text-align: center;
box-shadow: var(--shadow-lg);
font-weight: 500;
}
.success { background: var(--color-success); }

View File

@@ -1,5 +1,6 @@
.layout {
min-height: 100vh;
background: var(--color-bg);
}
.main {

View File

@@ -1,12 +1,24 @@
import { Outlet } from 'react-router-dom';
import { Outlet, useLocation } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { TabBar } from './TabBar';
import styles from './AppLayout.module.css';
export function AppLayout() {
const location = useLocation();
return (
<div className={styles.layout}>
<main className={styles.main}>
<AnimatePresence>
<motion.div
key={location.pathname}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.12 }}
>
<Outlet />
</motion.div>
</AnimatePresence>
</main>
<TabBar />
</div>

View File

@@ -7,7 +7,7 @@
max-width: var(--max-content-width);
height: var(--header-height);
background: var(--color-white);
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-divider);
display: flex;
align-items: center;
justify-content: space-between;
@@ -33,13 +33,13 @@
width: 36px;
height: 36px;
margin-left: -8px;
border-radius: var(--radius-full);
border-radius: var(--radius-sm);
color: var(--color-text-primary);
-webkit-tap-highlight-color: transparent;
}
.title {
font-size: var(--font-size-md);
font-size: 17px;
font-weight: 600;
text-align: center;
flex: 1;

View File

@@ -1,5 +1,20 @@
import { Outlet } from 'react-router-dom';
import { Outlet, useLocation } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion';
export function StackLayout() {
return <Outlet />;
const location = useLocation();
return (
<AnimatePresence>
<motion.div
key={location.pathname}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<Outlet />
</motion.div>
</AnimatePresence>
);
}

View File

@@ -7,11 +7,12 @@
max-width: var(--max-content-width);
height: var(--tab-bar-height);
background: var(--color-white);
border-top: 1px solid var(--color-border);
border-top: 1px solid var(--color-divider);
display: flex;
align-items: center;
justify-content: space-around;
z-index: var(--z-tab-bar);
padding: 0 8px;
padding-bottom: env(safe-area-inset-bottom);
}
@@ -21,9 +22,8 @@
align-items: center;
justify-content: center;
gap: 2px;
padding: var(--spacing-xs) var(--spacing-md);
padding: 6px 0;
min-width: 56px;
min-height: 44px;
color: var(--color-text-tertiary);
transition: color 0.2s;
-webkit-tap-highlight-color: transparent;
@@ -34,11 +34,57 @@
}
.tabIcon {
font-size: 22px;
line-height: 1;
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
transition: all 0.25s;
}
.tabActive .tabIcon {
background: var(--color-primary-bg);
transform: translateY(-6px);
box-shadow: 0 4px 12px rgba(79,110,247,0.25);
}
.tabActive .tabIcon::after {
content: '';
position: absolute;
bottom: -2px;
width: 20px;
height: 3px;
border-radius: 3px;
background: var(--color-primary);
}
.tabLabel {
font-size: var(--font-size-xs);
font-size: 10px;
font-weight: 500;
transition: color 0.2s;
}
.tabActive .tabLabel {
font-weight: 600;
}
.badge {
position: absolute;
top: -4px;
right: -6px;
min-width: 18px;
height: 18px;
padding: 0 5px;
background: var(--color-accent-red);
color: #fff;
border-radius: 10px;
font-size: 10px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
border: 2px solid #fff;
}

View File

@@ -1,7 +1,48 @@
import { useNavigate, useLocation } from 'react-router-dom';
import { NAV_ITEMS } from '@/utils/constants';
import styles from './TabBar.module.css';
const NAV_ITEMS = [
{
path: '/home',
label: '首页',
svg: (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
),
},
{
path: '/health',
label: '健康',
svg: (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
),
},
{
path: '/services',
label: '服务',
svg: (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
),
},
{
path: '/profile',
label: '我的',
svg: (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
),
},
];
export function TabBar() {
const navigate = useNavigate();
const location = useLocation();
@@ -16,7 +57,7 @@ export function TabBar() {
className={`${styles.tab} ${isActive ? styles.tabActive : ''}`}
onClick={() => navigate(item.path)}
>
<span className={styles.tabIcon}>{item.icon}</span>
<span className={styles.tabIcon}>{item.svg}</span>
<span className={styles.tabLabel}>{item.label}</span>
</button>
);

View File

@@ -48,7 +48,11 @@ export function LoginPage() {
return (
<div className={styles.page}>
<div className={styles.header}>
<div className={styles.logo}></div>
<div className={styles.logo}>
<svg width="36" height="36" viewBox="0 0 24 24" fill="var(--color-primary)" stroke="none">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
</div>
<h1 className={styles.title}></h1>
<p className={styles.subtitle}></p>
</div>

View File

@@ -1,22 +1,339 @@
.tabs { display: flex; gap: 8px; margin-bottom: 16px; }
.tab { padding: 6px 14px; border-radius: var(--radius-full); font-size: var(--font-size-sm); background: var(--color-bg-secondary); color: var(--color-text-secondary); }
.tabActive { background: var(--color-primary); color: var(--color-text-inverse); }
.sectionTitle { font-size: var(--font-size-base); font-weight: 600; margin: 16px 0 8px; }
.recCard { margin-bottom: 8px; }
.recHeader { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; font-weight: 600; font-size: var(--font-size-sm); }
.suitBadge { font-size: var(--font-size-xs); padding: 2px 8px; border-radius: var(--radius-sm); }
.suitYes { background: var(--color-success-bg); color: var(--color-success); }
.suitNo { background: var(--color-danger-bg); color: var(--color-danger); }
.notSuitable { opacity: 0.5; }
.recMeta { font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
.recDesc { font-size: var(--font-size-xs); color: var(--color-text-secondary); margin: 6px 0; }
.foodTags { display: flex; gap: 6px; flex-wrap: wrap; }
.foodTag { padding: 2px 8px; font-size: var(--font-size-xs); background: var(--color-primary-bg); color: var(--color-primary); border-radius: var(--radius-sm); }
.addCard { margin-bottom: 12px; display: flex; flex-direction: column; gap: 10px; }
.addRow { display: flex; gap: 8px; align-items: center; }
.select { padding: 10px 12px; border: 1.5px solid var(--color-border); border-radius: var(--radius-md); font-size: var(--font-size-sm); background: var(--color-bg); outline: none; }
.intensityRow { display: flex; gap: 8px; }
.intensityBtn { flex: 1; padding: 6px; font-size: var(--font-size-xs); background: var(--color-bg); border-radius: var(--radius-md); }
.intensityActive { background: var(--color-primary-bg); color: var(--color-primary); }
.logCard { margin-bottom: 6px; }
.logDate { font-size: var(--font-size-xs); color: var(--color-text-tertiary); margin-top: 2px; }
/* Tabs */
.tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.tab {
flex: 1;
padding: 12px;
border-radius: 14px;
background: #F5F6F9;
border: none;
font-size: 15px;
font-weight: 600;
color: #6B7280;
cursor: pointer;
transition: all 0.25s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.tabIcon { font-size: 18px; }
.tabActive {
background: linear-gradient(135deg, #4F6EF7, #6C8AFF);
color: #fff;
box-shadow: 0 4px 16px rgba(79,110,247,0.3);
transform: scale(1.02);
}
/* Summary cards */
.summaryCard,
.summaryCardDiet {
position: relative;
border-radius: 20px;
padding: 24px;
margin-bottom: 16px;
overflow: hidden;
background: linear-gradient(135deg, #f0f4ff 0%, #fff 60%);
box-shadow: 0 2px 16px rgba(0,0,0,0.04);
}
.summaryCardDiet {
background: linear-gradient(135deg, #f0faf6 0%, #fff 60%);
}
.summaryBg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.summaryContent {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
}
.summaryLabel { font-size: 13px; color: #6B7280; margin-bottom: 4px; }
.summaryValue { font-size: 32px; font-weight: 800; color: #1A1D28; }
.summaryUnit { font-size: 14px; font-weight: 500; color: #9BA0B4; }
.summaryHint { font-size: 12px; color: #9BA0B4; margin-top: 4px; }
.summaryRight {
animation: pulse 3s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
/* Add card */
.addCard {
padding: 16px;
margin-bottom: 20px;
border-radius: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.addGrid {
display: grid;
grid-template-columns: 1fr 100px;
gap: 10px;
}
.addGrid3 {
display: grid;
grid-template-columns: 1fr 80px 100px;
gap: 10px;
}
.select {
padding: 10px 14px;
border: 1.5px solid #E1E5ED;
border-radius: 10px;
font-size: 14px;
background: #fff;
color: #1A1D28;
outline: none;
cursor: pointer;
}
.select:focus { border-color: #4F6EF7; }
.intensityRow {
display: flex;
gap: 8px;
}
.intensityBtn {
flex: 1;
padding: 10px 0;
border: 1.5px solid #E1E5ED;
border-radius: 10px;
background: #fff;
font-size: 13px;
font-weight: 500;
color: #6B7280;
cursor: pointer;
transition: all 0.2s;
}
.intensityActive {
border-color: #4F6EF7;
background: #EEF2FF;
color: #4F6EF7;
font-weight: 600;
}
.mealTabRow {
display: flex;
gap: 6px;
}
.mealTab {
flex: 1;
padding: 10px 4px;
border: 1.5px solid #E1E5ED;
border-radius: 10px;
background: #fff;
font-size: 12px;
font-weight: 500;
color: #6B7280;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.mealTabActive {
border-color: #20C997;
background: #E6F9F2;
color: #20C997;
font-weight: 600;
}
/* Day groups */
.dayGroup {
margin-bottom: 16px;
}
.dayLabel {
font-size: 12px;
font-weight: 600;
color: #9BA0B4;
padding: 0 4px 8px;
letter-spacing: 0.3px;
}
/* Log cards */
.logCard {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: #fff;
border-radius: 14px;
margin-bottom: 8px;
box-shadow: 0 1px 8px rgba(0,0,0,0.03);
border-left: 4px solid #E1E5ED;
transition: transform 0.15s, box-shadow 0.15s;
animation: fadeInUp 0.3s ease both;
}
.logCard:active { transform: scale(0.98); }
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.intensityLow { border-left-color: #20C997; }
.intensityModerate { border-left-color: #F59E0B; }
.intensityHigh { border-left-color: #EF4444; }
.mealBreakfast { border-left-color: #F59E0B; }
.mealLunch { border-left-color: #4F6EF7; }
.mealDinner { border-left-color: #845EF7; }
.mealSnack { border-left-color: #20C997; }
.logIcon { font-size: 28px; flex-shrink: 0; }
.logInfo { flex: 1; min-width: 0; }
.logTitle { font-size: 15px; font-weight: 600; color: #1A1D28; }
.logMeta { font-size: 12px; color: #9BA0B4; margin-top: 2px; }
.intensityTag {
font-size: 11px;
padding: 3px 10px;
border-radius: 8px;
font-weight: 600;
background: #F5F6F9;
color: #6B7280;
flex-shrink: 0;
}
.delBtn {
width: 24px;
height: 24px;
border: none;
background: transparent;
color: #C0C5D2;
font-size: 18px;
cursor: pointer;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.15s;
margin-left: 4px;
}
.delBtn:hover {
color: #EF4444;
background: #FEE9E9;
}
.delBtn:active {
transform: scale(0.9);
}
/* Section titles */
.sectionTitle {
font-size: 16px;
font-weight: 700;
color: #1A1D28;
margin: 24px 0 12px;
}
/* Recommendation cards grid */
.recGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 24px;
}
.recCard {
position: relative;
padding: 16px;
background: #fff;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0,0,0,0.04);
text-align: center;
transition: transform 0.2s;
}
.recCard:hover { transform: translateY(-2px); }
.recNo { opacity: 0.5; }
.recEmoji { font-size: 32px; margin-bottom: 6px; }
.recName { font-size: 14px; font-weight: 600; color: #1A1D28; margin-bottom: 2px; }
.recMeta { font-size: 11px; color: #9BA0B4; margin-bottom: 8px; }
.recBadge {
display: inline-block;
font-size: 11px;
padding: 3px 12px;
border-radius: 10px;
font-weight: 600;
}
.recGood { background: #E6F9F2; color: #20C997; }
.recBad { background: #FEE9E9; color: #EF4444; }
/* Diet recommendation cards */
.dietRecCard {
padding: 16px;
background: #fff;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0,0,0,0.04);
transition: transform 0.2s;
}
.dietRecCard:hover { transform: translateY(-2px); }
.dietRecHeader {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.dietRecIcon { font-size: 16px; }
.dietRecTitle { font-size: 14px; font-weight: 600; color: #1A1D28; }
.dietRecDesc {
font-size: 12px;
color: #6B7280;
line-height: 1.5;
margin: 0 0 10px;
}
.dietRecFoods { display: flex; flex-wrap: wrap; gap: 6px; }
.dietFoodTag {
font-size: 11px;
padding: 4px 10px;
border-radius: 8px;
background: #E6F9F2;
color: #20C997;
font-weight: 500;
}
/* Responsive */
@media (max-width: 360px) {
.recGrid { grid-template-columns: 1fr; }
.addGrid3 { grid-template-columns: 1fr 1fr; }
.addGrid { grid-template-columns: 1fr 80px; }
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useMemo } from 'react';
import { PageHeader } from '@/components/layout/PageHeader';
import { Card } from '@/components/common/Card';
import { Button } from '@/components/common/Button';
@@ -10,144 +10,280 @@ import type { ExerciseRecord, DietRecord } from '@/types';
import { formatDate } from '@/utils/format';
import styles from './ExerciseDietPage.module.css';
const EXERCISE_TYPES = ['散步', '慢跑', '太极拳', '游泳', '骑自行车', '八段锦', '瑜伽', '广场舞'];
const INTENSITIES = [
{ key: 'low' as const, label: '低强度', emoji: '🟢' },
{ key: 'moderate' as const, label: '中强度', emoji: '🟡' },
{ key: 'high' as const, label: '高强度', emoji: '🔴' },
];
const MEAL_TYPES = [
{ key: 'breakfast' as const, label: '早餐', icon: '🌅' },
{ key: 'lunch' as const, label: '午餐', icon: '☀️' },
{ key: 'dinner' as const, label: '晚餐', icon: '🌙' },
{ key: 'snack' as const, label: '加餐', icon: '🍎' },
];
function groupByDate<T extends { date: string }>(items: T[]): [string, T[]][] {
const map = new Map<string, T[]>();
items.forEach((item) => {
const list = map.get(item.date) || [];
list.push(item);
map.set(item.date, list);
});
return [...map.entries()].sort((a, b) => b[0].localeCompare(a[0]));
}
export function ExerciseDietPage() {
const [subTab, setSubTab] = useState<'recommend' | 'exercise' | 'diet'>('recommend');
const [tab, setTab] = useState<'exercise' | 'diet'>('exercise');
const [exercises, setExercises] = useState<ExerciseRecord[]>([]);
const [diets, setDiets] = useState<DietRecord[]>([]);
// exercise form
const [exType, setExType] = useState('散步');
const [exDuration, setExDuration] = useState('30');
const [exIntensity, setExIntensity] = useState<'low' | 'moderate' | 'high'>('low');
// diet form
const [mealType, setMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast');
const [foodName, setFoodName] = useState('');
const [foodAmount, setFoodAmount] = useState('1份');
const [foodKcal, setFoodKcal] = useState('');
const recommendations = exerciseDietService.getExerciseRecommendations();
const exRecommendations = exerciseDietService.getExerciseRecommendations();
const dietRecommendations = exerciseDietService.getDietRecommendations();
const groupedExercises = useMemo(() => groupByDate(exercises).slice(0, 7), [exercises]);
const groupedDiets = useMemo(() => groupByDate(diets).slice(0, 7), [diets]);
useEffect(() => {
exerciseDietService.getExerciseLogs().then(setExercises);
exerciseDietService.getDietLogs().then(setDiets);
}, []);
const deleteExercise = async (id: string) => {
await exerciseDietService.deleteExerciseLog(id);
toast('已删除');
exerciseDietService.getExerciseLogs().then(setExercises);
};
const deleteDiet = async (id: string) => {
await exerciseDietService.deleteDietLog(id);
toast('已删除');
exerciseDietService.getDietLogs().then(setDiets);
};
const addExercise = async () => {
if (!exDuration) return;
await exerciseDietService.addExerciseLog({
type: exType, duration: parseInt(exDuration), intensity: exIntensity,
caloriesBurned: parseInt(exDuration) * 4, date: new Date().toISOString().slice(0, 10),
caloriesBurned: parseInt(exDuration) * ({ low: 4, moderate: 7, high: 11 }[exIntensity] || 4),
date: new Date().toISOString().slice(0, 10),
});
toast('记录成功');
toast('运动记录成功');
exerciseDietService.getExerciseLogs().then(setExercises);
};
const addDiet = async () => {
if (!foodName || !foodKcal) { toast('请填写食物信息', 'error'); return; }
if (!foodName || !foodKcal) { toast('请填写食物名称和热量', 'error'); return; }
await exerciseDietService.addDietLog({
mealType, foods: [{ name: foodName, amount: '1份', calories: parseInt(foodKcal) }],
mealType, foods: [{ name: foodName, amount: foodAmount, calories: parseInt(foodKcal) }],
totalCalories: parseInt(foodKcal), date: new Date().toISOString().slice(0, 10),
});
toast('记录成功');
setFoodName(''); setFoodAmount('1份'); setFoodKcal('');
toast('饮食记录成功');
exerciseDietService.getDietLogs().then(setDiets);
};
const todayExKcal = exercises.filter(e => e.date === new Date().toISOString().slice(0, 10)).reduce((s, e) => s + (e.caloriesBurned || 0), 0);
const todayDietKcal = diets.filter(d => d.date === new Date().toISOString().slice(0, 10)).reduce((s, d) => s + (d.totalCalories || 0), 0);
return (
<div className="page--no-tab">
<PageHeader title="运动饮食" />
{/* Tab switch */}
<div className={styles.tabs}>
{[
{ key: 'recommend', label: '推荐' },
{ key: 'exercise', label: '运动' },
{ key: 'diet', label: '饮食' },
].map((t) => (
<button key={t.key} className={`${styles.tab} ${subTab === t.key ? styles.tabActive : ''}`} onClick={() => setSubTab(t.key as typeof subTab)}>
{t.label}
<button className={`${styles.tab} ${tab === 'exercise' ? styles.tabActive : ''}`} onClick={() => setTab('exercise')}>
<span className={styles.tabIcon}>🏃</span>
</button>
<button className={`${styles.tab} ${tab === 'diet' ? styles.tabActive : ''}`} onClick={() => setTab('diet')}>
<span className={styles.tabIcon}>🥗</span>
</button>
))}
</div>
{subTab === 'recommend' && (
<div>
<h3 className={styles.sectionTitle}></h3>
{recommendations.map((r, i) => (
<Card key={i} className={`${styles.recCard} ${!r.suitable ? styles.notSuitable : ''}`}>
<div className={styles.recHeader}>
<span>{r.icon} {r.name}</span>
<span className={`${styles.suitBadge} ${r.suitable ? styles.suitYes : styles.suitNo}`}>
{r.suitable ? '适合' : '不适合'}
</span>
{/* ============ EXERCISE TAB ============ */}
{tab === 'exercise' && (
<>
{/* Today summary card */}
<div className={styles.summaryCard}>
<svg className={styles.summaryBg} viewBox="0 0 400 120" preserveAspectRatio="none">
<ellipse cx="350" cy="10" rx="180" ry="140" fill="rgba(79,110,247,0.06)" />
<ellipse cx="50" cy="100" rx="120" ry="60" fill="rgba(79,110,247,0.04)" />
</svg>
<div className={styles.summaryContent}>
<div className={styles.summaryLeft}>
<div className={styles.summaryLabel}></div>
<div className={styles.summaryValue}>{todayExKcal} <span className={styles.summaryUnit}>kcal</span></div>
<div className={styles.summaryHint}> {Math.round(todayExKcal / 8)} </div>
</div>
<div className={styles.summaryRight}>
<svg width="64" height="64" viewBox="0 0 64 64" fill="none">
<circle cx="32" cy="32" r="28" stroke="#E8ECF4" strokeWidth="6" />
<circle cx="32" cy="32" r="28" stroke="url(#exGrad)" strokeWidth="6" strokeLinecap="round"
strokeDasharray={`${Math.min(todayExKcal / 3, 175)} 176`} transform="rotate(-90 32 32)" />
<defs><linearGradient id="exGrad"><stop stopColor="#4F6EF7"/><stop offset="1" stopColor="#6C8AFF"/></linearGradient></defs>
</svg>
</div>
<div className={styles.recMeta}>{r.duration} · {r.frequency} · {r.intensity}</div>
</Card>
))}
<h3 className={styles.sectionTitle}></h3>
{dietRecommendations.slice(0, 3).map((d, i) => (
<Card key={i} className={styles.recCard}>
<div className={styles.recHeader}><span>🍽 {d.title}</span></div>
<p className={styles.recDesc}>{d.description}</p>
<div className={styles.foodTags}>
{d.recommendedFoods.slice(0, 3).map((f, j) => (
<span key={j} className={styles.foodTag}>{f}</span>
))}
</div>
</Card>
))}
</div>
)}
{subTab === 'exercise' && (
<div>
{/* Add record */}
<Card className={styles.addCard}>
<div className={styles.addRow}>
<div className={styles.addGrid}>
<select className={styles.select} value={exType} onChange={(e) => setExType(e.target.value)}>
{['散步', '慢跑', '太极拳', '游泳', '骑自行车', '八段锦'].map((t) => (
<option key={t}>{t}</option>
))}
{EXERCISE_TYPES.map((t) => (<option key={t}>{t}</option>))}
</select>
<Input value={exDuration} onChange={(e) => setExDuration(e.target.value)} type="number" placeholder="分钟" />
</div>
<div className={styles.intensityRow}>
{['low', 'moderate', 'high'].map((i) => (
<button key={i} className={`${styles.intensityBtn} ${exIntensity === i ? styles.intensityActive : ''}`} onClick={() => setExIntensity(i as typeof exIntensity)}>
{{ low: '低强度', moderate: '中强度', high: '高强度' }[i]}
{INTENSITIES.map((i) => (
<button key={i.key} className={`${styles.intensityBtn} ${exIntensity === i.key ? styles.intensityActive : ''}`}
onClick={() => setExIntensity(i.key)}>
{i.emoji} {i.label}
</button>
))}
</div>
<Button size="sm" onClick={addExercise}></Button>
</Card>
{exercises.length === 0 ? <Empty message="暂无运动记录" /> : exercises.slice(0, 10).map((e) => (
<Card key={e.id} className={styles.logCard}>
<div>{e.type} · {e.duration} · {e.caloriesBurned}kcal</div>
<div className={styles.logDate}>{formatDate(e.date, 'MM-DD')}</div>
</Card>
{/* Recent records */}
{groupedExercises.length === 0 ? (
<Empty message="暂无运动记录,开始记录吧" />
) : (
groupedExercises.map(([date, items]) => (
<div key={date} className={styles.dayGroup}>
<div className={styles.dayLabel}>{formatDate(date, 'MM月DD日')} · {items.reduce((s, e) => s + (e.duration || 0), 0)}</div>
{items.map((e, i) => (
<div key={i} className={`${styles.logCard} ${styles[`intensity${e.intensity?.[0]?.toUpperCase()}${e.intensity?.slice(1)}`] || ''}`}>
<div className={styles.logIcon}>{e.type === '散步' ? '🚶' : e.type === '慢跑' ? '🏃' : e.type === '太极拳' ? '🤸' : e.type === '游泳' ? '🏊' : e.type === '骑自行车' ? '🚴' : e.type === '八段锦' ? '🧘' : '💪'}</div>
<div className={styles.logInfo}>
<div className={styles.logTitle}>{e.type}</div>
<div className={styles.logMeta}>{e.duration} · {e.caloriesBurned}kcal</div>
</div>
<span className={styles.intensityTag}>
{{ low: '低', moderate: '中', high: '高' }[e.intensity || 'low']}
</span>
<button className={styles.delBtn} onClick={() => deleteExercise(e.id)} title="删除">×</button>
</div>
))}
</div>
))
)}
{subTab === 'diet' && (
<div>
{/* Recommendations */}
<h3 className={styles.sectionTitle}></h3>
<div className={styles.recGrid}>
{exRecommendations.slice(0, 4).map((r, i) => (
<div key={i} className={`${styles.recCard} ${!r.suitable ? styles.recNo : ''}`}>
<div className={styles.recEmoji}>{r.name === '散步' ? '🚶' : r.name === '太极拳' ? '🤸' : r.name === '慢跑' ? '🏃' : r.name === '游泳' ? '🏊' : r.name === '骑自行车' ? '🚴' : r.name === '八段锦' ? '🧘' : '🏋️'}</div>
<div className={styles.recName}>{r.name}</div>
<div className={styles.recMeta}>{r.duration} · {r.frequency}</div>
<span className={`${styles.recBadge} ${r.suitable ? styles.recGood : styles.recBad}`}>
{r.suitable ? '适合' : '避免'}
</span>
</div>
))}
</div>
</>
)}
{/* ============ DIET TAB ============ */}
{tab === 'diet' && (
<>
{/* Today summary card */}
<div className={styles.summaryCardDiet}>
<svg className={styles.summaryBg} viewBox="0 0 400 120" preserveAspectRatio="none">
<ellipse cx="350" cy="10" rx="180" ry="140" fill="rgba(32,201,151,0.06)" />
<ellipse cx="50" cy="100" rx="120" ry="60" fill="rgba(32,201,151,0.04)" />
</svg>
<div className={styles.summaryContent}>
<div className={styles.summaryLeft}>
<div className={styles.summaryLabel}></div>
<div className={styles.summaryValue}>{todayDietKcal} <span className={styles.summaryUnit}>kcal</span></div>
<div className={styles.summaryHint}>
{todayDietKcal < 1200 ? '摄入偏低,注意营养' : todayDietKcal < 2200 ? '摄入适中,继续保持' : '摄入偏高,注意控制'}
</div>
</div>
<div className={styles.summaryRight}>
<svg width="64" height="64" viewBox="0 0 64 64">
<path d="M16 24c0-4 3-8 8-8h16c5 0 8 4 8 8v8c0 8-3 16-16 16s-16-8-16-16V24z" fill="#20C997" opacity="0.15" />
<circle cx="40" cy="44" r="14" fill="#20C997" opacity="0.1" />
<text x="32" y="40" textAnchor="middle" fontSize="18">🍽</text>
</svg>
</div>
</div>
</div>
{/* Add record */}
<Card className={styles.addCard}>
<div className={styles.addRow}>
<select className={styles.select} value={mealType} onChange={(e) => setMealType(e.target.value as typeof mealType)}>
<option value="breakfast"></option>
<option value="lunch"></option>
<option value="dinner"></option>
<option value="snack"></option>
</select>
<Input value={foodName} onChange={(e) => setFoodName(e.target.value)} placeholder="食物名" />
<Input value={foodKcal} onChange={(e) => setFoodKcal(e.target.value)} type="number" placeholder="kcal" />
<div className={styles.mealTabRow}>
{MEAL_TYPES.map((m) => (
<button key={m.key} className={`${styles.mealTab} ${mealType === m.key ? styles.mealTabActive : ''}`}
onClick={() => setMealType(m.key)}>
{m.icon} {m.label}
</button>
))}
</div>
<div className={styles.addGrid3}>
<Input value={foodName} onChange={(e) => setFoodName(e.target.value)} placeholder="食物名称" />
<Input value={foodAmount} onChange={(e) => setFoodAmount(e.target.value)} placeholder="份量" />
<Input value={foodKcal} onChange={(e) => setFoodKcal(e.target.value)} type="number" placeholder="热量(kcal)" />
</div>
<Button size="sm" onClick={addDiet}></Button>
</Card>
{diets.length === 0 ? <Empty message="暂无饮食记录" /> : diets.slice(0, 10).map((d) => (
<Card key={d.id} className={styles.logCard}>
<div>{d.mealType === 'breakfast' ? '🌅' : d.mealType === 'lunch' ? '🌞' : d.mealType === 'dinner' ? '🌙' : '🍪'} {d.foods.map((f) => f.name).join(', ')}</div>
<div className={styles.logDate}>{d.totalCalories}kcal · {formatDate(d.date, 'MM-DD')}</div>
</Card>
{/* Recent records */}
{groupedDiets.length === 0 ? (
<Empty message="暂无饮食记录,开始记录吧" />
) : (
groupedDiets.map(([date, items]) => (
<div key={date} className={styles.dayGroup}>
<div className={styles.dayLabel}>{formatDate(date, 'MM月DD日')} · {items.reduce((s, d) => s + (d.totalCalories || 0), 0)}kcal</div>
{items.map((d, i) => (
<div key={i} className={`${styles.logCard} ${styles[`meal${d.mealType?.[0]?.toUpperCase()}${d.mealType?.slice(1)}`] || ''}`}>
<div className={styles.logIcon}>{MEAL_TYPES.find(m => m.key === d.mealType)?.icon || '🍽️'}</div>
<div className={styles.logInfo}>
<div className={styles.logTitle}>{d.foods?.map(f => f.name).join('、')}</div>
<div className={styles.logMeta}>
{MEAL_TYPES.find(m => m.key === d.mealType)?.label} · {d.totalCalories}kcal · {d.foods?.map(f => f.amount).join('、')}
</div>
</div>
<button className={styles.delBtn} onClick={() => deleteDiet(d.id)} title="删除">×</button>
</div>
))}
</div>
))
)}
{/* Recommendations */}
<h3 className={styles.sectionTitle}></h3>
<div className={styles.recGrid}>
{dietRecommendations.slice(0, 4).map((d, i) => (
<div key={i} className={styles.dietRecCard}>
<div className={styles.dietRecHeader}>
<span className={styles.dietRecIcon}>💡</span>
<span className={styles.dietRecTitle}>{d.title}</span>
</div>
<p className={styles.dietRecDesc}>{d.description.slice(0, 40)}...</p>
<div className={styles.dietRecFoods}>
{d.recommendedFoods.slice(0, 3).map((f, j) => (
<span key={j} className={styles.dietFoodTag}>{f}</span>
))}
</div>
</div>
))}
</div>
</>
)}
<ToastContainer />

View File

@@ -1,60 +1,87 @@
import { useState, useMemo } from 'react';
import { useState, useMemo, useEffect } from 'react';
import { PageHeader } from '@/components/layout/PageHeader';
import { Card } from '@/components/common/Card';
import type { CalendarDay } from '@/types';
import { api } from '@/services/api-client';
import dayjs from 'dayjs';
import styles from './HealthCalendarPage.module.css';
const MARKER_COLORS: Record<string, string> = {
medication_taken: '#10B981',
medication_missed: '#EF4444',
follow_up: '#F59E0B',
measurement: '#2563EB',
};
interface MedRecord { medicationId: string; timeSlot: string; takenAt?: string | null; isTaken: boolean; }
interface HealthRecord { id: string; type: string; recordedAt: string; }
interface Medication { id: string; drugName: string; timeSlots: string[]; status: string; startDate: string; endDate?: string | null; }
export function HealthCalendarPage() {
const [currentDate, setCurrentDate] = useState(dayjs());
const [medRecords, setMedRecords] = useState<MedRecord[]>([]);
const [medications, setMedications] = useState<Medication[]>([]);
useEffect(() => {
// Fetch all active medications and their records
api.get<Medication[]>('/api/medications').then((res) => {
const active = res.data.filter((m) => m.status === 'active');
setMedications(active);
// Fetch records for each active medication
Promise.all(active.map((m) =>
api.get<MedRecord[]>(`/api/medications/${m.id}/records`)
.then((r) => r.data)
.catch(() => [] as MedRecord[])
)).then((all) => setMedRecords(all.flat()));
});
}, []);
const calendarDays = useMemo(() => {
const startOfMonth = currentDate.startOf('month');
const endOfMonth = currentDate.endOf('month');
const startDay = startOfMonth.day();
const days: CalendarDay[] = [];
const today = dayjs().format('YYYY-MM-DD');
// Build a map: date -> { taken, missed }
const dateMap: Record<string, { taken: number; missed: number }> = {};
medRecords.forEach((r) => {
const d = r.takenAt?.split('T')[0];
if (!d) return;
if (!dateMap[d]) dateMap[d] = { taken: 0, missed: 0 };
if (r.isTaken) dateMap[d].taken++;
else dateMap[d].missed++;
});
// Pad previous month
for (let i = startDay - 1; i >= 0; i--) {
const d = startOfMonth.subtract(i + 1, 'day');
days.push({
date: d.format('YYYY-MM-DD'),
year: d.year(),
month: d.month() + 1,
day: d.date(),
isCurrentMonth: false,
isToday: d.format('YYYY-MM-DD') === today,
markers: [],
});
days.push({ date: d.format('YYYY-MM-DD'), year: d.year(), month: d.month() + 1, day: d.date(), isCurrentMonth: false, isToday: d.format('YYYY-MM-DD') === today, markers: [] });
}
for (let d = startOfMonth; d.isBefore(endOfMonth) || d.isSame(endOfMonth, 'day'); d = d.add(1, 'day')) {
const dateStr = d.format('YYYY-MM-DD');
const markers: CalendarDay['markers'] = [];
const dm = dateMap[dateStr];
// Calendar markers would be populated from real API data
days.push({
date: dateStr,
year: d.year(),
month: d.month() + 1,
day: d.date(),
isCurrentMonth: true,
isToday: dateStr === today,
markers,
if (dm) {
if (dm.taken > 0) {
markers.push({ type: 'medication_taken', color: '#10B981', count: dm.taken });
}
if (dm.missed > 0) {
markers.push({ type: 'medication_missed', color: '#EF4444', count: dm.missed });
}
} else {
// Check if any medication should have been taken on this date
const dateInRange = medications.some((m) => {
if (m.status !== 'active') return false;
const sd = m.startDate;
const ed = m.endDate || '9999-12-31';
return dateStr >= sd && dateStr <= ed;
});
if (dateInRange && medications.length > 0) {
markers.push({ type: 'medication_missed', color: '#FFA500', count: 0 });
}
}
days.push({ date: dateStr, year: d.year(), month: d.month() + 1, day: d.date(), isCurrentMonth: true, isToday: dateStr === today, markers });
}
return days;
}, [currentDate]);
}, [currentDate, medRecords, medications]);
const weeks: CalendarDay[][] = [];
for (let i = 0; i < calendarDays.length; i += 7) {
@@ -79,18 +106,11 @@ export function HealthCalendarPage() {
{weeks.map((week, wi) => (
<div key={wi} className={styles.week}>
{week.map((day) => (
<div
key={day.date}
className={`${styles.day} ${!day.isCurrentMonth ? styles.outside : ''} ${day.isToday ? styles.today : ''}`}
>
<div key={day.date} className={`${styles.day} ${!day.isCurrentMonth ? styles.outside : ''} ${day.isToday ? styles.today : ''}`}>
<span className={styles.dayNum}>{day.day}</span>
<div className={styles.markers}>
{day.markers.slice(0, 3).map((m, i) => (
<span
key={i}
className={styles.dot}
style={{ background: m.color }}
/>
<span key={i} className={styles.dot} style={{ background: m.color }} />
))}
</div>
</div>
@@ -101,10 +121,9 @@ export function HealthCalendarPage() {
<Card className={styles.legend}>
<div className={styles.legendTitle}></div>
<div className={styles.legendItems}>
<span className={styles.legendItem}><span className={styles.dot} style={{ background: '#2563EB' }} /> </span>
<span className={styles.legendItem}><span className={styles.dot} style={{ background: '#10B981' }} /> </span>
<span className={styles.legendItem}><span className={styles.dot} style={{ background: '#EF4444' }} /> </span>
<span className={styles.legendItem}><span className={styles.dot} style={{ background: '#F59E0B' }} /> </span>
<span className={styles.legendItem}><span className={styles.dot} style={{ background: '#FFA500' }} /> </span>
</div>
</Card>
</div>

View File

@@ -1,45 +1,251 @@
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-bottom: 16px;
/* Combined card */
.combinedCard {
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px;
margin: 0 -4px;
width: calc(100% + 8px);
background: #fff;
border-radius: 18px;
box-shadow: 0 2px 16px rgba(0,0,0,0.06);
cursor: pointer;
transition: transform 0.15s;
}
.card {
.combinedCard:active { transform: scale(0.985); }
.combinedRow {
display: flex;
align-items: center;
gap: 14px;
}
.combinedIcon {
width: 52px;
height: 52px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.combinedInfo { flex: 1; text-align: left; }
.combinedTitle { font-size: 18px; font-weight: 700; color: #1A1D28; display: block; }
.combinedDesc { font-size: 12px; color: #9BA0B4; margin-top: 2px; display: block; }
.combinedArrow { font-size: 24px; color: #C0C5D2; }
.indicatorTags {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.tag {
font-size: 11px;
font-weight: 600;
padding: 4px 12px;
border-radius: 20px;
}
/* Quick links — horizontal row */
.quickRow {
display: flex;
gap: 10px;
margin: 20px -4px 0;
}
.quickCard {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 20px 12px;
background: var(--color-white);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
transition: transform 0.15s;
-webkit-tap-highlight-color: transparent;
gap: 8px;
padding: 14px 8px;
background: #fff;
border: none;
border-radius: 14px;
box-shadow: 0 1px 8px rgba(0,0,0,0.04);
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
}
.card:active { transform: scale(0.96); }
.quickCard:active { transform: scale(0.95); }
.cardIcon { font-size: 32px; line-height: 1; }
.cardTitle { font-size: var(--font-size-base); font-weight: 600; }
.cardDesc { font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
.quickIcon {
width: 42px;
height: 42px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.extraLinks {
.quickLabel {
font-size: 12px;
font-weight: 600;
color: #1A1D28;
}
/* AI Assistant */
.aiCard {
display: flex;
flex-direction: column;
gap: 8px;
gap: 18px;
padding: 22px 18px;
margin: 16px -4px 0;
width: calc(100% + 8px);
background: linear-gradient(135deg, #EFF2FF 0%, #F5F7FF 40%, #FDF0F5 100%);
border-radius: 22px;
border: 1px solid rgba(79,110,247,0.08);
box-shadow: 0 2px 16px rgba(79,110,247,0.06);
transition: transform 0.15s, box-shadow 0.15s;
position: relative;
overflow: hidden;
}
.linkCard {
.aiCard::before {
content: '';
position: absolute;
width: 140px;
height: 140px;
border-radius: 50%;
background: rgba(79,110,247,0.04);
top: -40px;
right: -40px;
}
.aiCard::after {
content: '';
position: absolute;
width: 80px;
height: 80px;
border-radius: 50%;
background: rgba(240,101,149,0.04);
bottom: -20px;
left: 40px;
}
.aiCard:active { transform: scale(0.985); }
.aiHeader {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: var(--color-white);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
font-size: var(--font-size-base);
-webkit-tap-highlight-color: transparent;
position: relative;
z-index: 1;
}
.linkCard:active { background: var(--color-bg); }
.aiAvatar {
width: 50px;
height: 50px;
border-radius: 16px;
background: var(--color-primary-gradient, linear-gradient(135deg, #4F6EF7 0%, #845EF7 100%));
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4px 14px rgba(79,110,247,0.25);
}
.aiTitleBlock { flex: 1; }
.aiTitle {
font-size: 17px;
font-weight: 700;
color: var(--color-text-primary, #1A1D28);
display: block;
}
.aiSubtitle {
font-size: 11px;
color: var(--color-text-tertiary, #9BA0B4);
display: block;
margin-top: 3px;
}
.aiBadge {
background: var(--color-primary-bg, #EDF0FD);
color: var(--color-primary, #4F6EF7);
font-size: 10px;
font-weight: 600;
padding: 4px 12px;
border-radius: 20px;
letter-spacing: 0.5px;
z-index: 1;
}
.aiDivider {
height: 1px;
background: rgba(79,110,247,0.08);
margin: 0 4px;
position: relative;
z-index: 1;
}
.aiQuestionList {
display: flex;
flex-direction: column;
gap: 8px;
position: relative;
z-index: 1;
}
.aiQuestion {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 14px;
background: #fff;
border: 1px solid rgba(79,110,247,0.06);
border-radius: 14px;
font-size: 13px;
color: #3D4A6B;
text-align: left;
transition: all 0.2s;
line-height: 1.45;
box-shadow: 0 1px 4px rgba(0,0,0,0.02);
}
.aiQuestion:hover {
background: #F9FAFF;
border-color: rgba(79,110,247,0.15);
}
.aiDot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--color-primary, #4F6EF7);
flex-shrink: 0;
opacity: 0.5;
margin-top: 1px;
}
.inputHint {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: #fff;
border: 1.5px dashed rgba(79,110,247,0.15);
border-radius: 14px;
font-size: 13px;
color: var(--color-text-tertiary, #9BA0B4);
position: relative;
z-index: 1;
transition: all 0.2s;
}
.inputHint:hover {
border-color: rgba(79,110,247,0.3);
background: #F9FAFF;
}
.inputHintIcon {
width: 20px;
height: 20px;
opacity: 0.5;
flex-shrink: 0;
}

View File

@@ -1,75 +1,119 @@
import { useNavigate } from 'react-router-dom';
import { PageHeader } from '@/components/layout/PageHeader';
import { MEASUREMENT_TYPES } from '@/utils/constants';
import styles from './HealthHubPage.module.css';
const COMBINED = {
path: '/health/records/add',
label: '健康指标',
desc: '血压 · 心率 · 血糖 · 血氧 · 体重',
indicators: [
{ label: '血压', color: '#EF4444', bg: '#FEE9E9' },
{ label: '心率', color: '#F59E0B', bg: '#FFF4E5' },
{ label: '血糖', color: '#845EF7', bg: '#F3E8FF' },
{ label: '血氧', color: '#339AF0', bg: '#E6F0FF' },
{ label: '体重', color: '#20C997', bg: '#E6F9F2' },
],
};
const QUICK_LINKS = [
{
label: '健康日历', path: '/health/calendar',
color: '#F59E0B', bg: '#FFF8E6',
svg: (<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2" /><line x1="16" y1="2" x2="16" y2="6" /><line x1="8" y1="2" x2="8" y2="6" /><line x1="3" y1="10" x2="21" y2="10" /></svg>),
},
{
label: '服药管理', path: '/health/medications',
color: '#D67E0B', bg: '#FFF4E5',
svg: (<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="4" y="5" width="16" height="14" rx="4" /><path d="M10 9v6M14 9v6M8 12h8" /></svg>),
},
{
label: '运动饮食', path: '/health/exercise-diet',
color: '#20C997', bg: '#E6F9F2',
svg: (<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10" /><path d="M12 6v6l4 2" /></svg>),
},
];
export function HealthHubPage() {
const navigate = useNavigate();
return (
<div className="page">
<PageHeader title="健康中心" showBack={false} />
<div className={styles.grid}>
<button
className={styles.card}
onClick={() => navigate('/health/records?type=blood_pressure')}
>
<span className={styles.cardIcon}>💓</span>
<span className={styles.cardTitle}></span>
<span className={styles.cardDesc}></span>
{/* Combined indicators card */}
<button className={styles.combinedCard} onClick={() => navigate(COMBINED.path)}>
<div className={styles.combinedRow}>
<div className={styles.combinedIcon}>
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
<rect x="2" y="2" width="12" height="12" rx="3" fill="#EEF2FF" stroke="#4F6EF7" strokeWidth="1.5" />
<rect x="18" y="2" width="12" height="12" rx="3" fill="#FEE9E9" stroke="#EF4444" strokeWidth="1.5" />
<rect x="2" y="18" width="12" height="12" rx="3" fill="#FFF4E5" stroke="#F59E0B" strokeWidth="1.5" />
<rect x="18" y="18" width="12" height="12" rx="3" fill="#E6F9F2" stroke="#20C997" strokeWidth="1.5" />
</svg>
</div>
<div className={styles.combinedInfo}>
<span className={styles.combinedTitle}>{COMBINED.label}</span>
<span className={styles.combinedDesc}>{COMBINED.desc}</span>
</div>
<span className={styles.combinedArrow}></span>
</div>
<div className={styles.indicatorTags}>
{COMBINED.indicators.map((ind) => (
<span key={ind.label} className={styles.tag} style={{ background: ind.bg, color: ind.color }}>
{ind.label}
</span>
))}
</div>
</button>
<button
className={styles.card}
onClick={() => navigate('/health/records?type=heart_rate')}
>
<span className={styles.cardIcon}></span>
<span className={styles.cardTitle}></span>
<span className={styles.cardDesc}></span>
{/* Quick links — horizontal row */}
<div className={styles.quickRow}>
{QUICK_LINKS.map((link) => (
<button key={link.path} className={styles.quickCard} onClick={() => navigate(link.path)}>
<span className={styles.quickIcon} style={{ background: link.bg, color: link.color }}>
{link.svg}
</span>
<span className={styles.quickLabel}>{link.label}</span>
</button>
<button
className={styles.card}
onClick={() => navigate('/health/records?type=blood_sugar')}
>
<span className={styles.cardIcon}>🩸</span>
<span className={styles.cardTitle}></span>
<span className={styles.cardDesc}></span>
))}
</div>
{/* AI 健康助手 */}
<div className={styles.aiCard}>
<div className={styles.aiHeader}>
<div className={styles.aiAvatar}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</svg>
</div>
<div className={styles.aiTitleBlock}>
<span className={styles.aiTitle}>AI </span>
<span className={styles.aiSubtitle}> · </span>
</div>
<span className={styles.aiBadge}>Beta</span>
</div>
<div className={styles.aiDivider} />
<div className={styles.aiQuestionList}>
<button className={styles.aiQuestion}>
<span className={styles.aiDot} />
</button>
<button
className={styles.card}
onClick={() => navigate('/health/records?type=spo2')}
>
<span className={styles.cardIcon}>🫁</span>
<span className={styles.cardTitle}></span>
<span className={styles.cardDesc}></span>
</button>
<button
className={styles.card}
onClick={() => navigate('/health/records?type=weight')}
>
<span className={styles.cardIcon}></span>
<span className={styles.cardTitle}></span>
<span className={styles.cardDesc}></span>
</button>
<button
className={styles.card}
onClick={() => navigate('/health/records?type=steps')}
>
<span className={styles.cardIcon}>🚶</span>
<span className={styles.cardTitle}></span>
<span className={styles.cardDesc}></span>
<button className={styles.aiQuestion}>
<span className={styles.aiDot} />
</button>
</div>
<div className={styles.extraLinks}>
<button className={styles.linkCard} onClick={() => navigate('/health/calendar')}>
📅
</button>
<button className={styles.linkCard} onClick={() => navigate('/health/medications')}>
💊
</button>
<button className={styles.linkCard} onClick={() => navigate('/health/exercise-diet')}>
🏃
</button>
<div className={styles.inputHint}>
<svg className={styles.inputHintIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
AI ...
</div>
</div>
</div>
);

View File

@@ -3,11 +3,12 @@
width: 100%;
padding: 12px;
margin-bottom: 10px;
background: var(--color-primary);
background: var(--color-primary-gradient);
color: var(--color-text-inverse);
border-radius: var(--radius-md);
font-size: var(--font-size-base);
font-weight: 500;
font-weight: 600;
box-shadow: 0 4px 14px rgba(79,110,247,0.3);
}
.chartBtn {
@@ -20,6 +21,7 @@
border: 1.5px solid var(--color-primary);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
font-weight: 600;
}
.recordCard {
@@ -28,7 +30,7 @@
.recordValue {
font-size: var(--font-size-xl);
font-weight: 700;
font-weight: 800;
color: var(--color-text-primary);
margin-bottom: 6px;
}

View File

@@ -38,7 +38,7 @@ export function HealthRecordListPage() {
</button>
{records.length === 0 ? (
<Empty icon={config.icon} message={`暂无${config.label}记录`} />
<Empty message={`暂无${config.label}记录`} />
) : (
records.map((r) => (
<Card key={r.id} className={styles.recordCard}>
@@ -51,7 +51,7 @@ export function HealthRecordListPage() {
<div className={styles.recordMeta}>
<span>{formatDate(r.recordedAt, 'MM-DD HH:mm')}</span>
<span className={styles.source}>
{r.source === 'device' ? '📡 设备' : '手动'}
{r.source === 'device' ? '设备' : '手动'}
</span>
</div>
</Card>

View File

@@ -1,16 +1,152 @@
.form {
.cards {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px 0;
gap: 10px;
}
.bpRow {
display: flex;
gap: 12px;
.card {
background: #fff;
border-radius: 14px;
padding: 12px 14px;
box-shadow: 0 1px 6px rgba(0,0,0,0.04);
}
.row {
.cardHeader {
display: flex;
gap: 12px;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.cardIcon { font-size: 16px; }
.cardTitle {
font-size: 14px;
font-weight: 700;
}
.cardUnit {
font-size: 10px;
color: #9BA0B4;
background: #F5F6F9;
padding: 1px 6px;
border-radius: 5px;
margin-left: auto;
}
.cardInputs {
display: flex;
gap: 6px;
align-items: center;
}
.cardInput {
flex: 1;
min-width: 0;
padding: 9px 10px;
border: 1px solid #E8ECF2;
border-radius: 10px;
font-size: 14px;
background: #FAFBFC;
color: #1A1D28;
outline: none;
}
.cardInput:focus { border-color: #C8CDD5; }
.cardInput::placeholder {
color: #B0B8C1;
font-size: 12px;
}
/* Date chip */
.dateChip {
display: flex;
align-items: center;
gap: 4px;
padding: 0;
border: none;
border-radius: 10px;
flex-shrink: 0;
position: relative;
}
.dateInput {
display: flex;
align-items: center;
gap: 4px;
padding: 7px 8px;
border: 1px solid #E8ECF2;
border-radius: 10px;
background: #FAFBFC;
color: #5A6072;
font-size: 12px;
font-weight: 500;
cursor: pointer;
outline: none;
width: 100%;
transition: border-color 0.15s;
-webkit-appearance: none;
appearance: none;
}
.dateInput::-webkit-calendar-picker-indicator {
opacity: 0;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
cursor: pointer;
}
.dateInput::-webkit-datetime-edit-fields-wrapper {
padding: 0;
}
.dateInput::-webkit-datetime-edit-text {
padding: 0 1px;
}
/* Check button */
.checkBtn {
width: 36px;
height: 36px;
border: none;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
transition: opacity 0.15s;
}
.checkBtn:active { opacity: 0.8; }
.checkBtn:disabled { opacity: 0.4; }
/* Trend card */
.trendCard {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
margin-top: 24px;
padding: 16px;
background: #fff;
border: none;
border-radius: 16px;
box-shadow: 0 1px 8px rgba(0,0,0,0.04);
cursor: pointer;
text-align: left;
transition: transform 0.15s;
}
.trendCard:active { transform: scale(0.985); }
.trendIcon { font-size: 24px; flex-shrink: 0; }
.trendInfo { flex: 1; }
.trendTitle { font-size: 15px; font-weight: 700; color: #1A1D28; display: block; }
.trendSub { font-size: 11px; color: #9BA0B4; margin-top: 2px; display: block; }
.trendArrow { font-size: 22px; color: #C0C5D2; flex-shrink: 0; }

View File

@@ -1,87 +1,135 @@
import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Button } from '@/components/common/Button';
import { Input } from '@/components/common/Input';
import { useNavigate } from 'react-router-dom';
import { PageHeader } from '@/components/layout/PageHeader';
import { ToastContainer, toast } from '@/components/common/Toast';
import * as healthService from '@/services/health.service';
import { MEASUREMENT_TYPES } from '@/utils/constants';
import type { MeasurementType } from '@/types';
import dayjs from 'dayjs';
import styles from './ManualEntryPage.module.css';
const INDICATORS = [
{
type: 'blood_pressure' as const, label: '血压', color: '#DC4A4A',
icon: '🩺', multi: true, fields: [
{ key: 'systolic', placeholder: '收缩压', hint: '120' },
{ key: 'diastolic', placeholder: '舒张压', hint: '80' },
], unit: 'mmHg', chartLabel: '血压',
},
{
type: 'heart_rate' as const, label: '心率', color: '#D68B20',
icon: '💓', fields: [{ key: 'heart_rate', placeholder: '心率', hint: '72' }], unit: 'bpm', chartLabel: '心率',
},
{
type: 'blood_sugar' as const, label: '血糖', color: '#7C5CE7',
icon: '🩸', fields: [{ key: 'blood_sugar', placeholder: '血糖', hint: '5.6' }], unit: 'mmol/L', chartLabel: '血糖',
},
{
type: 'spo2' as const, label: '血氧', color: '#3B8ED4',
icon: '🫁', fields: [{ key: 'spo2', placeholder: '血氧', hint: '98' }], unit: '%', chartLabel: '血氧',
},
{
type: 'weight' as const, label: '体重', color: '#3DAF86',
icon: '⚖️', fields: [{ key: 'weight', placeholder: '体重', hint: '70.5' }], unit: 'kg', chartLabel: '体重',
},
];
export function ManualEntryPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const type = (searchParams.get('type') || 'blood_pressure') as MeasurementType;
const config = MEASUREMENT_TYPES[type];
const [systolic, setSystolic] = useState('');
const [diastolic, setDiastolic] = useState('');
const [value, setValue] = useState('');
const [date, setDate] = useState(dayjs().format('YYYY-MM-DD'));
const [time, setTime] = useState(dayjs().format('HH:mm'));
const [loading, setLoading] = useState(false);
const [values, setValues] = useState<Record<string, string>>({
systolic: '', diastolic: '', heart_rate: '', blood_sugar: '', spo2: '', weight: '',
});
const [dates, setDates] = useState<Record<string, string>>(
Object.fromEntries(INDICATORS.map((i) => [i.type, dayjs().format('YYYY-MM-DD')])),
);
const [loading, setLoading] = useState<string | null>(null);
const setVal = (key: string, v: string) => setValues((prev) => ({ ...prev, [key]: v }));
const saveOne = async (type: string) => {
setLoading(type);
try {
const d = dates[type] || dayjs().format('YYYY-MM-DD');
const recordedAt = `${d}T${dayjs().format('HH:mm:ss')}`;
const ind = INDICATORS.find((i) => i.type === type)!;
const handleSubmit = async () => {
const numVal = parseFloat(value);
if (type === 'blood_pressure') {
const sys = parseFloat(systolic);
const dia = parseFloat(diastolic);
if (!sys || !dia) { toast('请填写完整', 'error'); return; }
await healthService.addRecord({
type,
value: { systolic: sys, diastolic: dia },
unit: 'mmHg',
recordedAt: `${date}T${time}:00`,
recordedDate: date,
source: 'manual',
});
const sys = parseFloat(values.systolic);
const dia = parseFloat(values.diastolic);
if (!sys || !dia) { toast('请填写收缩压和舒张压', 'error'); return; }
await healthService.addRecord({ type, value: { systolic: sys, diastolic: dia }, unit: 'mmHg', recordedAt, recordedDate: d, source: 'manual' });
} else {
if (!numVal) { toast('请填写数值', 'error'); return; }
await healthService.addRecord({
type,
value: numVal,
unit: config.unit,
recordedAt: `${date}T${time}:00`,
recordedDate: date,
source: 'manual',
});
const v = parseFloat(values[ind.fields[0].key]);
if (!v) { toast('请填写数值', 'error'); return; }
await healthService.addRecord({ type, value: v, unit: ind.unit, recordedAt, recordedDate: d, source: 'manual' });
}
toast(`${ind.label} 已保存`);
} catch {
toast('保存失败', 'error');
} finally {
setLoading(null);
}
toast('记录成功');
setTimeout(() => navigate(-1), 500);
};
return (
<div className="page--no-tab">
<PageHeader title={`新增${config.label}记录`} />
<div className={styles.form}>
{type === 'blood_pressure' ? (
<>
<div className={styles.bpRow}>
<Input label="收缩压 (mmHg)" value={systolic} onChange={(e) => setSystolic(e.target.value)} type="number" />
<Input label="舒张压 (mmHg)" value={diastolic} onChange={(e) => setDiastolic(e.target.value)} type="number" />
<PageHeader title="健康指标" />
<div className={styles.cards}>
{INDICATORS.map((ind) => (
<div key={ind.type} className={styles.card}>
<div className={styles.cardHeader}>
<span className={styles.cardIcon}>{ind.icon}</span>
<span className={styles.cardTitle} style={{ color: ind.color }}>{ind.label}</span>
<span className={styles.cardUnit}>{ind.unit}</span>
</div>
</>
) : (
<Input
label={`${config.label} (${config.unit})`}
value={value}
onChange={(e) => setValue(e.target.value)}
<div className={styles.cardInputs}>
{ind.fields.map((f) => (
<input
key={f.key}
className={styles.cardInput}
type="number"
step="0.1"
step={ind.type === 'blood_sugar' || ind.type === 'weight' ? '0.1' : '1'}
placeholder={`${f.placeholder} ${f.hint}`}
value={values[f.key] || ''}
onChange={(e) => setVal(f.key, e.target.value)}
/>
))}
<div className={styles.dateChip}>
<input
className={styles.dateInput}
type="date"
value={dates[ind.type] || ''}
onChange={(e) => setDates((prev) => ({ ...prev, [ind.type]: e.target.value }))}
/>
</div>
<button
className={styles.checkBtn}
style={{ background: ind.color }}
onClick={() => saveOne(ind.type)}
disabled={loading === ind.type}
>
{loading === ind.type ? '…' : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
<div className={styles.row}>
<Input label="日期" value={date} onChange={(e) => setDate(e.target.value)} type="date" />
<Input label="时间" value={time} onChange={(e) => setTime(e.target.value)} type="time" />
</button>
</div>
</div>
))}
</div>
<Button variant="primary" size="lg" fullWidth loading={loading} onClick={handleSubmit}>
</Button>
{/* Trend entry */}
<button className={styles.trendCard} onClick={() => navigate('/health/trends')}>
<span className={styles.trendIcon}>📈</span>
<div className={styles.trendInfo}>
<span className={styles.trendTitle}></span>
<span className={styles.trendSub}> · · · · </span>
</div>
<span className={styles.trendArrow}></span>
</button>
<ToastContainer />
</div>
);

View File

@@ -6,14 +6,46 @@
.periodBtn {
padding: 6px 14px;
border-radius: var(--radius-full);
font-size: var(--font-size-sm);
background: var(--color-bg-secondary);
color: var(--color-text-secondary);
border-radius: 20px;
border: none;
font-size: 13px;
font-weight: 500;
background: #F5F6F9;
color: #6B7280;
cursor: pointer;
transition: all 0.2s;
}
.active {
background: var(--color-primary);
color: var (--color-text-inverse);
background: #4F6EF7;
color: #fff;
}
.toggleBar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.toggleBtn {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: 20px;
border: 1.5px solid;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
background: #fff;
}
.toggleDot {
width: 8px;
height: 8px;
border-radius: 50%;
transition: background 0.2s;
}

View File

@@ -1,14 +1,20 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useEffect, useState, useMemo } from 'react';
import { PageHeader } from '@/components/layout/PageHeader';
import { LineChart } from '@/components/charts/LineChart';
import { MultiLineChart, type SeriesData } from '@/components/charts/MultiLineChart';
import { Empty } from '@/components/common/Empty';
import { MEASUREMENT_TYPES } from '@/utils/constants';
import * as healthService from '@/services/health.service';
import type { HealthRecord, MeasurementType } from '@/types';
import { Button } from '@/components/common/Button';
import type { HealthRecord } from '@/types';
import styles from './TrendChartPage.module.css';
const INDICATORS = [
{ type: 'bp_systolic' as const, label: '收缩压', color: '#DC4A4A', unit: 'mmHg', source: 'blood_pressure' as const, field: 'systolic' as const },
{ type: 'bp_diastolic' as const, label: '舒张压', color: '#E0558A', unit: 'mmHg', source: 'blood_pressure' as const, field: 'diastolic' as const },
{ type: 'heart_rate' as const, label: '心率', color: '#D68B20', unit: 'bpm' },
{ type: 'blood_sugar' as const, label: '血糖', color: '#7C5CE7', unit: 'mmol/L' },
{ type: 'spo2' as const, label: '血氧', color: '#3B8ED4', unit: '%' },
{ type: 'weight' as const, label: '体重', color: '#3DAF86', unit: 'kg' },
];
const PERIODS = [
{ label: '7天', days: 7 },
{ label: '14天', days: 14 },
@@ -17,25 +23,60 @@ const PERIODS = [
];
export function TrendChartPage() {
const { type } = useParams<{ type: MeasurementType }>();
const config = MEASUREMENT_TYPES[type || 'blood_pressure'];
const [records, setRecords] = useState<HealthRecord[]>([]);
const [visible, setVisible] = useState<Set<string>>(new Set(INDICATORS.map((i) => i.type)));
const [period, setPeriod] = useState(30);
const [allRecords, setAllRecords] = useState<HealthRecord[]>([]);
useEffect(() => {
if (type) healthService.getTrendData(type, period).then(setRecords);
}, [type, period]);
const sources = [...new Set(INDICATORS.map((i) => (i as Record<string, string>).source || i.type))];
Promise.all(sources.map((s) => healthService.getTrendData(s as Parameters<typeof healthService.getTrendData>[0], period)))
.then((results) => setAllRecords(results.flat()));
}, [period]);
const isBP = type === 'blood_pressure';
const chartData = records.map((r) => ({
const series: SeriesData[] = useMemo(() => {
const result: SeriesData[] = [];
for (const ind of INDICATORS) {
if (!visible.has(ind.type)) continue;
const sourceType = (ind as Record<string, string>).source || ind.type;
const raw = allRecords
.filter((r) => r.type === sourceType)
.sort((a, b) => a.recordedDate.localeCompare(b.recordedDate));
if ('field' in ind && ind.field) {
result.push({
name: ind.label,
color: ind.color,
data: raw.map((r) => ({
date: r.recordedDate,
value: isBP ? (typeof r.value === 'object' ? r.value.systolic : 0) : (r.value as number),
value2: isBP ? (typeof r.value === 'object' ? r.value.diastolic : 0) : undefined,
}));
value: typeof r.value === 'object' ? (r.value as Record<string, number>)[ind.field!] : 0,
})),
unit: ind.unit,
});
} else {
result.push({
name: ind.label,
color: ind.color,
data: raw.map((r) => ({ date: r.recordedDate, value: r.value as number })),
unit: ind.unit,
});
}
}
return result;
}, [allRecords, visible]);
const toggle = (type: string) => {
const next = new Set(visible);
if (next.has(type)) next.delete(type); else next.add(type);
setVisible(next);
};
const hasData = series.some((s) => s.data.length > 0);
return (
<div className="page--no-tab">
<PageHeader title={`${config.label}趋势`} />
<PageHeader title="健康趋势" />
{/* Period selector */}
<div className={styles.periodBar}>
{PERIODS.map((p) => (
<button
@@ -47,18 +88,31 @@ export function TrendChartPage() {
</button>
))}
</div>
{chartData.length > 0 ? (
<LineChart
data={chartData}
seriesName={isBP ? '收缩压' : config.label}
seriesName2={isBP ? '舒张压' : undefined}
unit={config.unit}
markLine={isBP ? 140 : undefined}
markLineLabel={isBP ? '140警戒线' : undefined}
/>
{/* Toggle indicators */}
<div className={styles.toggleBar}>
{INDICATORS.map((ind) => (
<button
key={ind.type}
className={`${styles.toggleBtn} ${visible.has(ind.type) ? styles.toggleOn : styles.toggleOff}`}
style={{
borderColor: ind.color,
...(visible.has(ind.type) ? { background: ind.color, color: '#fff' } : { color: ind.color }),
}}
onClick={() => toggle(ind.type)}
>
<span className={styles.toggleDot} style={{ background: visible.has(ind.type) ? '#fff' : ind.color }} />
{ind.label}
</button>
))}
</div>
{hasData ? (
<MultiLineChart series={series} />
) : (
<Empty message="暂无数据" />
)}
</div>
);
}

View File

@@ -36,11 +36,11 @@ export function DeviceBindingPage() {
<div className="page--no-tab">
<PageHeader title="设备管理" />
<Button fullWidth loading={scanning} onClick={handleScan} style={{ marginBottom: 16 }}>
{scanning ? '搜索中...' : '🔍 扫描附近设备'}
{scanning ? '搜索中...' : '扫描附近设备'}
</Button>
{devices.length === 0 ? (
<Empty icon="📡" message="暂无已绑定设备" />
<Empty message="暂无已绑定设备" />
) : (
devices.map((d) => (
<Card key={d.id} className={styles.deviceCard}>
@@ -51,7 +51,7 @@ export function DeviceBindingPage() {
<span className={`${styles.status} ${d.status === 'connected' ? styles.connected : styles.disconnected}`}>
{d.status === 'connected' ? '已连接' : '未连接'}
</span>
<span className={styles.battery}>🔋 {d.batteryLevel}%</span>
<span className={styles.battery}> {d.batteryLevel}%</span>
</div>
</div>
<Button

View File

@@ -1,138 +1,358 @@
.notifyBtn {
.greetingBar {
padding: 12px 0 16px;
display: flex;
justify-content: center;
align-items: center;
position: relative;
font-size: 20px;
padding: 4px;
min-width: 44px;
min-height: 44px;
}
.dateText {
font-size: 15px;
font-weight: 600;
color: var(--color-text-secondary);
}
.notifyBtn {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-white);
border-radius: 14px;
box-shadow: var(--shadow-sm);
border: 1px solid var(--color-divider);
}
.notifyBadge {
position: absolute;
top: 6px;
right: 6px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-accent-red);
border: 2px solid #fff;
}
.overviewCard {
margin-bottom: 16px;
background: linear-gradient(135deg, #1E6BFF, #4D8FFF);
background: linear-gradient(145deg, #3A54E8 0%, #5B74F7 30%, #7D9AFF 100%);
color: #fff;
border-radius: var(--radius-xl);
padding: 20px 16px;
overflow: hidden;
position: relative;
box-shadow: 0 8px 25px rgba(58,84,232,0.3);
}
.overviewCard::before {
content: '';
position: absolute;
width: 160px;
height: 160px;
border-radius: 50%;
background: rgba(255,255,255,0.05);
top: -40px;
right: -40px;
}
.overviewCard::after {
content: '';
position: absolute;
width: 100px;
height: 100px;
border-radius: 50%;
background: rgba(255,255,255,0.04);
bottom: -30px;
left: -30px;
}
.overviewHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
margin-bottom: 18px;
position: relative;
z-index: 1;
}
.overviewTitle {
font-size: var(--font-size-md);
font-weight: 600;
color: #fff;
opacity: 0.95;
}
.overviewTime {
font-size: var(--font-size-xs);
color: var(--color-text-tertiary);
font-size: 11px;
opacity: 0.65;
background: rgba(255,255,255,0.15);
padding: 4px 10px;
border-radius: 12px;
}
.overviewData {
display: flex;
align-items: center;
gap: 16px;
align-items: stretch;
position: relative;
z-index: 1;
}
.bpSection,
.hrSection {
.dataCol {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
justify-content: center;
gap: 2px;
padding: 2px 0;
}
.dataCol:first-child {
flex: 1.35;
}
.dataLabel {
font-size: var(--font-size-xs);
color: var(--color-text-tertiary);
font-size: 10px;
opacity: 0.65;
font-weight: 500;
letter-spacing: 0.5px;
}
.bpValues {
display: flex;
align-items: baseline;
gap: 4px;
gap: 1px;
}
.bpNum {
font-size: var(--font-size-3xl);
font-weight: 700;
line-height: 1.1;
font-size: 22px;
font-weight: 800;
line-height: 1.15;
color: #fff;
white-space: nowrap;
letter-spacing: -1px;
}
.risk_normal { color: var(--color-success); }
.risk_borderline { color: var(--color-warning); }
.risk_abnormal { color: var(--color-danger); }
.bpSep {
font-size: var(--font-size-xl);
color: var(--color-text-tertiary);
font-size: 14px;
opacity: 0.35;
margin: 0 -1px;
}
.hrNum {
font-size: var(--font-size-3xl);
font-weight: 700;
color: var(--color-text-primary);
line-height: 1.1;
font-size: 22px;
font-weight: 800;
line-height: 1.15;
color: #fff;
white-space: nowrap;
letter-spacing: -0.5px;
}
.unit {
font-size: var(--font-size-xs);
color: var(--color-text-tertiary);
font-size: 10px;
opacity: 0.5;
font-weight: 500;
}
.divider {
width: 1px;
height: 60px;
background: var(--color-border);
background: rgba(255,255,255,0.18);
flex-shrink: 0;
align-self: stretch;
margin: 6px 0;
}
.riskAbnormal {
color: #FF7171 !important;
}
/* Quick Actions */
.quickActions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin-bottom: 16px;
background: var(--color-white);
border-radius: var(--radius-lg);
padding: 16px;
box-shadow: var(--shadow-sm);
}
.quickAction {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 12px 8px;
border-radius: var(--radius-md);
transition: background 0.15s;
gap: 8px;
padding: 14px 4px;
background: var(--color-white);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
transition: all 0.2s;
-webkit-tap-highlight-color: transparent;
}
.quickAction:active {
background: var(--color-bg);
transform: scale(0.94);
box-shadow: var(--shadow-md);
}
.quickIcon {
font-size: 28px;
width: 46px;
height: 46px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
line-height: 1;
}
.quickLabel {
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
font-size: 12px;
color: var(--color-text-primary);
font-weight: 600;
}
/* Today's Medications */
/* Today's Medications */
.medSection {
margin-bottom: 20px;
}
.medSectionTitle {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
font-size: 17px;
font-weight: 700;
color: var(--color-text-primary);
}
.medTitleIcon {
width: 36px;
height: 36px;
border-radius: 12px;
background: var(--color-primary-gradient);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(79,110,247,0.3);
}
.medTitleCount {
margin-left: auto;
font-size: 12px;
font-weight: 600;
color: var(--color-primary);
background: #EDF0FD;
padding: 4px 12px;
border-radius: 20px;
}
.medCard {
background: var(--color-white);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: 18px;
margin-bottom: 10px;
cursor: pointer;
transition: transform 0.15s;
}
.medCard:active {
transform: scale(0.98);
}
.medHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 10px;
}
.medNameGroup {
display: flex;
flex-direction: column;
gap: 4px;
}
.medName {
font-size: 16px;
font-weight: 700;
color: var(--color-text-primary);
}
.medDosage {
font-size: 12px;
font-weight: 500;
color: var(--color-text-tertiary);
}
.medStatus {
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: 20px;
}
.medStatusDone { background: var(--color-success-bg); color: #0D8A5E; }
.medStatusPending { background: var(--color-primary-bg); color: var(--color-primary); }
.medStatusMissed { background: var(--color-danger-bg); color: #D53131; }
.medSlots {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.medSlot {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 10px;
border-radius: 8px;
font-size: 12px;
font-weight: 500;
}
.medSlotTaken { background: var(--color-success-bg); color: #0D8A5E; }
.medSlotTodo { background: var(--color-bg); color: var(--color-text-secondary); }
.medSlotMissed { background: var(--color-danger-bg); color: #D53131; }
.medSlotIcon {
width: 14px;
height: 14px;
opacity: 0.6;
}
.medSlotDot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.medSlotDotTaken { background: var(--color-success); }
.medSlotDotTodo { background: var(--color-text-tertiary); }
.medSlotDotMissed { background: var(--color-danger); }
.medEmpty {
text-align: center;
padding: 32px 24px;
color: var(--color-text-tertiary);
font-size: 13px;
background: var(--color-white);
border-radius: var(--radius-lg);
}
/* Health Tip */
.tipCard {
margin-bottom: 16px;
background: linear-gradient(135deg, #FFFDF5, #FFF8EC);
border: 1px solid #FDE8B3;
border-radius: var(--radius-lg);
}
.tipHeader {
@@ -144,18 +364,19 @@
.tipTitle {
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--color-text-secondary);
font-weight: 700;
color: #B7791F;
}
.tipHint {
margin-left: auto;
font-size: var(--font-size-xs);
color: var(--color-text-tertiary);
color: #D69E2E;
font-weight: 500;
}
.tipContent {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
font-size: 13px;
color: #7B3F00;
line-height: 1.6;
}

View File

@@ -1,118 +1,273 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useEffect, useState, useMemo } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Card } from '@/components/common/Card';
import { Empty } from '@/components/common/Empty';
import { Badge } from '@/components/common/Badge';
import { PageHeader } from '@/components/layout/PageHeader';
import { useAuth } from '@/hooks/useAuth';
import { useNotificationStore } from '@/stores/notification.store';
import { api } from '@/services/api-client';
import * as healthService from '@/services/health.service';
import { MEASUREMENT_TYPES, HEALTH_TIPS } from '@/utils/constants';
import { getBPRiskLevel } from '@/utils/format';
import type { HealthStats } from '@/types';
import styles from './HomePage.module.css';
interface MedSlot { time: string; taken: boolean; missed: boolean; takenAt?: string }
interface MedSummary { id: string; drugName: string; dosage: string; frequency: string; slots: MedSlot[]; allTaken: boolean }
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'];
const QUICK_ACTIONS = [
{ label: '测血压', icon: '💓', path: '/health/records?type=blood_pressure' },
{ label: '记用药', icon: '💊', path: '/health/medications' },
{ label: '在线问诊', icon: '👨‍⚕️', path: '/services/consultation' },
{ label: '报告解读', icon: '📋', path: '/services/reports' },
{ label: '健康日历', icon: '📅', path: '/health/calendar' },
{ label: '运动饮食', icon: '🏃', path: '/health/exercise-diet' },
{
key: 'bp',
label: '血压',
path: '/health/records?type=blood_pressure',
svg: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#EF4444" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
<polyline points="12 7 12 13 15 15" />
</svg>
),
iconBg: '#FEE9E9',
},
{
key: 'med',
label: '用药',
path: '/health/medications',
svg: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#D67E0B" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="4" y="5" width="16" height="14" rx="4" />
<path d="M10 9v6M14 9v6M8 12h8" />
</svg>
),
iconBg: '#FFF4E5',
},
{
key: 'chat',
label: '问诊',
path: '/services/consultation',
svg: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4F6EF7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
<line x1="9" y1="10" x2="15" y2="10" />
<line x1="12" y1="7" x2="12" y2="13" />
</svg>
),
iconBg: '#E6F0FF',
},
{
key: 'report',
label: '报告',
path: '/services/reports',
svg: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#845EF7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
),
iconBg: '#F3E8FF',
},
{
key: 'calendar',
label: '日历',
path: '/health/calendar',
svg: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#20C997" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
),
iconBg: '#E6F9F2',
},
{
key: 'followup',
label: '复查',
path: '/services/follow-ups',
svg: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#F06595" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 10h-2.5L14 13l-2-6-3 6.5L7 10H5" />
<rect x="2" y="2" width="20" height="20" rx="3" />
</svg>
),
iconBg: '#FFF0F5',
},
{
key: 'diet',
label: '饮食',
path: '/health/exercise-diet',
svg: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#10B981" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" />
</svg>
),
iconBg: '#F0FDF4',
},
{
key: 'device',
label: '设备',
path: '/home/device-binding',
svg: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#339AF0" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
<line x1="12" y1="18" x2="12.01" y2="18" />
<path d="M9 6h6" />
</svg>
),
iconBg: '#EFF6FF',
},
];
export function HomePage() {
const navigate = useNavigate();
const location = useLocation();
const { user } = useAuth();
const { unreadCount, fetchNotifications } = useNotificationStore();
const [stats, setStats] = useState<HealthStats[]>([]);
const [tipIndex, setTipIndex] = useState(0);
const [meds, setMeds] = useState<MedSummary[]>([]);
useEffect(() => {
healthService.getLatestStats().then(setStats);
fetchNotifications();
}, [fetchNotifications]);
api.get<MedSummary[]>('/api/medications/today-summary')
.then((r) => setMeds(r.data))
.catch(() => {});
}, [fetchNotifications, location.pathname]);
const bpStats = stats.find((s) => s.type === 'blood_pressure');
const hrStats = stats.find((s) => s.type === 'heart_rate');
const sugarStats = stats.find((s) => s.type === 'blood_sugar');
const spo2Stats = stats.find((s) => s.type === 'spo2');
const bpValue = bpStats?.latest?.value;
const systolic = typeof bpValue === 'object' ? bpValue.systolic : null;
const diastolic = typeof bpValue === 'object' ? bpValue.diastolic : null;
const riskLevel = systolic && diastolic ? getBPRiskLevel(systolic, diastolic) : null;
const todayDate = useMemo(() => {
const d = new Date();
return `${d.getMonth() + 1}${d.getDate()}日 星期${WEEKDAYS[d.getDay()]}`;
}, []);
const bpAbnormal = systolic !== null && diastolic !== null
&& (systolic >= 120 || diastolic >= 80);
const hrValue = hrStats?.latest ? Number(hrStats.latest.value) : null;
const hrAbnormal = hrValue !== null && (hrValue < 60 || hrValue > 100);
const sugarValue = sugarStats?.latest ? Number(sugarStats.latest.value) : null;
const sugarAbnormal = sugarValue !== null && (sugarValue < 3.9 || sugarValue > 6.1);
const spo2Value = spo2Stats?.latest ? Number(spo2Stats.latest.value) : null;
const spo2Abnormal = spo2Value !== null && spo2Value < 95;
return (
<div className="page">
<PageHeader
title={`你好,${user?.nickname || '用户'}`}
showBack={false}
rightAction={
<button className={styles.notifyBtn} onClick={() => navigate('/notifications')}>
🔔
{unreadCount > 0 && <Badge count={unreadCount} />}
<div className="page" style={{ paddingTop: 0 }}>
<div className={styles.greetingBar}>
<div className={styles.dateText}>{todayDate}</div>
<button onClick={() => navigate('/notifications')} className={styles.notifyBtn}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-secondary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
{unreadCount > 0 && <span className={styles.notifyBadge} />}
</button>
}
/>
</div>
{/* Health Overview */}
{bpStats?.latest && hrStats?.latest ? (
<Card className={styles.overviewCard}>
<div className={styles.overviewHeader}>
<span className={styles.overviewTitle}></span>
<span className={styles.overviewTime}></span>
</div>
<div className={styles.overviewData}>
<div className={styles.bpSection}>
<div className={styles.dataCol}>
<span className={styles.dataLabel}></span>
{systolic ? (
<div className={styles.bpValues}>
<span className={`${styles.bpNum} ${styles[`risk_${riskLevel}`] || ''}`}>
<span className={`${styles.bpNum} ${bpAbnormal ? styles.riskAbnormal : ''}`}>
{systolic}
</span>
<span className={styles.bpSep}>/</span>
<span className={`${styles.bpNum} ${styles[`risk_${riskLevel}`] || ''}`}>
<span className={`${styles.bpNum} ${bpAbnormal ? styles.riskAbnormal : ''}`}>
{diastolic}
</span>
</div>
) : <span className={styles.bpNum} style={{ fontSize: 22, opacity: 0.4 }}>--/--</span>}
<span className={styles.unit}>mmHg</span>
</div>
<div className={styles.divider} />
<div className={styles.hrSection}>
<div className={styles.dataCol}>
<span className={styles.dataLabel}></span>
<span className={styles.hrNum}>{Number(hrStats.latest.value)}</span>
<span className={`${styles.hrNum} ${hrAbnormal ? styles.riskAbnormal : ''}`}>{hrValue ?? '--'}</span>
<span className={styles.unit}>bpm</span>
</div>
<div className={styles.divider} />
<div className={styles.dataCol}>
<span className={styles.dataLabel}></span>
<span className={`${styles.hrNum} ${sugarAbnormal ? styles.riskAbnormal : ''}`}>{sugarValue ?? '--'}</span>
<span className={styles.unit}>mmol/L</span>
</div>
<div className={styles.divider} />
<div className={styles.dataCol}>
<span className={styles.dataLabel}></span>
<span className={`${styles.hrNum} ${spo2Abnormal ? styles.riskAbnormal : ''}`}>{spo2Value ?? '--'}</span>
<span className={styles.unit}>%</span>
</div>
</div>
</Card>
) : (
<Empty icon="💓" message="暂无健康数据" />
)}
{/* Quick Actions */}
<div className={styles.quickActions}>
{QUICK_ACTIONS.map((action) => (
<button
key={action.label}
className={styles.quickAction}
onClick={() => navigate(action.path)}
>
<span className={styles.quickIcon}>{action.icon}</span>
<button key={action.key} className={styles.quickAction} onClick={() => navigate(action.path)}>
<span className={styles.quickIcon} style={{ background: action.iconBg }}>
{action.svg}
</span>
<span className={styles.quickLabel}>{action.label}</span>
</button>
))}
</div>
{/* Health Tip */}
<Card
className={styles.tipCard}
onClick={() => setTipIndex((prev) => (prev + 1) % HEALTH_TIPS.length)}
>
<div className={styles.tipHeader}>
<span>💡</span>
<span className={styles.tipTitle}></span>
<span className={styles.tipHint}></span>
<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>
<p className={styles.tipContent}>{HEALTH_TIPS[tipIndex]}</p>
</Card>
))
)}
</div>
</div>
);
}

View File

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

View File

@@ -1,59 +1,174 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useParams } from 'react-router-dom';
import { PageHeader } from '@/components/layout/PageHeader';
import { Card } from '@/components/common/Card';
import { Button } from '@/components/common/Button';
import { PieChart } from '@/components/charts/PieChart';
import { ToastContainer, toast } from '@/components/common/Toast';
import * as medicationService from '@/services/medication.service';
import type { Medication, MedicationAdherence } from '@/types';
import type { Medication, MedicationRecord } from '@/types';
import styles from './MedicationDetailPage.module.css';
export function MedicationDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [medications, setMedications] = useState<Medication[]>([]);
const [adherence, setAdherence] = useState<MedicationAdherence | null>(null);
const [med, setMed] = useState<Medication | null>(null);
const [records, setRecords] = useState<MedicationRecord[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
medicationService.getMedications().then(setMedications);
if (id) medicationService.getAdherence(id).then(setAdherence).catch(() => {});
}, [id]);
const load = () => {
if (!id) return;
medicationService.getMedications().then((meds) => {
const m = meds.find((x) => x.id === id);
if (m) setMed(m);
});
medicationService.getMedicationRecords(id).then(setRecords);
};
const med = medications.find((m) => m.id === id);
useEffect(() => { load(); }, [id]);
const today = new Date().toISOString().split('T')[0];
const todayRecords = records.filter((r) => r.takenAt?.startsWith(today) || !r.takenAt);
const todaySlots = med?.timeSlots || [];
const handleMarkTaken = async (slot: string) => {
if (!id) return;
setLoading(true);
try {
await medicationService.markTaken(id, slot);
toast('已记录');
load();
} catch { toast('失败', 'error'); }
finally { setLoading(false); }
};
if (!med) {
return (
<div className="page--no-tab">
<PageHeader title="药品详情" />
<div style={{ padding: 40, textAlign: 'center', color: '#9CA3AF' }}></div>
</div>
);
return <div className="page--no-tab"><PageHeader title="药品详情" /><div style={{ padding: 40, textAlign: 'center', color: '#9CA3AF' }}></div></div>;
}
const slotTaken = (slot: string) => todayRecords.some((r) => r.timeSlot === slot && r.isTaken);
const last7Days: { date: string; taken: number; total: number }[] = [];
for (let i = 6; i >= 0; i--) {
const d = new Date();
d.setDate(d.getDate() - i);
const ds = d.toISOString().split('T')[0];
const dayRecords = records.filter((r) => r.takenAt?.startsWith(ds) && r.isTaken);
last7Days.push({ date: ds, taken: dayRecords.length, total: todaySlots.length });
}
return (
<div className="page--no-tab">
<PageHeader title={med.drugName} />
<Card className={styles.infoCard}>
<div className={styles.infoTitle}>{med.drugName}</div>
<div className={styles.infoRow}><span></span><span>{med.dosage}</span></div>
<div className={styles.infoRow}><span></span><span>{med.timeSlots.join(', ')}</span></div>
<div className={styles.infoRow}><span></span><span>{med.startDate} ~ {med.endDate || '长期'}</span></div>
<div className={styles.infoRow}><span></span><span className={med.status === 'active' ? styles.activeBadge : ''}>{med.status === 'active' ? '进行中' : '已结束'}</span></div>
{med.notes && <div className={styles.infoRow}><span></span><span>{med.notes}</span></div>}
<div className={styles.heroHeader}>
<div className={styles.heroIcon}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="4" y="5" width="16" height="14" rx="4" />
<path d="M10 9v6M14 9v6" />
</svg>
</div>
<div className={styles.heroInfo}>
<div className={styles.heroName}>{med.drugName}</div>
<div className={styles.heroMeta}>{med.dosage} · {med.frequency}</div>
</div>
</div>
<div className={styles.infoGrid}>
<div className={styles.infoItem}>
<div className={styles.infoLabel}></div>
<div className={styles.infoValue}>{med.dosage}</div>
</div>
<div className={styles.infoItem}>
<div className={styles.infoLabel}></div>
<div className={styles.infoValue}>{med.frequency}</div>
</div>
<div className={styles.infoItem}>
<div className={styles.infoLabel}></div>
<div className={styles.infoValue}>{med.timeSlots.join(', ')}</div>
</div>
<div className={styles.infoItem}>
<div className={styles.infoLabel}></div>
<div className={styles.infoValue}>{med.startDate} ~ {med.endDate || '长期'}</div>
</div>
</div>
</Card>
{adherence && (
<Card className={styles.adherenceCard}>
<div className={styles.adherenceTitle}>30</div>
<div className={styles.adherenceRate}>{adherence.rate}%</div>
<PieChart
data={[
{ name: '已服用', value: adherence.rate, color: '#10B981' },
{ name: '未服用', value: 100 - adherence.rate, color: '#EF4444' },
]}
/>
{med.notes && (
<Card className={styles.infoCard}>
<div className={styles.infoLabel} style={{ marginBottom: 4 }}></div>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.6 }}>{med.notes}</div>
</Card>
)}
<Card className={styles.infoCard}>
<div className={styles.todayTitle}></div>
<div className={styles.todayDate}>{today}</div>
{todaySlots.map((slot) => {
const taken = slotTaken(slot);
return (
<div key={slot} className={`${styles.slotRow} ${taken ? styles.slotRowTaken : styles.slotRowPending}`}>
<div className={styles.slotLeft}>
<div className={`${styles.slotCircle} ${taken ? styles.slotCircleTaken : styles.slotCirclePending}`}>
{taken ? '✓' : slot}
</div>
<div>
<div className={styles.slotTime}>{slot}</div>
<div className={styles.slotLabel}>{taken ? '已服用' : '待服用'}</div>
</div>
</div>
{!taken && med.status === 'active' && (
<Button size="sm" variant="primary" loading={loading} onClick={() => handleMarkTaken(slot)}>
</Button>
)}
</div>
);
})}
<div className={styles.todaySummary}>
<span className={styles.todayProgress}>
{todaySlots.filter((s) => slotTaken(s)).length}/{todaySlots.length}
</span>
<div className={styles.todayProgressBar}>
<div className={styles.todayProgressFill} style={{
width: `${todaySlots.length > 0 ? (todaySlots.filter((s) => slotTaken(s)).length / todaySlots.length) * 100 : 0}%`,
}} />
</div>
</div>
</Card>
<Card className={styles.infoCard}>
<div className={styles.chartTitle}>7</div>
<div className={styles.chartBars}>
{last7Days.map((d) => {
const pct = d.total > 0 ? (d.taken / d.total) * 100 : 0;
const height = d.total > 0 ? Math.max(8, (d.taken / d.total) * 60) : 8;
return (
<div key={d.date} className={styles.chartBarWrap}>
<div
className={`${styles.chartBar} ${pct === 100 ? styles.chartBarFull : pct > 0 ? styles.chartBarPartial : styles.chartBarEmpty}`}
style={{ height }}
/>
<div className={styles.chartDate}>{d.date.slice(5)}</div>
</div>
);
})}
</div>
<div className={styles.chartLegend}>
<div className={styles.chartLegendItem}>
<span className={`${styles.chartLegendDot} ${styles.chartLegendDotFull}`} />
</div>
<div className={styles.chartLegendItem}>
<span className={`${styles.chartLegendDot} ${styles.chartLegendDotPartial}`} />
</div>
<div className={styles.chartLegendItem}>
<span className={`${styles.chartLegendDot} ${styles.chartLegendDotEmpty}`} />
</div>
</div>
</Card>
<ToastContainer />
</div>
);
}

View File

@@ -6,7 +6,7 @@
.sectionLabel {
font-size: var(--font-size-sm);
font-weight: 500;
font-weight: 600;
color: var(--color-text-secondary);
display: block;
margin-bottom: 8px;

View File

@@ -10,33 +10,33 @@ import styles from './MedicationEditPage.module.css';
export function MedicationEditPage() {
const navigate = useNavigate();
const [drugName, setDrugName] = useState('');
const [dosage, setDosage] = useState('');
const [frequency, setFrequency] = useState('每日1次');
const [timeSlots, setTimeSlots] = useState(['08:00']);
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [notes, setNotes] = useState('');
const [loading, setLoading] = useState(false);
const freqLabels: Record<string, string> = {
'每日1次': '每日一次', '每日2次': '每日两次', '每日3次': '每日三次',
const addTimeSlot = () => setTimeSlots([...timeSlots, '12:00']);
const removeTimeSlot = (i: number) => {
if (timeSlots.length <= 1) return;
setTimeSlots(timeSlots.filter((_, idx) => idx !== i));
};
const handleFreqChange = (f: string) => {
setFrequency(f);
if (f === '每日1次' || f === 'once_daily') setTimeSlots(['08:00']);
else if (f === '每日2次' || f === 'twice_daily') setTimeSlots(['08:00', '20:00']);
else setTimeSlots(['08:00', '14:00', '20:00']);
const updateTimeSlot = (i: number, val: string) => {
setTimeSlots(timeSlots.map((s, idx) => idx === i ? val : s));
};
const handleSubmit = async () => {
if (!drugName || !dosage) { toast('请填写药品名和剂量', 'error'); return; }
if (timeSlots.length === 0) { toast('请设置至少一个服药时间', 'error'); return; }
const sorted = [...timeSlots].sort();
setLoading(true);
try {
await medicationService.addMedication({
drugName, dosage, frequency, timeSlots,
drugName, dosage,
frequency: `每日${sorted.length}`,
timeSlots: sorted,
startDate: startDate || new Date().toISOString().slice(0, 10),
endDate: endDate || undefined,
notes,
@@ -44,9 +44,7 @@ export function MedicationEditPage() {
});
toast('添加成功');
navigate(-1);
} finally {
setLoading(false);
}
} finally { setLoading(false); }
};
return (
@@ -57,45 +55,37 @@ export function MedicationEditPage() {
<label className={styles.sectionLabel}></label>
<div className={styles.drugGrid}>
{COMMON_DRUGS.slice(0, 6).map((d) => (
<button
key={d}
className={`${styles.drugChip} ${drugName === d ? styles.drugChipActive : ''}`}
onClick={() => setDrugName(d)}
>
{d}
</button>
<button key={d} className={`${styles.drugChip} ${drugName === d ? styles.drugChipActive : ''}`} onClick={() => setDrugName(d)}>{d}</button>
))}
</div>
<Input placeholder="或手动输入药品名" value={drugName} onChange={(e) => setDrugName(e.target.value)} />
<Input placeholder="或手动输入" value={drugName} onChange={(e) => setDrugName(e.target.value)} />
</div>
<Input label="剂量 (如 100mg)" value={dosage} onChange={(e) => setDosage(e.target.value)} placeholder="100mg" />
<Input label="剂量" value={dosage} onChange={(e) => setDosage(e.target.value)} placeholder="100mg" />
<div className={styles.section}>
<label className={styles.sectionLabel}></label>
<div className={styles.freqRow}>
{(['每日1次', '每日2次', '每日3次'] as const).map((f) => (
<button
key={f}
className={`${styles.freqBtn} ${frequency === f ? styles.freqActive : ''}`}
onClick={() => handleFreqChange(f)}
>
{freqLabels[f]}
</button>
))}
<label className={styles.sectionLabel}></label>
{timeSlots.map((slot, i) => (
<div key={i} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<input type="time" value={slot} onChange={(e) => updateTimeSlot(i, e.target.value)}
style={{ flex: 1, padding: '8px 12px', border: '1px solid #ddd', borderRadius: 8, fontSize: 14, fontFamily: 'inherit' }} />
<button onClick={() => removeTimeSlot(i)} disabled={timeSlots.length <= 1}
style={{ background: 'none', border: 'none', color: '#EF4444', fontSize: 18, cursor: 'pointer', padding: 4, fontWeight: 700 }}>×</button>
</div>
))}
<button onClick={addTimeSlot} style={{ padding: '6px 14px', border: '1px dashed #2563EB', borderRadius: 8, background: 'none', color: '#2563EB', fontSize: 13, cursor: 'pointer' }}>
+
</button>
</div>
<div className={styles.row}>
<Input label="开始日期" value={startDate} onChange={(e) => setStartDate(e.target.value)} type="date" />
<Input label="结束日期" value={endDate} onChange={(e) => setEndDate(e.target.value)} type="date" />
<Input label="结束日期(可选)" value={endDate} onChange={(e) => setEndDate(e.target.value)} type="date" />
</div>
<Input label="备注 (如饭后服用)" value={notes} onChange={(e) => setNotes(e.target.value)} />
<Input label="备注" value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="如:饭后服用" />
<Button variant="primary" size="lg" fullWidth loading={loading} onClick={handleSubmit}>
</Button>
<Button variant="primary" size="lg" fullWidth loading={loading} onClick={handleSubmit}></Button>
</div>
<ToastContainer />
</div>

View File

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

View File

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

View File

@@ -1,10 +1,11 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { PageHeader } from '@/components/layout/PageHeader';
import { Card } from '@/components/common/Card';
import { Empty } from '@/components/common/Empty';
import { useNotificationStore } from '@/stores/notification.store';
import { formatRelative } from '@/utils/format';
import type { NotificationType } from '@/types';
import type { NotificationType, Notification } from '@/types';
import styles from './NotificationListPage.module.css';
const TYPE_TABS: { key: NotificationType | 'all'; label: string }[] = [
@@ -15,7 +16,23 @@ const TYPE_TABS: { key: NotificationType | 'all'; label: string }[] = [
{ key: 'system', label: '系统' },
];
function getNavPath(n: Notification): string | null {
switch (n.type) {
case 'consultation':
return '/services/consultation';
case 'medication':
return '/health/medications';
case 'followup':
return '/services/follow-ups';
case 'report':
return n.relatedId ? `/services/reports/${n.relatedId}` : '/services/reports';
default:
return null;
}
}
export function NotificationListPage() {
const navigate = useNavigate();
const { notifications, unreadCount, fetchNotifications, markRead, markAllRead } = useNotificationStore();
const [tab, setTab] = useState<NotificationType | 'all'>('all');
@@ -27,6 +44,12 @@ export function NotificationListPage() {
? notifications
: notifications.filter((n) => n.type === tab);
const handleClick = (n: Notification) => {
if (!n.isRead) markRead(n.id);
const path = getNavPath(n);
if (path) navigate(path);
};
return (
<div className="page--no-tab">
<PageHeader
@@ -53,13 +76,13 @@ export function NotificationListPage() {
</div>
{filtered.length === 0 ? (
<Empty icon="🔔" message="暂无通知" />
<Empty message="暂无通知" />
) : (
filtered.map((n) => (
<Card
key={n.id}
className={`${styles.notifCard} ${!n.isRead ? styles.unread : ''}`}
onClick={() => { if (!n.isRead) markRead(n.id); }}
onClick={() => handleClick(n)}
>
<div className={styles.notifHeader}>
<span className={styles.notifTitle}>{n.title}</span>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -46,7 +46,11 @@ export function AboutPage() {
<div className="page--no-tab">
<PageHeader title="关于" />
<div style={{ textAlign: 'center', padding: '40px 20px' }}>
<div style={{ fontSize: 56 }}>💙</div>
<div style={{ fontSize: 56 }}>
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="#4F6EF7" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
</div>
<div style={{ fontSize: '18px', fontWeight: 700, marginTop: 12 }}> Demo</div>
<div style={{ fontSize: '13px', color: '#9CA3AF', marginTop: 4 }}>v1.0.0-demo</div>
<div style={{ fontSize: '13px', color: '#6B7280', marginTop: 16 }}> H5 Web Demo</div>

Some files were not shown because too many files have changed in this diff Show More