Compare commits

...

16 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
127 changed files with 6974 additions and 1429 deletions

View File

@@ -2,10 +2,18 @@
"version": "0.0.1", "version": "0.0.1",
"configurations": [ "configurations": [
{ {
"name": "健康管家 Web Demo", "name": "健康管家-患者端",
"runtimeExecutable": "cmd.exe", "runtimeExecutable": "cmd.exe",
"runtimeArgs": ["/c", "D:\\nodejs\\npm.cmd", "run", "dev"], "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/ obj/
# Data (large files) # Data (large files)
data/ /data/
# Environment # Environment
.env .env

View File

@@ -14,6 +14,18 @@ public record UserProfileResponse(
List<string>? MedicalHistory, DateOnly? StentDate, string? StentType, List<string>? MedicalHistory, DateOnly? StentDate, string? StentType,
string? Department, string? Title, List<string>? Specialty, string? Introduction); string? Department, string? Title, List<string>? Specialty, string? Introduction);
public record UpdateProfileRequest( public class UpdateProfileRequest
string? Name, string? Gender, DateOnly? Birthday, {
decimal? HeightCm, decimal? WeightKg, List<string>? MedicalHistory); public string? Name { get; set; }
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) 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 var consultation = new Consultation
{ {
PatientId = patientId, PatientId = patientId,
@@ -35,7 +41,19 @@ public class ConsultationService(AppDbContext db)
Subject = subject, Subject = subject,
}; };
db.Consultations.Add(consultation); db.Consultations.Add(consultation);
try
{
await db.SaveChangesAsync(); 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; return consultation;
} }

View File

@@ -14,13 +14,20 @@ public class FollowUpService(AppDbContext db)
.ToListAsync(); .ToListAsync();
public async Task<List<FollowUp>> GetDoctorFollowUpsAsync(Guid doctorId) 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 => await db.FollowUps
.Include(f => f.Patient) .Include(f => f.Patient)
.Where(f => f.DoctorId == doctorId) .Where(f => f.DoctorId == doctorId)
.OrderBy(f => f.ScheduledAt) .OrderBy(f => f.ScheduledAt)
.ToListAsync(); .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 var followUp = new FollowUp
{ {
@@ -30,6 +37,7 @@ public class FollowUpService(AppDbContext db)
Description = description, Description = description,
ScheduledAt = DateTime.SpecifyKind(scheduledAt, DateTimeKind.Utc), ScheduledAt = DateTime.SpecifyKind(scheduledAt, DateTimeKind.Utc),
ReminderEnabled = reminderEnabled, ReminderEnabled = reminderEnabled,
Notes = notes,
}; };
db.FollowUps.Add(followUp); db.FollowUps.Add(followUp);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -53,10 +61,18 @@ public class FollowUpService(AppDbContext db)
if (scheduledAt.HasValue) followUp.ScheduledAt = DateTime.SpecifyKind(scheduledAt.Value, DateTimeKind.Utc); if (scheduledAt.HasValue) followUp.ScheduledAt = DateTime.SpecifyKind(scheduledAt.Value, DateTimeKind.Utc);
if (status != null) followUp.Status = status; if (status != null) followUp.Status = status;
if (notes != null) followUp.Notes = notes; if (notes != null) followUp.Notes = notes;
followUp.DoctorId = doctorId;
followUp.UpdatedAt = DateTime.UtcNow; followUp.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return followUp; 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; 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) public async Task<Dictionary<string, object>> GetStatsAsync(Guid userId)
{ {
var types = new[] { "blood_pressure", "heart_rate", "blood_sugar", "spo2", "weight", "steps" }; 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; 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.Domain.Entities;
using HealthManager.Infrastructure.Data; using HealthManager.Infrastructure.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Notification = HealthManager.Domain.Entities.Notification;
namespace HealthManager.Application.Services; 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(); await db.SaveChangesAsync();
return report; 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="Microsoft.IdentityModel.Tokens" Version="8.18.0" />
<PackageReference Include="Minio" Version="7.0.0" /> <PackageReference Include="Minio" Version="7.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" /> <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" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
</ItemGroup> </ItemGroup>

View File

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

View File

@@ -1,3 +1,4 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using HealthManager.Application.DTOs.Auth; using HealthManager.Application.DTOs.Auth;
using HealthManager.Domain.Interfaces; using HealthManager.Domain.Interfaces;
@@ -5,6 +6,7 @@ using HealthManager.Application.Services;
using HealthManager.Domain.Entities; using HealthManager.Domain.Entities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace HealthManager.WebApi.Controllers; namespace HealthManager.WebApi.Controllers;
@@ -12,23 +14,57 @@ namespace HealthManager.WebApi.Controllers;
[Route("api/auth")] [Route("api/auth")]
public class AuthController( public class AuthController(
AuthService authService, AuthService authService,
IJwtProvider jwtProvider) : ControllerBase IJwtProvider jwtProvider,
VerificationService verificationService,
RateLimitService rateLimit,
TokenBlacklistService tokenBlacklist) : ControllerBase
{ {
[HttpPost("send-sms")] [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 = "验证码已发送" }); return Ok(new { message = "验证码已发送" });
} }
[HttpPost("login")] [HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest request) public async Task<IActionResult> Login([FromBody] LoginRequest request)
{ {
// Demo: skip SMS verification, accept any code
var user = await authService.GetUserByPhoneAsync(request.Phone); var user = await authService.GetUserByPhoneAsync(request.Phone);
if (user == null) 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 accessToken = jwtProvider.GenerateAccessToken(user.Id, user.Name, user.Role);
var refreshToken = jwtProvider.GenerateRefreshToken(); var refreshToken = jwtProvider.GenerateRefreshToken();
await authService.SaveRefreshTokenAsync(user.Id, refreshToken, DateTime.UtcNow.AddDays(7)); await authService.SaveRefreshTokenAsync(user.Id, refreshToken, DateTime.UtcNow.AddDays(7));
@@ -51,7 +87,6 @@ public class AuthController(
PasswordHash = AuthService.HashPassword("demo123"), PasswordHash = AuthService.HashPassword("demo123"),
}; };
// Access DbContext via DI
var db = HttpContext.RequestServices.GetRequiredService<Infrastructure.Data.AppDbContext>(); var db = HttpContext.RequestServices.GetRequiredService<Infrastructure.Data.AppDbContext>();
db.Users.Add(user); db.Users.Add(user);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -63,6 +98,24 @@ public class AuthController(
return Ok(new AuthResponse(user.Id, user.Name, user.Role, accessToken, refreshToken)); 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")] [HttpPost("refresh")]
public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequest request) 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.HeightCm.HasValue) user.HeightCm = request.HeightCm;
if (request.WeightKg.HasValue) user.WeightKg = request.WeightKg; if (request.WeightKg.HasValue) user.WeightKg = request.WeightKg;
if (request.MedicalHistory != null) user.MedicalHistory = request.MedicalHistory; if (request.MedicalHistory != null) user.MedicalHistory = request.MedicalHistory;
if (request.StentDate.HasValue) user.StentDate = request.StentDate;
if (request.StentType != null) user.StentType = request.StentType;
if (request.Department != null) user.Department = request.Department;
if (request.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; user.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();

View File

@@ -14,11 +14,22 @@ public class FollowUpController(FollowUpService followUpService) : ControllerBas
private string Role => User.FindFirstValue(ClaimTypes.Role)!; private string Role => User.FindFirstValue(ClaimTypes.Role)!;
[HttpGet] [HttpGet]
public async Task<IActionResult> GetFollowUps() public async Task<IActionResult> GetFollowUps([FromQuery] string? type)
{ {
var followUps = Role == "doctor" List<HealthManager.Domain.Entities.FollowUp> followUps;
? await followUpService.GetDoctorFollowUpsAsync(UserId)
: await followUpService.GetPatientFollowUpsAsync(UserId); if (Role == "doctor" && type == "followup")
followUps = await followUpService.GetDoctorInitiatedFollowUpsAsync(UserId);
else if (Role == "doctor")
followUps = await followUpService.GetDoctorFollowUpsAsync(UserId);
else
{
followUps = await followUpService.GetPatientFollowUpsAsync(UserId);
if (type == "followup")
followUps = followUps.Where(f => f.DoctorId != null).ToList();
else if (type == "recheck")
followUps = followUps.Where(f => f.DoctorId == null).ToList();
}
return Ok(followUps.Select(f => new return Ok(followUps.Select(f => new
{ {
@@ -45,11 +56,29 @@ public class FollowUpController(FollowUpService followUpService) : ControllerBas
[HttpPost] [HttpPost]
public async Task<IActionResult> AddFollowUp([FromBody] FollowUpCreateRequest request) public async Task<IActionResult> AddFollowUp([FromBody] FollowUpCreateRequest request)
{ {
var followUp = await followUpService.AddAsync(UserId, request.Title, request.Description, var patientId = UserId;
request.ScheduledAt, request.ReminderEnabled); 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 }); 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}")] [HttpPut("{id:guid}")]
[Authorize(Roles = "doctor")] [Authorize(Roles = "doctor")]
public async Task<IActionResult> UpdateFollowUp(Guid id, [FromBody] FollowUpUpdateRequest request) 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( public class FollowUpUpdateRequest
string? Title, string? Description, DateTime? ScheduledAt, string? Status, string? Notes); {
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); 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 }); 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); 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 Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
private string Role => User.FindFirstValue(ClaimTypes.Role)!; private string Role => User.FindFirstValue(ClaimTypes.Role)!;
[HttpGet("today-summary")]
public async Task<IActionResult> GetTodaySummary()
{
var summary = await medicationService.GetTodaySummaryAsync(UserId);
return Ok(summary);
}
[HttpGet] [HttpGet]
public async Task<IActionResult> GetMedications() public async Task<IActionResult> GetMedications()
{ {
@@ -71,10 +78,32 @@ public class MedicationController(MedicationService medicationService) : Control
var rate = await medicationService.GetAdherenceRateAsync(id); var rate = await medicationService.GetAdherenceRateAsync(id);
return Ok(new { medicationId = id, rate }); 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( public record MedicationCreateRequest(
string DrugName, string Dosage, string Frequency, string DrugName, string Dosage, string Frequency,
List<string> TimeSlots, DateOnly StartDate, DateOnly? EndDate, string? Notes); 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); 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.Authentication.JwtBearer" Version="10.0.8" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
<PackageReference Include="Microsoft.OpenApi" Version="2.4.1" /> <PackageReference Include="Microsoft.OpenApi" Version="2.4.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup> </ItemGroup>

View File

@@ -1,9 +1,11 @@
using System.IdentityModel.Tokens.Jwt;
using System.Text; using System.Text;
using HealthManager.Domain.Interfaces; using HealthManager.Domain.Interfaces;
using HealthManager.Application.Services; using HealthManager.Application.Services;
using HealthManager.Infrastructure.Data; using HealthManager.Infrastructure.Data;
using HealthManager.Infrastructure.Services; using HealthManager.Infrastructure.Services;
using HealthManager.WebApi.Hubs; using HealthManager.WebApi.Hubs;
using HealthManager.WebApi.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
@@ -39,6 +41,14 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs")) if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
context.Token = accessToken; context.Token = accessToken;
return Task.CompletedTask; 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<PatientService>();
builder.Services.AddScoped<NotificationService>(); 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 // SignalR
builder.Services.AddSignalR(); 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(); var app = builder.Build();
@@ -95,6 +116,7 @@ using (var scope = app.Services.CreateScope())
{ {
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.EnsureCreatedAsync(); await db.Database.EnsureCreatedAsync();
await MigrationHelper.EnsureNewTablesAsync(db);
await DataSeeder.SeedAsync(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", "Issuer": "HealthManager",
"Audience": "HealthManagerApp" "Audience": "HealthManagerApp"
}, },
"Redis": {
"Connection": "localhost:6379"
},
"MinIO": { "MinIO": {
"Endpoint": "localhost:9000", "Endpoint": "localhost:9000",
"AccessKey": "minioadmin", "AccessKey": "minioadmin",

View File

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

View File

@@ -8,6 +8,7 @@
"name": "frontend-doctor", "name": "frontend-doctor",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@microsoft/signalr": "^10.0.0",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"echarts-for-react": "^3.0.6", "echarts-for-react": "^3.0.6",
@@ -574,6 +575,19 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", "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": { "node_modules/acorn": {
"version": "8.16.0", "version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -1674,6 +1700,24 @@
"node": ">=0.10.0" "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": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "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": { "node_modules/file-entry-cache": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -2355,6 +2409,26 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/node-releases": {
"version": "2.0.44", "version": "2.0.44",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz",
@@ -2491,16 +2565,33 @@
"node": ">= 0.8.0" "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": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "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": { "node_modules/react": {
"version": "19.2.6", "version": "19.2.6",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
@@ -2560,6 +2651,12 @@
"react-dom": ">=18" "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": { "node_modules/rolldown": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
@@ -2672,6 +2769,27 @@
"url": "https://github.com/sponsors/SuperchupuDev" "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": { "node_modules/ts-api-utils": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -2749,6 +2867,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/update-browserslist-db": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -2790,6 +2917,16 @@
"punycode": "^2.1.0" "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": { "node_modules/vite": {
"version": "8.0.13", "version": "8.0.13",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", "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": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -2894,6 +3047,27 @@
"node": ">=0.10.0" "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": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -10,6 +10,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@microsoft/signalr": "^10.0.0",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"echarts-for-react": "^3.0.6", "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; } *, *::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; } body {
button { cursor: pointer; } 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,17 +1,76 @@
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'; import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../stores/auth.store'; 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 = [ const navItems = [
{ to: '/dashboard', label: '工作台', icon: '📊' }, { to: '/dashboard', label: '工作台', ikey: 'dashboard' },
{ to: '/patients', label: '患者管理', icon: '👥' }, { to: '/patients', label: '患者管理', ikey: 'patients' },
{ to: '/consultations', label: '在线问诊', icon: '💬' }, { to: '/consultations', label: '在线问诊', ikey: 'consultations' },
{ to: '/reports', label: '报告审核', icon: '📋' }, { to: '/reports', label: '报告审核', ikey: 'reports' },
{ to: '/follow-ups', label: '随访管理', icon: '📅' }, { to: '/follow-ups', label: '复查管理', ikey: 'followups' },
{ to: '/visits', label: '随访管理', ikey: 'visits' },
]; ];
const sidebarBg = '#0F1D3D'; const sidebarStyles = {
const accentColor = '#4D8FFF'; bg: '#FFFFFF',
const textMuted = '#8E9DB5'; 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() { export function DoctorLayout() {
const { user, logout } = useAuthStore(); const { user, logout } = useAuthStore();
@@ -24,60 +83,85 @@ export function DoctorLayout() {
}; };
return ( return (
<div style={{ display: 'flex', minHeight: '100vh', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif' }}> <div style={{ display: 'flex', minHeight: '100vh' }}>
{/* Sidebar */}
<aside style={{ <aside style={{
width: 220, background: sidebarBg, color: '#fff', width: 224, background: sidebarStyles.bg, color: textPrimary,
display: 'flex', flexDirection: 'column', flexShrink: 0, 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)' }}> <div style={{ padding: '24px 20px 20px', borderBottom: `1px solid ${sidebarStyles.borderColor}` }}>
<h1 style={{ fontSize: 17, margin: 0, fontWeight: 600, color: '#fff', letterSpacing: 1 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ color: accentColor }}></span> <div style={{
</h1> width: 38, height: 38, borderRadius: 12,
<p style={{ fontSize: 12, margin: '6px 0 0', color: textMuted }}></p> 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> </div>
<nav style={{ flex: 1, padding: '12px 0' }}> <nav style={{ flex: 1, padding: '8px 0' }}>
{navItems.map((item) => ( {navItems.map((item) => (
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
style={({ isActive }) => ({ style={({ isActive }) => ({
display: 'flex', alignItems: 'center', gap: 10, display: 'flex', alignItems: 'center', gap: 10,
padding: '11px 20px', margin: '2px 8px', padding: '11px 16px', margin: '2px 10px',
borderRadius: 8, borderRadius: 10,
color: isActive ? '#fff' : textMuted, color: isActive ? accentColor : textMuted,
background: isActive ? accentColor : 'transparent', background: isActive ? sidebarStyles.activeBg : 'transparent',
textDecoration: 'none', fontSize: 14, textDecoration: 'none', fontSize: 14,
fontWeight: isActive ? 500 : 400, fontWeight: isActive ? 600 : 400,
transition: 'all 0.15s', transition: 'all 0.2s',
})} })}
> >
<span style={{ fontSize: 16 }}>{item.icon}</span> {SIDEBAR_ICONS[item.ikey]}
<span>{item.label}</span> <span>{item.label}</span>
</NavLink> </NavLink>
))} ))}
</nav> </nav>
<div style={{ padding: '16px 20px', borderTop: '1px solid rgba(255,255,255,0.08)' }}> <div style={{ padding: '16px 16px', borderTop: `1px solid ${sidebarStyles.borderColor}`, background: '#FAFBFD' }}>
<div style={{ fontSize: 13, color: '#fff', fontWeight: 500 }}>{user?.name}</div> <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<div style={{ fontSize: 11, color: textMuted, marginTop: 2 }}>{user?.department} · {user?.title}</div> <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} <button onClick={handleLogout}
style={{ style={{
marginTop: 10, padding: '6px 14px', fontSize: 12, width: '100%', padding: '8px 0', fontSize: 12,
background: 'transparent', color: textMuted, border: '1px solid rgba(255,255,255,0.15)', background: 'transparent', color: '#EF4444',
borderRadius: 6, cursor: 'pointer', transition: 'all 0.15s', 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'; }} onMouseEnter={(e) => { e.currentTarget.style.background = '#FEF2F2'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = textMuted; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.15)'; }}> onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}>
退 退
</button> </button>
</div> </div>
</aside> </aside>
{/* Main content */}
<main style={{ flex: 1, background: '#F2F5FA', overflow: 'auto' }}> <main style={{ flex: 1, background: '#F2F5FA', overflow: 'auto' }}>
<div key={location.pathname} style={{ animation: 'fadeIn 0.2s ease-out' }}> <div key={location.pathname} style={{ animation: 'fadeIn 0.25s ease-out' }}>
<Outlet /> <Outlet />
</div> </div>
</main> </main>

View File

@@ -4,108 +4,26 @@
--bg: #fff; --bg: #fff;
--border: #e5e4e7; --border: #e5e4e7;
--code-bg: #f4f3ec; --code-bg: #f4f3ec;
--accent: #aa3bff; --accent: #4F6EF7;
--accent-bg: rgba(170, 59, 255, 0.1); --accent-bg: rgba(79, 110, 247, 0.1);
--accent-border: rgba(170, 59, 255, 0.5); --accent-border: rgba(79, 110, 247, 0.5);
--social-bg: rgba(244, 243, 236, 0.5); --social-bg: rgba(244, 243, 236, 0.5);
--shadow: --shadow: rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
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; --sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif; --heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace; --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 { #root {
width: 1126px; width: 100%;
max-width: 100%; max-width: 100%;
margin: 0 auto; margin: 0;
text-align: center; text-align: left;
border-inline: 1px solid var(--border); min-height: 100vh;
min-height: 100svh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-sizing: border-box; box-sizing: border-box;
} }
body { body { margin: 0; }
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);
}

View File

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

View File

@@ -27,37 +27,48 @@ export function LoginPage() {
return ( return (
<div style={{ <div style={{
display: 'flex', justifyContent: 'center', alignItems: 'center', 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={{ <form onSubmit={handleLogin} style={{
width: 400, padding: 40, background: '#fff', borderRadius: 8, width: 400, padding: 40, background: '#fff', borderRadius: 20,
boxShadow: '0 2px 12px rgba(0,0,0,0.1)', 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 }}> <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)} <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>
<div style={{ marginBottom: 20 }}> <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)} <input value={code} onChange={(e) => setCode(e.target.value)}
placeholder="输入任意验证码" 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> </div>
<button type="submit" disabled={loading} style={{ <button type="submit" disabled={loading} style={{
width: '100%', padding: '12px', background: '#1976d2', color: '#fff', width: '100%', padding: '13px', background: 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)', color: '#fff',
border: 'none', borderRadius: 4, fontSize: 15, opacity: loading ? 0.7 : 1, border: 'none', borderRadius: 10, fontSize: 15, fontWeight: 600,
opacity: loading ? 0.7 : 1, boxShadow: '0 4px 16px rgba(79,110,247,0.3)',
}}> }}>
{loading ? '登录中...' : '登录'} {loading ? '登录中...' : '登录'}
</button> </button>
<p style={{ marginTop: 16, fontSize: 12, color: '#999', textAlign: 'center' }}> <p style={{ marginTop: 16, fontSize: 12, color: '#9BA0B4', textAlign: 'center' }}>
13700137000 ( ) 13700137000 ( )
</p> </p>
</form> </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 { useParams } from 'react-router-dom';
import { HubConnectionBuilder, HubConnection, HubConnectionState } from '@microsoft/signalr';
import { api } from '../../services/api-client'; import { api } from '../../services/api-client';
interface Message { interface Message {
@@ -7,12 +8,24 @@ interface Message {
content: string; contentType: string; createdAt: string; 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() { export function ChatPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [connected, setConnected] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const connRef = useRef<HubConnection | null>(null);
// Load initial messages via HTTP
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
api.get<Message[]>(`/api/consultations/${id}/messages`) api.get<Message[]>(`/api/consultations/${id}/messages`)
@@ -20,23 +33,69 @@ export function ChatPage() {
.catch(() => {}); .catch(() => {});
}, [id]); }, [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(() => { useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]); }, [messages]);
const handleSend = async () => { const handleSend = useCallback(async () => {
if (!input.trim() || !id) return; if (!input.trim() || !id || !connRef.current) return;
try { const text = input;
const res = await api.post<Message>(`/api/consultations/${id}/messages`, { content: input });
setMessages((prev) => [...prev, res.data]);
setInput(''); setInput('');
try {
await connRef.current.invoke('SendMessage', id, text);
} catch { /* ignore */ } } catch { /* ignore */ }
}; }, [input, id]);
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 0px)' }}> <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>
<div style={{ flex: 1, overflow: 'auto', padding: 20, background: '#fafafa' }}> <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 { Link } from 'react-router-dom';
import { api } from '../../services/api-client'; import { api } from '../../services/api-client';
interface ConsultationItem {
id: string; patientId: string; patientName: string; subject: string;
status: string; startedAt: string;
}
interface RawConsultation { interface RawConsultation {
id: string; patientId: string; patientName?: string; subject?: string; id: string; patientId: string; patientName?: string; subject?: string;
status: string; startedAt: string; status: string; startedAt: string;
} }
export function ConsultationListPage() { export function ConsultationListPage() {
const [consultations, setConsultations] = useState<ConsultationItem[]>([]); const [consultations, setConsultations] = useState<RawConsultation[]>([]);
useEffect(() => { useEffect(() => {
api.get<RawConsultation[]>('/api/consultations').then((r) => { api.get<RawConsultation[]>('/api/consultations').then((r) => {
const mapped = r.data.map((c) => ({ setConsultations(r.data);
id: c.id,
patientId: c.patientId,
patientName: c.patientName || 'unknown',
subject: c.subject || 'online consult',
status: c.status,
startedAt: c.startedAt,
}));
setConsultations(mapped);
}).catch(() => {}); }).catch(() => {});
}, []); }, []);
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 28 }}>
<h2 style={{ marginBottom: 16 }}>线</h2> <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) => ( {consultations.map((c) => (
<Link key={c.id} to={`/consultations/${c.id}`} style={{ <Link key={c.id} to={`/consultations/${c.id}`} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '14px 20px', borderBottom: '1px solid #f5f5f5', padding: '16px 22px', borderBottom: '1px solid #F5F6F9',
textDecoration: 'none', color: 'inherit', textDecoration: 'none', color: 'inherit', transition: 'background 0.15s',
}}> }}
onMouseEnter={(e) => { e.currentTarget.style.background = '#F9FAFC'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = ''; }}>
<div> <div>
<div style={{ fontSize: 14, fontWeight: 500 }}>{c.patientName}</div> <div style={{ fontSize: 14, fontWeight: 600, color: '#1A1D28' }}>{c.patientName || '未知'}</div>
<div style={{ fontSize: 12, color: '#888', marginTop: 2 }}>{c.subject}</div> <div style={{ fontSize: 12, color: '#9BA0B4', marginTop: 3 }}>{c.subject || '在线问诊'}</div>
</div> </div>
<div style={{ textAlign: 'right' }}> <div style={{ textAlign: 'right' }}>
<span style={{ <span style={{
padding: '2px 8px', borderRadius: 10, fontSize: 11, padding: '4px 12px', borderRadius: 10, fontSize: 11, fontWeight: 500,
background: c.status === 'active' ? '#e8f5e9' : '#f5f5f5', background: c.status === 'active' ? '#E6F9F2' : '#F5F6F9',
color: c.status === 'active' ? '#2e7d32' : '#999', color: c.status === 'active' ? '#20C997' : '#9BA0B4',
}}> }}>
{c.status === 'active' ? '进行中' : '已结束'} {c.status === 'active' ? '进行中' : '已结束'}
</span> </span>
<div style={{ fontSize: 11, color: '#bbb', marginTop: 4 }}> <div style={{ fontSize: 11, color: '#C0C5D2', marginTop: 4 }}>
{c.startedAt?.split('T')[0]} {c.startedAt?.split('T')[0]}
</div> </div>
</div> </div>
</Link> </Link>
))} ))}
{consultations.length === 0 && ( {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>
</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 RawFollowUp { id: string; scheduledAt: string; title: string; status: string; }
interface RawReport { id: 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() { export function DashboardPage() {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const [stats, setStats] = useState<DashboardStats>({ const [stats, setStats] = useState<DashboardStats>({
@@ -22,7 +57,7 @@ export function DashboardPage() {
const [patients, consultations, reports, followUps] = await Promise.all([ const [patients, consultations, reports, followUps] = await Promise.all([
api.get<RawPatient[]>('/api/patients'), api.get<RawPatient[]>('/api/patients'),
api.get<RawConsultation[]>('/api/consultations'), api.get<RawConsultation[]>('/api/consultations'),
api.get<RawReport[]>('/api/reports?status=pending'), api.get<RawReport[]>('/api/reports/pending'),
api.get<RawFollowUp[]>('/api/follow-ups'), api.get<RawFollowUp[]>('/api/follow-ups'),
]); ]);
setStats({ setStats({
@@ -41,58 +76,69 @@ export function DashboardPage() {
loadStats(); loadStats();
}, []); }, []);
return ( const statItems = [
<div style={{ padding: 24 }}> { label: '患者总数', value: stats.totalPatients, color: '#4F6EF7', bg: '#EDF0FD' },
<h2 style={{ marginBottom: 20 }}>{user?.name}</h2> { 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 }}> const quickActions = [
{[ { label: '患者列表', href: '/patients', color: '#4F6EF7', bg: '#EDF0FD' },
{ label: '患者总数', value: stats.totalPatients, color: '#1976d2' }, { label: '在线问诊', href: '/consultations', color: '#20C997', bg: '#E6F9F2' },
{ label: '进行中问诊', value: stats.activeConsultations, color: '#388e3c' }, { label: '报告审核', href: '/reports', color: '#F59E0B', bg: '#FFF8E6' },
{ label: '待审核报告', value: stats.pendingReports, color: '#f57c00' }, { label: '随访管理', href: '/follow-ups', color: '#845EF7', bg: '#F3E8FF' },
{ label: '今日随访', value: stats.todayFollowUps, color: '#7b1fa2' }, ];
].map((item) => (
<div key={item.label} style={{ return (
background: '#fff', padding: 20, borderRadius: 8, <div style={{ padding: 28 }}>
borderLeft: `4px solid ${item.color}`, boxShadow: '0 1px 4px rgba(0,0,0,0.08)', <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={{ fontSize: 28, fontWeight: 700, color: item.color }}>{item.value}</div>
<div style={{ fontSize: 13, color: '#888', marginTop: 4 }}>{item.label}</div> <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> </div>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 16 }}> <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)' }}> <div style={{ background: '#fff', padding: 22, borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
<h3 style={{ marginBottom: 12, fontSize: 15 }}></h3> <h3 style={{ marginBottom: 16, fontSize: 16, fontWeight: 600, color: '#1A1D28' }}></h3>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{[ {quickActions.map((action) => (
{ label: '患者列表', href: '/patients' },
{ label: '在线问诊', href: '/consultations' },
{ label: '报告审核', href: '/reports' },
{ label: '随访管理', href: '/follow-ups' },
].map((action) => (
<Link key={action.label} to={action.href} style={{ <Link key={action.label} to={action.href} style={{
padding: '8px 16px', background: '#f0f2f5', borderRadius: 4, padding: '10px 18px', background: action.bg, borderRadius: 10,
textDecoration: 'none', color: '#1976d2', fontSize: 13, 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} {action.label}
</Link> </Link>
))} ))}
</div> </div>
</div> </div>
<div style={{ background: '#fff', padding: 20, borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}> <div style={{ background: '#fff', padding: 22, borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
<h3 style={{ marginBottom: 12, fontSize: 15 }}></h3> <h3 style={{ marginBottom: 14, fontSize: 16, fontWeight: 600, color: '#1A1D28' }}></h3>
<ul style={{ fontSize: 13, color: '#666', listStyle: 'none', padding: 0 }}> <ul style={{ fontSize: 13, color: '#5A6072', listStyle: 'none', padding: 0 }}>
<li style={{ padding: '6px 0', borderBottom: '1px solid #f0f0f0' }}> <li style={{ padding: '10px 0', borderBottom: '1px solid #F0F2F5', display: 'flex', alignItems: 'center', gap: 8 }}>
📋 : {stats.pendingReports} {todoIcons.reports}
: <strong style={{ color: '#F59E0B' }}>{stats.pendingReports}</strong>
</li> </li>
<li style={{ padding: '6px 0', borderBottom: '1px solid #f0f0f0' }}> <li style={{ padding: '10px 0', borderBottom: '1px solid #F0F2F5', display: 'flex', alignItems: 'center', gap: 8 }}>
💬 : {stats.activeConsultations} {todoIcons.consultations}
: <strong style={{ color: '#4F6EF7' }}>{stats.activeConsultations}</strong>
</li> </li>
<li style={{ padding: '6px 0' }}> <li style={{ padding: '10px 0', display: 'flex', alignItems: 'center', gap: 8 }}>
📅 访: {stats.todayFollowUps} {todoIcons.followups}
访: <strong style={{ color: '#845EF7' }}>{stats.todayFollowUps}</strong>
</li> </li>
</ul> </ul>
</div> </div>

View File

@@ -29,30 +29,35 @@ export function FollowUpEditPage() {
e.preventDefault(); e.preventDefault();
const body = { title, patientId, scheduledAt, notes }; const body = { title, patientId, scheduledAt, notes };
try { try {
if (isNew) { if (isNew) { await api.post('/api/follow-ups', body); }
await api.post('/api/follow-ups', body); else { await api.put(`/api/follow-ups/${id}`, body); }
} else {
await api.put(`/api/follow-ups/${id}`, body);
}
navigate('/follow-ups'); navigate('/follow-ups');
} catch { alert('操作失败'); } } catch { alert('操作失败'); }
}; };
return ( const inputStyle: React.CSSProperties = {
<div style={{ padding: 24 }}> width: '100%', padding: '10px 14px', border: '1.5px solid #E1E5ED',
<h2 style={{ marginBottom: 16 }}>{isNew ? '新建随访' : '编辑随访'}</h2> 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 }}> return (
<div style={{ marginBottom: 14 }}> <div style={{ padding: 28 }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}></label> <h2 style={{ marginBottom: 20, fontSize: 20, fontWeight: 700, color: '#1A1D28' }}>{isNew ? '新建复查' : '编辑复查'}</h2>
<input value={title} onChange={(e) => setTitle(e.target.value)} required
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={{ 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>
<div style={{ marginBottom: 14 }}> <div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}></label> <label style={labelStyle}></label>
<select value={patientId} onChange={(e) => setPatientId(e.target.value)} required <select value={patientId} onChange={(e) => setPatientId(e.target.value)} required style={inputStyle}>
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4 }}>
<option value=""></option> <option value=""></option>
{patients.map((p) => ( {patients.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option> <option key={p.id} value={p.id}>{p.name}</option>
@@ -60,21 +65,23 @@ export function FollowUpEditPage() {
</select> </select>
</div> </div>
<div style={{ marginBottom: 14 }}> <div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}></label> <label style={labelStyle}></label>
<input type="datetime-local" value={scheduledAt} onChange={(e) => setScheduledAt(e.target.value)} required <input type="datetime-local" value={scheduledAt} onChange={(e) => setScheduledAt(e.target.value)} required style={inputStyle}
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4 }} /> onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
</div> </div>
<div style={{ marginBottom: 18 }}> <div style={{ marginBottom: 20 }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}></label> <label style={labelStyle}></label>
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={3} <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> </div>
<button type="submit" style={{ <button type="submit" style={{
padding: '10px 24px', background: '#1976d2', color: '#fff', padding: '11px 28px', background: 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)', color: '#fff',
border: 'none', borderRadius: 4, fontSize: 14, border: 'none', borderRadius: 10, fontSize: 14, fontWeight: 600,
boxShadow: '0 4px 16px rgba(79,110,247,0.25)',
}}> }}>
{isNew ? '创建' : '保存'} {isNew ? '创建' : '保存'}
</button> </button>

View File

@@ -2,81 +2,122 @@ import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { api } from '../../services/api-client'; import { api } from '../../services/api-client';
interface FollowUpItem {
id: string; patientId: string; patientName: string;
title: string; scheduledAt: string; status: string;
}
interface RawFollowUpItem { interface RawFollowUpItem {
id: string; patientId: string; patientName?: string; id: string; patientId: string; patientName?: string;
title: string; scheduledAt: string; status: string; title: string; scheduledAt: string; status: string;
} }
export function FollowUpListPage() { export function FollowUpListPage() {
const [followUps, setFollowUps] = useState<FollowUpItem[]>([]); const [followUps, setFollowUps] = useState<RawFollowUpItem[]>([]);
const [showCompleted, setShowCompleted] = useState(false);
useEffect(() => { const load = () => {
api.get<RawFollowUpItem[]>('/api/follow-ups').then((r) => { api.get<RawFollowUpItem[]>('/api/follow-ups?type=recheck')
const mapped = r.data.map((f) => ({ .then((r) => setFollowUps(r.data)).catch(() => {});
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' };
}
}; };
useEffect(() => { load(); }, []);
const handleComplete = async (e: React.MouseEvent, id: string) => {
e.stopPropagation();
try {
await api.put(`/api/follow-ups/${id}`, { status: 'completed' });
load();
} catch { /* ignore */ }
};
const handleDelete = async (e: React.MouseEvent, id: string) => {
e.stopPropagation();
if (!confirm('确定删除?')) return;
try {
await api.del(`/api/follow-ups/${id}`);
load();
} catch { /* ignore */ }
};
const now = new Date();
const statusLabel = (f: RawFollowUpItem) => {
if (f.status === 'completed') return { text: '已完成', color: '#20C997', bg: '#E6F9F2' };
if (f.status === 'cancelled') return { text: '已取消', color: '#EF4444', bg: '#FEE9E9' };
if (f.scheduledAt && new Date(f.scheduledAt) < now) return { text: '已过期', color: '#EF4444', bg: '#FEE9E9' };
return { text: '待复查', color: '#F59E0B', bg: '#FFF8E6' };
};
const active = followUps.filter((f) => f.status !== 'completed');
const completed = followUps.filter((f) => f.status === 'completed');
const displayed = [...active, ...(showCompleted ? completed : [])];
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 28 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<h2>访</h2> <h2 style={{ fontSize: 20, fontWeight: 700, color: '#1A1D28', margin: 0 }}></h2>
<Link to="/follow-ups/new/edit" style={{ <Link to="/follow-ups/new/edit" style={{
padding: '8px 16px', background: '#1976d2', color: '#fff', padding: '10px 20px', background: 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)', color: '#fff',
borderRadius: 4, textDecoration: 'none', fontSize: 13, borderRadius: 10, textDecoration: 'none', fontSize: 13, fontWeight: 600,
boxShadow: '0 4px 16px rgba(79,110,247,0.25)',
}}> }}>
+ 访 +
</Link> </Link>
</div> </div>
<p style={{ marginBottom: 18, fontSize: 13, color: '#9BA0B4' }}> {followUps.length} </p>
<div style={{ background: '#fff', borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{followUps.map((f) => { {displayed.map((f) => {
const s = statusLabel(f.status); const s = statusLabel(f);
return ( return (
<div key={f.id} style={{ <div key={f.id} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '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>
<div style={{ fontSize: 14, fontWeight: 500 }}>{f.title}</div> <div style={{ fontSize: 14, fontWeight: 600, color: '#1A1D28' }}>{f.title}</div>
<div style={{ fontSize: 12, color: '#888', marginTop: 2 }}> <div style={{ fontSize: 12, color: '#9BA0B4', marginTop: 3 }}>
{f.patientName} · {f.scheduledAt?.split('T')[0]} {f.patientName || '未知'} · {f.scheduledAt?.split('T')[0]}
</div> </div>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ padding: '2px 8px', borderRadius: 10, fontSize: 11, background: s.bg, color: s.color }}> <span style={{ padding: '4px 12px', borderRadius: 10, fontSize: 11, fontWeight: 500, background: s.bg, color: s.color }}>
{s.text} {s.text}
</span> </span>
<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>
</div> </div>
); );
})} })}
{followUps.length === 0 && ( {followUps.length === 0 && (
<div style={{ padding: 40, textAlign: 'center', color: '#999' }}>访</div> <div style={{ padding: 40, textAlign: 'center', color: '#9BA0B4', fontSize: 13 }}></div>
)} )}
</div> </div>
{completed.length > 0 && (
<button onClick={() => setShowCompleted(!showCompleted)} style={{
display: 'flex', alignItems: 'center', gap: 6, margin: '12px auto 0',
padding: '8px 20px', borderRadius: 20, border: '1px solid #E4E8EE',
background: '#fff', color: '#9BA0B4', fontSize: 12, cursor: 'pointer',
}}>
{showCompleted ? '收起已完成' : `查看已完成 (${completed.length})`}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ transform: showCompleted ? 'rotate(180deg)' : '' }}>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
)}
</div> </div>
); );
} }

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 { useParams, Link } from 'react-router-dom';
import { api } from '../../services/api-client'; import { api } from '../../services/api-client';
import { MultiLineChart, type SeriesData } from '../../components/charts/MultiLineChart';
interface PatientDetail { interface PatientDetail {
id: string; name: string; phone: string; gender: string; birthday: string; 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; 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() { export function PatientDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [patient, setPatient] = useState<PatientDetail | null>(null); const [patient, setPatient] = useState<PatientDetail | null>(null);
const [records, setRecords] = useState<HealthRecord[]>([]); const [records, setRecords] = useState<HealthRecord[]>([]);
const [exercises, setExercises] = useState<ExerciseEntry[]>([]);
const [diets, setDiets] = useState<DietEntry[]>([]);
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
// Fetch patient detail directly by ID + health records
api.get<PatientDetail>(`/api/patients/${id}`).then((r) => { api.get<PatientDetail>(`/api/patients/${id}`).then((r) => {
if (r.data) setPatient(r.data); if (r.data) setPatient(r.data);
}).catch(() => {}); }).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]); }, [id]);
if (!patient) return <div style={{ padding: 24 }}>...</div>; if (!patient) return <div style={{ padding: 28, color: '#9BA0B4' }}>...</div>;
const latestByType: Record<string, HealthRecord> = {}; const latestByType: Record<string, HealthRecord> = {};
records.forEach((r) => { records.forEach((r) => {
@@ -44,38 +100,209 @@ export function PatientDetailPage() {
}; };
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 28, maxWidth: 1100 }}>
<Link to="/patients" style={{ fontSize: 13, color: '#1976d2' }}> </Link> <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)' }}> {/* Patient info card */}
<h2>{patient.name}</h2> <div style={{ background: '#fff', marginTop: 16, padding: 28, borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px 24px', marginTop: 12, fontSize: 14 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 20 }}>
<div>{patient.phone}</div> <div style={{
<div>{patient.gender || '-'}</div> width: 52, height: 52, borderRadius: 16,
<div>{patient.birthday || '-'}</div> background: 'linear-gradient(135deg, #4F6EF7, #6C8AFF)',
<div>{patient.heightCm}cm / {patient.weightKg}kg</div> display: 'flex', alignItems: 'center', justifyContent: 'center',
<div>{(patient.medicalHistory || []).join('、') || '-'}</div> fontSize: 20, fontWeight: 700, color: '#fff',
<div>{patient.stentDate || '-'}</div> }}>
<div>{patient.stentType || '-'}</div> {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>
</div> </div>
<h3 style={{ marginTop: 24, marginBottom: 12 }}></h3> {/* Health vitals */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}> <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]) => ( {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 key={type} style={{
<div style={{ fontSize: 12, color: '#888' }}> background: '#fff', padding: 20, borderRadius: 16,
{type === 'blood_pressure' ? '血压' : type === 'heart_rate' ? '心率' : type} 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>
<div style={{ fontSize: 20, fontWeight: 600, marginTop: 4 }}> <div style={{ fontSize: 22, fontWeight: 800, color: '#1A1D28' }}>
{parseValueDisplay(record)} {record.unit} {parseValueDisplay(record)} <span style={{ fontSize: 13, fontWeight: 500, color: '#9BA0B4' }}>{record.unit}</span>
</div> </div>
<div style={{ fontSize: 11, color: '#bbb', marginTop: 4 }}> <div style={{ fontSize: 11, color: '#C0C5D2', marginTop: 6 }}>
{record.recordedAt?.split('T')[0]} {record.recordedAt?.split('T')[0]}
</div> </div>
</div> </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> </div>
); );
} }

View File

@@ -4,7 +4,7 @@ import { api } from '../../services/api-client';
interface Patient { interface Patient {
id: string; name: string; phone: string; gender: string; id: string; name: string; phone: string; gender: string;
medicalHistory: string[]; stentDate: string; medicalHistory: string[]; stentDate: string; stentType: string;
} }
export function PatientListPage() { export function PatientListPage() {
@@ -24,40 +24,52 @@ export function PatientListPage() {
); );
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 28 }}>
<h2 style={{ marginBottom: 16 }}></h2> <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="搜索姓名或手机号..." <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> : ( {loading ? <div style={{ color: '#9BA0B4' }}>...</div> : (
<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' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead> <thead>
<tr style={{ borderBottom: '2px solid #f0f0f0', textAlign: 'left' }}> <tr style={{ borderBottom: '2px solid #F0F2F5', textAlign: 'left', background: '#F9FAFC' }}>
<th style={{ padding: '12px 16px' }}></th> <th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}></th>
<th style={{ padding: '12px 16px' }}></th> <th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}></th>
<th style={{ padding: '12px 16px' }}></th> <th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}></th>
<th style={{ padding: '12px 16px' }}></th> <th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}></th>
<th style={{ padding: '12px 16px' }}></th> <th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}></th>
<th style={{ padding: '12px 16px' }}></th> <th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}></th>
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filtered.map((p) => ( {filtered.map((p) => (
<tr key={p.id} style={{ borderBottom: '1px solid #f5f5f5' }}> <tr key={p.id} style={{ borderBottom: '1px solid #F5F6F9' }}>
<td style={{ padding: '10px 16px' }}>{p.name}</td> <td style={{ padding: '12px 20px', fontWeight: 500 }}>{p.name}</td>
<td style={{ padding: '10px 16px', color: '#888' }}>{p.phone}</td> <td style={{ padding: '12px 20px', color: '#9BA0B4' }}>{p.phone}</td>
<td style={{ padding: '10px 16px' }}>{p.gender || '-'}</td> <td style={{ padding: '12px 20px' }}>{p.gender || '-'}</td>
<td style={{ padding: '10px 16px' }}>{(p.medicalHistory || []).slice(0, 3).join('、') || '-'}</td> <td style={{ padding: '12px 20px', color: '#5A6072' }}>{(p.medicalHistory || []).slice(0, 3).join('、') || '-'}</td>
<td style={{ padding: '10px 16px' }}>{p.stentDate || '-'}</td> <td style={{ padding: '12px 20px', color: '#5A6072' }}>{p.stentDate || '-'}</td>
<td style={{ padding: '10px 16px' }}> <td style={{ padding: '12px 20px', color: '#5A6072' }}>{p.stentType || '-'}</td>
<Link to={`/patients/${p.id}`} style={{ color: '#1976d2', fontSize: 13 }}></Link> <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> </td>
</tr> </tr>
))} ))}
{filtered.length === 0 && ( {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> </tbody>
</table> </table>

View File

@@ -7,7 +7,7 @@ interface RawReport {
imageUrls: string[]; status: string; riskLevel?: string; imageUrls: string[]; status: string; riskLevel?: string;
summary?: string; suggestions?: string; summary?: string; suggestions?: string;
patientName?: string; doctorName?: string; patientName?: string; doctorName?: string;
createdAt: string; completedAt?: string; uploadedAt: string; completedAt?: string;
items?: RawItem[]; items?: RawItem[];
} }
@@ -76,7 +76,7 @@ export function ReportDetailPage() {
<div style={{ marginTop: 8, fontSize: 13, color: '#888' }}> <div style={{ marginTop: 8, fontSize: 13, color: '#888' }}>
{report.patientName || '未知'} &nbsp;|&nbsp; {report.patientName || '未知'} &nbsp;|&nbsp;
{categoryMap[report.category] || report.category} &nbsp;|&nbsp; {categoryMap[report.category] || report.category} &nbsp;|&nbsp;
{report.createdAt?.split('T')[0]} {report.uploadedAt?.split('T')[0]}
</div> </div>
</div> </div>
<span style={{ <span style={{

View File

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

View File

@@ -24,10 +24,8 @@ export function ProfilePage() {
e.preventDefault(); e.preventDefault();
try { try {
await api.put('/api/auth/me', { await api.put('/api/auth/me', {
name: form.name, name: form.name, department: form.department,
department: form.department, title: form.title, introduction: form.introduction,
title: form.title,
introduction: form.introduction,
}); });
updateProfile(form); updateProfile(form);
alert('保存成功'); alert('保存成功');
@@ -36,44 +34,71 @@ export function ProfilePage() {
if (!user) return null; 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 ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 28 }}>
<h2 style={{ marginBottom: 16 }}></h2> <h2 style={{ marginBottom: 20, fontSize: 20, fontWeight: 700, color: '#1A1D28' }}></h2>
<form onSubmit={handleSubmit} style={{ background: '#fff', padding: 24, borderRadius: 8, maxWidth: 500 }}> <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: 14 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 24, paddingBottom: 20, borderBottom: '1px solid #F0F2F5' }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}></label> <div style={{
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} width: 56, height: 56, borderRadius: 18,
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4 }} /> 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>
<div style={{ marginBottom: 14 }}> <div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}></label> <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 <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>
<div style={{ marginBottom: 14 }}> <div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}></label> <label style={labelStyle}></label>
<input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} <input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} style={inputStyle}
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4 }} /> onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
</div> </div>
<div style={{ marginBottom: 14 }}> <div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}></label> <label style={labelStyle}></label>
<input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} <input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} style={inputStyle}
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4 }} /> onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
</div> </div>
<div style={{ marginBottom: 18 }}> <div style={{ marginBottom: 20 }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}></label> <label style={labelStyle}></label>
<textarea value={form.introduction} onChange={(e) => setForm({ ...form, introduction: e.target.value })} rows={4} <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> </div>
<button type="submit" style={{ <button type="submit" style={{
padding: '10px 24px', background: '#1976d2', color: '#fff', padding: '11px 28px', background: 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)', color: '#fff',
border: 'none', borderRadius: 4, fontSize: 14, border: 'none', borderRadius: 10, fontSize: 14, fontWeight: 600,
boxShadow: '0 4px 16px rgba(79,110,247,0.25)',
}}> }}>
</button> </button>

View File

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

View File

@@ -1,13 +1,14 @@
{ {
"name": "haruite-medical-demo", "name": "health-manager-demo",
"version": "0.0.0", "version": "0.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "haruite-medical-demo", "name": "health-manager-demo",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@microsoft/signalr": "^10.0.0",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"echarts-for-react": "^3.0.6", "echarts-for-react": "^3.0.6",
@@ -550,6 +551,19 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", "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": { "node_modules/acorn": {
"version": "8.16.0", "version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -1648,6 +1674,24 @@
"node": ">=0.10.0" "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": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "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": { "node_modules/file-entry-cache": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -2323,6 +2377,26 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/node-releases": {
"version": "2.0.44", "version": "2.0.44",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz",
@@ -2459,16 +2533,33 @@
"node": ">= 0.8.0" "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": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "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": { "node_modules/react": {
"version": "19.2.6", "version": "19.2.6",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
@@ -2528,6 +2619,12 @@
"react-dom": ">=18" "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": { "node_modules/rolldown": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
@@ -2640,6 +2737,27 @@
"url": "https://github.com/sponsors/SuperchupuDev" "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": { "node_modules/ts-api-utils": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -2717,6 +2835,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/update-browserslist-db": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -2758,6 +2885,16 @@
"punycode": "^2.1.0" "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": { "node_modules/vite": {
"version": "8.0.13", "version": "8.0.13",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", "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": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -2862,6 +3015,27 @@
"node": ">=0.10.0" "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": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

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

View File

@@ -36,7 +36,51 @@
white-space: nowrap; 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 */ /* 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 { .page-enter {
animation: slideInRight 0.3s ease-out; animation: slideInRight 0.3s ease-out;
} }

View File

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

View File

@@ -4,7 +4,7 @@
justify-content: center; justify-content: center;
gap: var(--spacing-sm); gap: var(--spacing-sm);
border-radius: var(--radius-md); border-radius: var(--radius-md);
font-weight: 500; font-weight: 600;
transition: all 0.2s; transition: all 0.2s;
cursor: pointer; cursor: pointer;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
@@ -21,11 +21,13 @@
.lg { padding: 12px 24px; font-size: var(--font-size-md); } .lg { padding: 12px 24px; font-size: var(--font-size-md); }
.primary { .primary {
background: var(--color-primary); background: var(--color-primary-gradient);
color: var(--color-text-inverse); color: var(--color-text-inverse);
box-shadow: 0 4px 14px rgba(79,110,247,0.3);
} }
.primary:hover:not(:disabled) { .primary:hover:not(:disabled) {
background: var(--color-primary-dark); box-shadow: 0 6px 20px rgba(79,110,247,0.35);
transform: translateY(-1px);
} }
.secondary { .secondary {
@@ -48,24 +50,4 @@
.text { .text {
background: transparent; background: transparent;
color: var(--color-primary); 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 { .card {
background: var(--color-white); background: var(--color-white);
border-radius: var(--radius-lg); border-radius: var(--radius-xl);
padding: var(--spacing-lg); padding: 18px;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
transition: transform 0.2s, box-shadow 0.2s;
} }
.clickable { .clickable {
cursor: pointer; cursor: pointer;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
transition: transform 0.15s, box-shadow 0.15s;
} }
.clickable:active { .clickable:active {
transform: scale(0.98); transform: scale(0.985);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
} }

View File

@@ -7,11 +7,24 @@
} }
.icon { .icon {
font-size: 48px; width: 64px;
margin-bottom: 12px; 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 { .message {
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
color: var(--color-text-tertiary); color: var(--color-text-tertiary);
font-weight: 500;
} }

View File

@@ -1,14 +1,23 @@
import styles from './Empty.module.css'; import styles from './Empty.module.css';
interface EmptyProps { interface EmptyProps {
icon?: string; icon?: React.ReactNode;
message?: string; 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 ( return (
<div className={styles.empty}> <div className={styles.empty}>
<span className={styles.icon}>{icon}</span> <div className={styles.icon}>{icon}</div>
<p className={styles.message}>{message}</p> <p className={styles.message}>{message}</p>
</div> </div>
); );

View File

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

View File

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

View File

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

View File

@@ -9,13 +9,12 @@ export function AppLayout() {
return ( return (
<div className={styles.layout}> <div className={styles.layout}>
<main className={styles.main}> <main className={styles.main}>
<AnimatePresence mode="wait"> <AnimatePresence>
<motion.div <motion.div
key={location.pathname} key={location.pathname}
initial={{ opacity: 0, y: 8 }} initial={{ opacity: 0 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1 }}
exit={{ opacity: 0, y: -8 }} transition={{ duration: 0.12 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
> >
<Outlet /> <Outlet />
</motion.div> </motion.div>

View File

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

View File

@@ -5,13 +5,13 @@ export function StackLayout() {
const location = useLocation(); const location = useLocation();
return ( return (
<AnimatePresence mode="wait"> <AnimatePresence>
<motion.div <motion.div
key={location.pathname} key={location.pathname}
initial={{ opacity: 0, x: 40 }} initial={{ opacity: 0 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1 }}
exit={{ opacity: 0, x: -40 }} exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }} transition={{ duration: 0.15 }}
> >
<Outlet /> <Outlet />
</motion.div> </motion.div>

View File

@@ -7,11 +7,12 @@
max-width: var(--max-content-width); max-width: var(--max-content-width);
height: var(--tab-bar-height); height: var(--tab-bar-height);
background: var(--color-white); background: var(--color-white);
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-divider);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-around; justify-content: space-around;
z-index: var(--z-tab-bar); z-index: var(--z-tab-bar);
padding: 0 8px;
padding-bottom: env(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom);
} }
@@ -21,9 +22,8 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 2px; gap: 2px;
padding: var(--spacing-xs) var(--spacing-md); padding: 6px 0;
min-width: 56px; min-width: 56px;
min-height: 44px;
color: var(--color-text-tertiary); color: var(--color-text-tertiary);
transition: color 0.2s; transition: color 0.2s;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
@@ -34,33 +34,57 @@
} }
.tabIcon { .tabIcon {
font-size: 22px; width: 44px;
line-height: 1; 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 { .tabLabel {
font-size: var(--font-size-xs); font-size: 10px;
font-weight: 500; font-weight: 500;
transition: color 0.2s;
} }
.tabIcon { .tabActive .tabLabel {
position: relative; font-weight: 600;
} }
.badge { .badge {
position: absolute; position: absolute;
top: -6px; top: -4px;
right: -10px; right: -6px;
min-width: 16px; min-width: 18px;
height: 16px; height: 18px;
padding: 0 4px; padding: 0 5px;
background: #EF4444; background: var(--color-accent-red);
color: #fff; color: #fff;
border-radius: 10px; border-radius: 10px;
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 700;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
line-height: 1; line-height: 1;
border: 2px solid #fff;
} }

View File

@@ -1,12 +1,51 @@
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { NAV_ITEMS } from '@/utils/constants';
import { useNotificationStore } from '@/stores/notification.store';
import styles from './TabBar.module.css'; 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() { export function TabBar() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const unreadCount = useNotificationStore((s) => s.unreadCount);
return ( return (
<nav className={styles.tabBar}> <nav className={styles.tabBar}>
@@ -18,12 +57,7 @@ export function TabBar() {
className={`${styles.tab} ${isActive ? styles.tabActive : ''}`} className={`${styles.tab} ${isActive ? styles.tabActive : ''}`}
onClick={() => navigate(item.path)} onClick={() => navigate(item.path)}
> >
<span className={styles.tabIcon}> <span className={styles.tabIcon}>{item.svg}</span>
{item.icon}
{item.path === '/services' && unreadCount > 0 && (
<span className={styles.badge}>{unreadCount > 99 ? '99+' : unreadCount}</span>
)}
</span>
<span className={styles.tabLabel}>{item.label}</span> <span className={styles.tabLabel}>{item.label}</span>
</button> </button>
); );

View File

@@ -48,7 +48,11 @@ export function LoginPage() {
return ( return (
<div className={styles.page}> <div className={styles.page}>
<div className={styles.header}> <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> <h1 className={styles.title}></h1>
<p className={styles.subtitle}></p> <p className={styles.subtitle}></p>
</div> </div>

View File

@@ -1,22 +1,339 @@
.tabs { display: flex; gap: 8px; margin-bottom: 16px; } /* Tabs */
.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); } .tabs {
.tabActive { background: var(--color-primary); color: var(--color-text-inverse); } display: flex;
.sectionTitle { font-size: var(--font-size-base); font-weight: 600; margin: 16px 0 8px; } gap: 8px;
.recCard { margin-bottom: 8px; } margin-bottom: 16px;
.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); } .tab {
.suitNo { background: var(--color-danger-bg); color: var(--color-danger); } flex: 1;
.notSuitable { opacity: 0.5; } padding: 12px;
.recMeta { font-size: var(--font-size-xs); color: var(--color-text-tertiary); } border-radius: 14px;
.recDesc { font-size: var(--font-size-xs); color: var(--color-text-secondary); margin: 6px 0; } background: #F5F6F9;
.foodTags { display: flex; gap: 6px; flex-wrap: wrap; } border: none;
.foodTag { padding: 2px 8px; font-size: var(--font-size-xs); background: var(--color-primary-bg); color: var(--color-primary); border-radius: var(--radius-sm); } font-size: 15px;
.addCard { margin-bottom: 12px; display: flex; flex-direction: column; gap: 10px; } font-weight: 600;
.addRow { display: flex; gap: 8px; align-items: center; } color: #6B7280;
.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; } cursor: pointer;
.intensityRow { display: flex; gap: 8px; } transition: all 0.25s ease;
.intensityBtn { flex: 1; padding: 6px; font-size: var(--font-size-xs); background: var(--color-bg); border-radius: var(--radius-md); } display: flex;
.intensityActive { background: var(--color-primary-bg); color: var(--color-primary); } align-items: center;
.logCard { margin-bottom: 6px; } justify-content: center;
.logDate { font-size: var(--font-size-xs); color: var(--color-text-tertiary); margin-top: 2px; } 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 { PageHeader } from '@/components/layout/PageHeader';
import { Card } from '@/components/common/Card'; import { Card } from '@/components/common/Card';
import { Button } from '@/components/common/Button'; import { Button } from '@/components/common/Button';
@@ -10,144 +10,280 @@ import type { ExerciseRecord, DietRecord } from '@/types';
import { formatDate } from '@/utils/format'; import { formatDate } from '@/utils/format';
import styles from './ExerciseDietPage.module.css'; 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() { export function ExerciseDietPage() {
const [subTab, setSubTab] = useState<'recommend' | 'exercise' | 'diet'>('recommend'); const [tab, setTab] = useState<'exercise' | 'diet'>('exercise');
const [exercises, setExercises] = useState<ExerciseRecord[]>([]); const [exercises, setExercises] = useState<ExerciseRecord[]>([]);
const [diets, setDiets] = useState<DietRecord[]>([]); const [diets, setDiets] = useState<DietRecord[]>([]);
// exercise form
const [exType, setExType] = useState('散步'); const [exType, setExType] = useState('散步');
const [exDuration, setExDuration] = useState('30'); const [exDuration, setExDuration] = useState('30');
const [exIntensity, setExIntensity] = useState<'low' | 'moderate' | 'high'>('low'); const [exIntensity, setExIntensity] = useState<'low' | 'moderate' | 'high'>('low');
// diet form
const [mealType, setMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast'); const [mealType, setMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast');
const [foodName, setFoodName] = useState(''); const [foodName, setFoodName] = useState('');
const [foodAmount, setFoodAmount] = useState('1份');
const [foodKcal, setFoodKcal] = useState(''); const [foodKcal, setFoodKcal] = useState('');
const recommendations = exerciseDietService.getExerciseRecommendations(); const exRecommendations = exerciseDietService.getExerciseRecommendations();
const dietRecommendations = exerciseDietService.getDietRecommendations(); const dietRecommendations = exerciseDietService.getDietRecommendations();
const groupedExercises = useMemo(() => groupByDate(exercises).slice(0, 7), [exercises]);
const groupedDiets = useMemo(() => groupByDate(diets).slice(0, 7), [diets]);
useEffect(() => { useEffect(() => {
exerciseDietService.getExerciseLogs().then(setExercises); exerciseDietService.getExerciseLogs().then(setExercises);
exerciseDietService.getDietLogs().then(setDiets); 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 () => { const addExercise = async () => {
if (!exDuration) return; if (!exDuration) return;
await exerciseDietService.addExerciseLog({ await exerciseDietService.addExerciseLog({
type: exType, duration: parseInt(exDuration), intensity: exIntensity, 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); exerciseDietService.getExerciseLogs().then(setExercises);
}; };
const addDiet = async () => { const addDiet = async () => {
if (!foodName || !foodKcal) { toast('请填写食物信息', 'error'); return; } if (!foodName || !foodKcal) { toast('请填写食物名称和热量', 'error'); return; }
await exerciseDietService.addDietLog({ 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), totalCalories: parseInt(foodKcal), date: new Date().toISOString().slice(0, 10),
}); });
toast('记录成功'); setFoodName(''); setFoodAmount('1份'); setFoodKcal('');
toast('饮食记录成功');
exerciseDietService.getDietLogs().then(setDiets); 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 ( return (
<div className="page--no-tab"> <div className="page--no-tab">
<PageHeader title="运动饮食" /> <PageHeader title="运动饮食" />
{/* Tab switch */}
<div className={styles.tabs}> <div className={styles.tabs}>
{[ <button className={`${styles.tab} ${tab === 'exercise' ? styles.tabActive : ''}`} onClick={() => setTab('exercise')}>
{ key: 'recommend', label: '推荐' }, <span className={styles.tabIcon}>🏃</span>
{ key: 'exercise', label: '运动' }, </button>
{ key: 'diet', label: '饮食' }, <button className={`${styles.tab} ${tab === 'diet' ? styles.tabActive : ''}`} onClick={() => setTab('diet')}>
].map((t) => ( <span className={styles.tabIcon}>🥗</span>
<button key={t.key} className={`${styles.tab} ${subTab === t.key ? styles.tabActive : ''}`} onClick={() => setSubTab(t.key as typeof subTab)}>
{t.label}
</button> </button>
))}
</div> </div>
{subTab === 'recommend' && ( {/* ============ EXERCISE TAB ============ */}
<div> {tab === 'exercise' && (
<h3 className={styles.sectionTitle}></h3> <>
{recommendations.map((r, i) => ( {/* Today summary card */}
<Card key={i} className={`${styles.recCard} ${!r.suitable ? styles.notSuitable : ''}`}> <div className={styles.summaryCard}>
<div className={styles.recHeader}> <svg className={styles.summaryBg} viewBox="0 0 400 120" preserveAspectRatio="none">
<span>{r.icon} {r.name}</span> <ellipse cx="350" cy="10" rx="180" ry="140" fill="rgba(79,110,247,0.06)" />
<span className={`${styles.suitBadge} ${r.suitable ? styles.suitYes : styles.suitNo}`}> <ellipse cx="50" cy="100" rx="120" ry="60" fill="rgba(79,110,247,0.04)" />
{r.suitable ? '适合' : '不适合'} </svg>
</span> <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>
<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> </div>
</Card>
))}
</div> </div>
)}
{subTab === 'exercise' && ( {/* Add record */}
<div>
<Card className={styles.addCard}> <Card className={styles.addCard}>
<div className={styles.addRow}> <div className={styles.addGrid}>
<select className={styles.select} value={exType} onChange={(e) => setExType(e.target.value)}> <select className={styles.select} value={exType} onChange={(e) => setExType(e.target.value)}>
{['散步', '慢跑', '太极拳', '游泳', '骑自行车', '八段锦'].map((t) => ( {EXERCISE_TYPES.map((t) => (<option key={t}>{t}</option>))}
<option key={t}>{t}</option>
))}
</select> </select>
<Input value={exDuration} onChange={(e) => setExDuration(e.target.value)} type="number" placeholder="分钟" /> <Input value={exDuration} onChange={(e) => setExDuration(e.target.value)} type="number" placeholder="分钟" />
</div> </div>
<div className={styles.intensityRow}> <div className={styles.intensityRow}>
{['low', 'moderate', 'high'].map((i) => ( {INTENSITIES.map((i) => (
<button key={i} className={`${styles.intensityBtn} ${exIntensity === i ? styles.intensityActive : ''}`} onClick={() => setExIntensity(i as typeof exIntensity)}> <button key={i.key} className={`${styles.intensityBtn} ${exIntensity === i.key ? styles.intensityActive : ''}`}
{{ low: '低强度', moderate: '中强度', high: '高强度' }[i]} onClick={() => setExIntensity(i.key)}>
{i.emoji} {i.label}
</button> </button>
))} ))}
</div> </div>
<Button size="sm" onClick={addExercise}></Button> <Button size="sm" onClick={addExercise}></Button>
</Card> </Card>
{exercises.length === 0 ? <Empty message="暂无运动记录" /> : exercises.slice(0, 10).map((e) => ( {/* Recent records */}
<Card key={e.id} className={styles.logCard}> {groupedExercises.length === 0 ? (
<div>{e.type} · {e.duration} · {e.caloriesBurned}kcal</div> <Empty message="暂无运动记录,开始记录吧" />
<div className={styles.logDate}>{formatDate(e.date, 'MM-DD')}</div> ) : (
</Card> 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> </div>
))
)} )}
{subTab === 'diet' && ( {/* Recommendations */}
<div> <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}> <Card className={styles.addCard}>
<div className={styles.addRow}> <div className={styles.mealTabRow}>
<select className={styles.select} value={mealType} onChange={(e) => setMealType(e.target.value as typeof mealType)}> {MEAL_TYPES.map((m) => (
<option value="breakfast"></option> <button key={m.key} className={`${styles.mealTab} ${mealType === m.key ? styles.mealTabActive : ''}`}
<option value="lunch"></option> onClick={() => setMealType(m.key)}>
<option value="dinner"></option> {m.icon} {m.label}
<option value="snack"></option> </button>
</select> ))}
<Input value={foodName} onChange={(e) => setFoodName(e.target.value)} placeholder="食物名" /> </div>
<Input value={foodKcal} onChange={(e) => setFoodKcal(e.target.value)} type="number" placeholder="kcal" /> <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> </div>
<Button size="sm" onClick={addDiet}></Button> <Button size="sm" onClick={addDiet}></Button>
</Card> </Card>
{diets.length === 0 ? <Empty message="暂无饮食记录" /> : diets.slice(0, 10).map((d) => ( {/* Recent records */}
<Card key={d.id} className={styles.logCard}> {groupedDiets.length === 0 ? (
<div>{d.mealType === 'breakfast' ? '🌅' : d.mealType === 'lunch' ? '🌞' : d.mealType === 'dinner' ? '🌙' : '🍪'} {d.foods.map((f) => f.name).join(', ')}</div> <Empty message="暂无饮食记录,开始记录吧" />
<div className={styles.logDate}>{d.totalCalories}kcal · {formatDate(d.date, 'MM-DD')}</div> ) : (
</Card> 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> </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 /> <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 { PageHeader } from '@/components/layout/PageHeader';
import { Card } from '@/components/common/Card'; import { Card } from '@/components/common/Card';
import type { CalendarDay } from '@/types'; import type { CalendarDay } from '@/types';
import { api } from '@/services/api-client';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import styles from './HealthCalendarPage.module.css'; import styles from './HealthCalendarPage.module.css';
const MARKER_COLORS: Record<string, string> = { interface MedRecord { medicationId: string; timeSlot: string; takenAt?: string | null; isTaken: boolean; }
medication_taken: '#10B981', interface HealthRecord { id: string; type: string; recordedAt: string; }
medication_missed: '#EF4444', interface Medication { id: string; drugName: string; timeSlots: string[]; status: string; startDate: string; endDate?: string | null; }
follow_up: '#F59E0B',
measurement: '#2563EB',
};
export function HealthCalendarPage() { export function HealthCalendarPage() {
const [currentDate, setCurrentDate] = useState(dayjs()); 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 calendarDays = useMemo(() => {
const startOfMonth = currentDate.startOf('month'); const startOfMonth = currentDate.startOf('month');
const endOfMonth = currentDate.endOf('month'); const endOfMonth = currentDate.endOf('month');
const startDay = startOfMonth.day(); const startDay = startOfMonth.day();
const days: CalendarDay[] = []; const days: CalendarDay[] = [];
const today = dayjs().format('YYYY-MM-DD'); 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--) { for (let i = startDay - 1; i >= 0; i--) {
const d = startOfMonth.subtract(i + 1, 'day'); const d = startOfMonth.subtract(i + 1, 'day');
days.push({ 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: [] });
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')) { for (let d = startOfMonth; d.isBefore(endOfMonth) || d.isSame(endOfMonth, 'day'); d = d.add(1, 'day')) {
const dateStr = d.format('YYYY-MM-DD'); const dateStr = d.format('YYYY-MM-DD');
const markers: CalendarDay['markers'] = []; const markers: CalendarDay['markers'] = [];
const dm = dateMap[dateStr];
// Calendar markers would be populated from real API data if (dm) {
if (dm.taken > 0) {
days.push({ markers.push({ type: 'medication_taken', color: '#10B981', count: dm.taken });
date: dateStr, }
year: d.year(), if (dm.missed > 0) {
month: d.month() + 1, markers.push({ type: 'medication_missed', color: '#EF4444', count: dm.missed });
day: d.date(), }
isCurrentMonth: true, } else {
isToday: dateStr === today, // Check if any medication should have been taken on this date
markers, 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; return days;
}, [currentDate]); }, [currentDate, medRecords, medications]);
const weeks: CalendarDay[][] = []; const weeks: CalendarDay[][] = [];
for (let i = 0; i < calendarDays.length; i += 7) { for (let i = 0; i < calendarDays.length; i += 7) {
@@ -79,18 +106,11 @@ export function HealthCalendarPage() {
{weeks.map((week, wi) => ( {weeks.map((week, wi) => (
<div key={wi} className={styles.week}> <div key={wi} className={styles.week}>
{week.map((day) => ( {week.map((day) => (
<div <div key={day.date} className={`${styles.day} ${!day.isCurrentMonth ? styles.outside : ''} ${day.isToday ? styles.today : ''}`}>
key={day.date}
className={`${styles.day} ${!day.isCurrentMonth ? styles.outside : ''} ${day.isToday ? styles.today : ''}`}
>
<span className={styles.dayNum}>{day.day}</span> <span className={styles.dayNum}>{day.day}</span>
<div className={styles.markers}> <div className={styles.markers}>
{day.markers.slice(0, 3).map((m, i) => ( {day.markers.slice(0, 3).map((m, i) => (
<span <span key={i} className={styles.dot} style={{ background: m.color }} />
key={i}
className={styles.dot}
style={{ background: m.color }}
/>
))} ))}
</div> </div>
</div> </div>
@@ -101,10 +121,9 @@ export function HealthCalendarPage() {
<Card className={styles.legend}> <Card className={styles.legend}>
<div className={styles.legendTitle}></div> <div className={styles.legendTitle}></div>
<div className={styles.legendItems}> <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: '#10B981' }} /> </span>
<span className={styles.legendItem}><span className={styles.dot} style={{ background: '#EF4444' }} /> </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> </div>
</Card> </Card>
</div> </div>

View File

@@ -1,45 +1,251 @@
.grid { /* Combined card */
display: grid; .combinedCard {
grid-template-columns: repeat(3, 1fr); display: flex;
gap: 10px; flex-direction: column;
margin-bottom: 16px; 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; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 6px; gap: 8px;
padding: 20px 12px; padding: 14px 8px;
background: var(--color-white); background: #fff;
border-radius: var(--radius-lg); border: none;
box-shadow: var(--shadow-sm); border-radius: 14px;
transition: transform 0.15s; box-shadow: 0 1px 8px rgba(0,0,0,0.04);
-webkit-tap-highlight-color: transparent; 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; } .quickIcon {
.cardTitle { font-size: var(--font-size-base); font-weight: 600; } width: 42px;
.cardDesc { font-size: var(--font-size-xs); color: var(--color-text-tertiary); } 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; display: flex;
flex-direction: column; 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; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 14px 16px; position: relative;
background: var(--color-white); z-index: 1;
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
font-size: var(--font-size-base);
-webkit-tap-highlight-color: transparent;
} }
.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 { useNavigate } from 'react-router-dom';
import { PageHeader } from '@/components/layout/PageHeader'; import { PageHeader } from '@/components/layout/PageHeader';
import { MEASUREMENT_TYPES } from '@/utils/constants';
import styles from './HealthHubPage.module.css'; 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() { export function HealthHubPage() {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<div className="page"> <div className="page">
<PageHeader title="健康中心" showBack={false} /> <PageHeader title="健康中心" showBack={false} />
<div className={styles.grid}>
<button {/* Combined indicators card */}
className={styles.card} <button className={styles.combinedCard} onClick={() => navigate(COMBINED.path)}>
onClick={() => navigate('/health/records?type=blood_pressure')} <div className={styles.combinedRow}>
> <div className={styles.combinedIcon}>
<span className={styles.cardIcon}>💓</span> <svg width="28" height="28" viewBox="0 0 32 32" fill="none">
<span className={styles.cardTitle}></span> <rect x="2" y="2" width="12" height="12" rx="3" fill="#EEF2FF" stroke="#4F6EF7" strokeWidth="1.5" />
<span className={styles.cardDesc}></span> <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>
<button
className={styles.card} {/* Quick links — horizontal row */}
onClick={() => navigate('/health/records?type=heart_rate')} <div className={styles.quickRow}>
> {QUICK_LINKS.map((link) => (
<span className={styles.cardIcon}></span> <button key={link.path} className={styles.quickCard} onClick={() => navigate(link.path)}>
<span className={styles.cardTitle}></span> <span className={styles.quickIcon} style={{ background: link.bg, color: link.color }}>
<span className={styles.cardDesc}></span> {link.svg}
</span>
<span className={styles.quickLabel}>{link.label}</span>
</button> </button>
<button ))}
className={styles.card} </div>
onClick={() => navigate('/health/records?type=blood_sugar')}
> {/* AI 健康助手 */}
<span className={styles.cardIcon}>🩸</span> <div className={styles.aiCard}>
<span className={styles.cardTitle}></span> <div className={styles.aiHeader}>
<span className={styles.cardDesc}></span> <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>
<button <button className={styles.aiQuestion}>
className={styles.card} <span className={styles.aiDot} />
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> </button>
</div> </div>
<div className={styles.extraLinks}> <div className={styles.inputHint}>
<button className={styles.linkCard} onClick={() => navigate('/health/calendar')}> <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" />
</button> <line x1="5" y1="12" x2="19" y2="12" />
<button className={styles.linkCard} onClick={() => navigate('/health/medications')}> </svg>
💊 AI ...
</button> </div>
<button className={styles.linkCard} onClick={() => navigate('/health/exercise-diet')}>
🏃
</button>
</div> </div>
</div> </div>
); );

View File

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

View File

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

View File

@@ -1,16 +1,152 @@
.form { .cards {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 10px;
padding: 16px 0;
} }
.bpRow { .card {
display: flex; background: #fff;
gap: 12px; border-radius: 14px;
padding: 12px 14px;
box-shadow: 0 1px 6px rgba(0,0,0,0.04);
} }
.row { .cardHeader {
display: flex; 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 { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/common/Button';
import { Input } from '@/components/common/Input';
import { PageHeader } from '@/components/layout/PageHeader'; import { PageHeader } from '@/components/layout/PageHeader';
import { ToastContainer, toast } from '@/components/common/Toast'; import { ToastContainer, toast } from '@/components/common/Toast';
import * as healthService from '@/services/health.service'; import * as healthService from '@/services/health.service';
import { MEASUREMENT_TYPES } from '@/utils/constants';
import type { MeasurementType } from '@/types';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import styles from './ManualEntryPage.module.css'; 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() { export function ManualEntryPage() {
const navigate = useNavigate(); 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 [values, setValues] = useState<Record<string, string>>({
const [diastolic, setDiastolic] = useState(''); systolic: '', diastolic: '', heart_rate: '', blood_sugar: '', spo2: '', weight: '',
const [value, setValue] = useState(''); });
const [date, setDate] = useState(dayjs().format('YYYY-MM-DD')); const [dates, setDates] = useState<Record<string, string>>(
const [time, setTime] = useState(dayjs().format('HH:mm')); Object.fromEntries(INDICATORS.map((i) => [i.type, dayjs().format('YYYY-MM-DD')])),
const [loading, setLoading] = useState(false); );
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') { if (type === 'blood_pressure') {
const sys = parseFloat(systolic); const sys = parseFloat(values.systolic);
const dia = parseFloat(diastolic); const dia = parseFloat(values.diastolic);
if (!sys || !dia) { toast('请填写完整', 'error'); return; } if (!sys || !dia) { toast('请填写收缩压和舒张压', 'error'); return; }
await healthService.addRecord({ await healthService.addRecord({ type, value: { systolic: sys, diastolic: dia }, unit: 'mmHg', recordedAt, recordedDate: d, source: 'manual' });
type,
value: { systolic: sys, diastolic: dia },
unit: 'mmHg',
recordedAt: `${date}T${time}:00`,
recordedDate: date,
source: 'manual',
});
} else { } else {
if (!numVal) { toast('请填写数值', 'error'); return; } const v = parseFloat(values[ind.fields[0].key]);
await healthService.addRecord({ if (!v) { toast('请填写数值', 'error'); return; }
type, await healthService.addRecord({ type, value: v, unit: ind.unit, recordedAt, recordedDate: d, source: 'manual' });
value: numVal, }
unit: config.unit, toast(`${ind.label} 已保存`);
recordedAt: `${date}T${time}:00`, } catch {
recordedDate: date, toast('保存失败', 'error');
source: 'manual', } finally {
}); setLoading(null);
} }
toast('记录成功');
setTimeout(() => navigate(-1), 500);
}; };
return ( return (
<div className="page--no-tab"> <div className="page--no-tab">
<PageHeader title={`新增${config.label}记录`} /> <PageHeader title="健康指标" />
<div className={styles.form}>
{type === 'blood_pressure' ? ( <div className={styles.cards}>
<> {INDICATORS.map((ind) => (
<div className={styles.bpRow}> <div key={ind.type} className={styles.card}>
<Input label="收缩压 (mmHg)" value={systolic} onChange={(e) => setSystolic(e.target.value)} type="number" /> <div className={styles.cardHeader}>
<Input label="舒张压 (mmHg)" value={diastolic} onChange={(e) => setDiastolic(e.target.value)} type="number" /> <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> </div>
</>
) : ( <div className={styles.cardInputs}>
<Input {ind.fields.map((f) => (
label={`${config.label} (${config.unit})`} <input
value={value} key={f.key}
onChange={(e) => setValue(e.target.value)} className={styles.cardInput}
type="number" 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>
)} )}
</button>
<div className={styles.row}> </div>
<Input label="日期" value={date} onChange={(e) => setDate(e.target.value)} type="date" /> </div>
<Input label="时间" value={time} onChange={(e) => setTime(e.target.value)} type="time" /> ))}
</div> </div>
<Button variant="primary" size="lg" fullWidth loading={loading} onClick={handleSubmit}> {/* Trend entry */}
<button className={styles.trendCard} onClick={() => navigate('/health/trends')}>
</Button> <span className={styles.trendIcon}>📈</span>
<div className={styles.trendInfo}>
<span className={styles.trendTitle}></span>
<span className={styles.trendSub}> · · · · </span>
</div> </div>
<span className={styles.trendArrow}></span>
</button>
<ToastContainer /> <ToastContainer />
</div> </div>
); );

View File

@@ -6,14 +6,46 @@
.periodBtn { .periodBtn {
padding: 6px 14px; padding: 6px 14px;
border-radius: var(--radius-full); border-radius: 20px;
font-size: var(--font-size-sm); border: none;
background: var(--color-bg-secondary); font-size: 13px;
color: var(--color-text-secondary); font-weight: 500;
background: #F5F6F9;
color: #6B7280;
cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
.active { .active {
background: var(--color-primary); background: #4F6EF7;
color: var (--color-text-inverse); 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 { useEffect, useState, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { PageHeader } from '@/components/layout/PageHeader'; 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 { Empty } from '@/components/common/Empty';
import { MEASUREMENT_TYPES } from '@/utils/constants';
import * as healthService from '@/services/health.service'; import * as healthService from '@/services/health.service';
import type { HealthRecord, MeasurementType } from '@/types'; import type { HealthRecord } from '@/types';
import { Button } from '@/components/common/Button';
import styles from './TrendChartPage.module.css'; 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 = [ const PERIODS = [
{ label: '7天', days: 7 }, { label: '7天', days: 7 },
{ label: '14天', days: 14 }, { label: '14天', days: 14 },
@@ -17,25 +23,60 @@ const PERIODS = [
]; ];
export function TrendChartPage() { export function TrendChartPage() {
const { type } = useParams<{ type: MeasurementType }>(); const [visible, setVisible] = useState<Set<string>>(new Set(INDICATORS.map((i) => i.type)));
const config = MEASUREMENT_TYPES[type || 'blood_pressure'];
const [records, setRecords] = useState<HealthRecord[]>([]);
const [period, setPeriod] = useState(30); const [period, setPeriod] = useState(30);
const [allRecords, setAllRecords] = useState<HealthRecord[]>([]);
useEffect(() => { useEffect(() => {
if (type) healthService.getTrendData(type, period).then(setRecords); const sources = [...new Set(INDICATORS.map((i) => (i as Record<string, string>).source || i.type))];
}, [type, period]); 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 series: SeriesData[] = useMemo(() => {
const chartData = records.map((r) => ({ 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, date: r.recordedDate,
value: isBP ? (typeof r.value === 'object' ? r.value.systolic : 0) : (r.value as number), value: typeof r.value === 'object' ? (r.value as Record<string, number>)[ind.field!] : 0,
value2: isBP ? (typeof r.value === 'object' ? r.value.diastolic : 0) : undefined, })),
})); 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 ( return (
<div className="page--no-tab"> <div className="page--no-tab">
<PageHeader title={`${config.label}趋势`} /> <PageHeader title="健康趋势" />
{/* Period selector */}
<div className={styles.periodBar}> <div className={styles.periodBar}>
{PERIODS.map((p) => ( {PERIODS.map((p) => (
<button <button
@@ -47,18 +88,31 @@ export function TrendChartPage() {
</button> </button>
))} ))}
</div> </div>
{chartData.length > 0 ? (
<LineChart {/* Toggle indicators */}
data={chartData} <div className={styles.toggleBar}>
seriesName={isBP ? '收缩压' : config.label} {INDICATORS.map((ind) => (
seriesName2={isBP ? '舒张压' : undefined} <button
unit={config.unit} key={ind.type}
markLine={isBP ? 140 : undefined} className={`${styles.toggleBtn} ${visible.has(ind.type) ? styles.toggleOn : styles.toggleOff}`}
markLineLabel={isBP ? '140警戒线' : undefined} 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="暂无数据" /> <Empty message="暂无数据" />
)} )}
</div> </div>
); );
} }

View File

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

View File

@@ -1,138 +1,358 @@
.notifyBtn { .greetingBar {
padding: 12px 0 16px;
display: flex;
justify-content: center;
align-items: center;
position: relative; position: relative;
font-size: 20px; }
padding: 4px;
min-width: 44px; .dateText {
min-height: 44px; 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; display: flex;
align-items: center; align-items: center;
justify-content: 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 { .overviewCard {
margin-bottom: 16px; margin-bottom: 16px;
background: linear-gradient(135deg, #1E6BFF, #4D8FFF); background: linear-gradient(145deg, #3A54E8 0%, #5B74F7 30%, #7D9AFF 100%);
color: #fff; 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 { .overviewHeader {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 16px; margin-bottom: 18px;
position: relative;
z-index: 1;
} }
.overviewTitle { .overviewTitle {
font-size: var(--font-size-md); font-size: var(--font-size-md);
font-weight: 600; font-weight: 600;
color: #fff; opacity: 0.95;
} }
.overviewTime { .overviewTime {
font-size: var(--font-size-xs); font-size: 11px;
color: var(--color-text-tertiary); opacity: 0.65;
background: rgba(255,255,255,0.15);
padding: 4px 10px;
border-radius: 12px;
} }
.overviewData { .overviewData {
display: flex; display: flex;
align-items: center; align-items: stretch;
gap: 16px; position: relative;
z-index: 1;
} }
.bpSection, .dataCol {
.hrSection {
flex: 1; flex: 1;
min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 4px; justify-content: center;
gap: 2px;
padding: 2px 0;
}
.dataCol:first-child {
flex: 1.35;
} }
.dataLabel { .dataLabel {
font-size: var(--font-size-xs); font-size: 10px;
color: var(--color-text-tertiary); opacity: 0.65;
font-weight: 500;
letter-spacing: 0.5px;
} }
.bpValues { .bpValues {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 4px; gap: 1px;
} }
.bpNum { .bpNum {
font-size: var(--font-size-3xl); font-size: 22px;
font-weight: 700; font-weight: 800;
line-height: 1.1; 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 { .bpSep {
font-size: var(--font-size-xl); font-size: 14px;
color: var(--color-text-tertiary); opacity: 0.35;
margin: 0 -1px;
} }
.hrNum { .hrNum {
font-size: var(--font-size-3xl); font-size: 22px;
font-weight: 700; font-weight: 800;
color: var(--color-text-primary); line-height: 1.15;
line-height: 1.1; color: #fff;
white-space: nowrap;
letter-spacing: -0.5px;
} }
.unit { .unit {
font-size: var(--font-size-xs); font-size: 10px;
color: var(--color-text-tertiary); opacity: 0.5;
font-weight: 500;
} }
.divider { .divider {
width: 1px; width: 1px;
height: 60px; background: rgba(255,255,255,0.18);
background: var(--color-border); flex-shrink: 0;
align-self: stretch;
margin: 6px 0;
}
.riskAbnormal {
color: #FF7171 !important;
} }
/* Quick Actions */ /* Quick Actions */
.quickActions { .quickActions {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(4, 1fr);
gap: 8px; gap: 10px;
margin-bottom: 16px; margin-bottom: 16px;
background: var(--color-white);
border-radius: var(--radius-lg);
padding: 16px;
box-shadow: var(--shadow-sm);
} }
.quickAction { .quickAction {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 6px; gap: 8px;
padding: 12px 8px; padding: 14px 4px;
border-radius: var(--radius-md); background: var(--color-white);
transition: background 0.15s; border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
transition: all 0.2s;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
.quickAction:active { .quickAction:active {
background: var(--color-bg); transform: scale(0.94);
box-shadow: var(--shadow-md);
} }
.quickIcon { .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; line-height: 1;
} }
.quickLabel { .quickLabel {
font-size: var(--font-size-xs); font-size: 12px;
color: var(--color-text-secondary); 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; font-weight: 500;
color: var(--color-text-tertiary);
}
.medStatus {
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: 20px;
}
.medStatusDone { background: var(--color-success-bg); color: #0D8A5E; }
.medStatusPending { background: var(--color-primary-bg); color: var(--color-primary); }
.medStatusMissed { background: var(--color-danger-bg); color: #D53131; }
.medSlots {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.medSlot {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 10px;
border-radius: 8px;
font-size: 12px;
font-weight: 500;
}
.medSlotTaken { background: var(--color-success-bg); color: #0D8A5E; }
.medSlotTodo { background: var(--color-bg); color: var(--color-text-secondary); }
.medSlotMissed { background: var(--color-danger-bg); color: #D53131; }
.medSlotIcon {
width: 14px;
height: 14px;
opacity: 0.6;
}
.medSlotDot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.medSlotDotTaken { background: var(--color-success); }
.medSlotDotTodo { background: var(--color-text-tertiary); }
.medSlotDotMissed { background: var(--color-danger); }
.medEmpty {
text-align: center;
padding: 32px 24px;
color: var(--color-text-tertiary);
font-size: 13px;
background: var(--color-white);
border-radius: var(--radius-lg);
} }
/* Health Tip */ /* Health Tip */
.tipCard { .tipCard {
margin-bottom: 16px; margin-bottom: 16px;
background: linear-gradient(135deg, #FFFDF5, #FFF8EC);
border: 1px solid #FDE8B3;
border-radius: var(--radius-lg);
} }
.tipHeader { .tipHeader {
@@ -144,18 +364,19 @@
.tipTitle { .tipTitle {
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
font-weight: 600; font-weight: 700;
color: var(--color-text-secondary); color: #B7791F;
} }
.tipHint { .tipHint {
margin-left: auto; margin-left: auto;
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
color: var(--color-text-tertiary); color: #D69E2E;
font-weight: 500;
} }
.tipContent { .tipContent {
font-size: var(--font-size-sm); font-size: 13px;
color: var(--color-text-secondary); color: #7B3F00;
line-height: 1.6; line-height: 1.6;
} }

View File

@@ -1,118 +1,273 @@
import { useEffect, useState } from 'react'; import { useEffect, useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { Card } from '@/components/common/Card'; import { 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 { useAuth } from '@/hooks/useAuth';
import { useNotificationStore } from '@/stores/notification.store'; import { useNotificationStore } from '@/stores/notification.store';
import { api } from '@/services/api-client';
import * as healthService from '@/services/health.service'; import * as healthService from '@/services/health.service';
import { MEASUREMENT_TYPES, HEALTH_TIPS } from '@/utils/constants';
import { getBPRiskLevel } from '@/utils/format';
import type { HealthStats } from '@/types'; import type { HealthStats } from '@/types';
import styles from './HomePage.module.css'; import styles from './HomePage.module.css';
interface MedSlot { time: string; taken: boolean; missed: boolean; takenAt?: string }
interface MedSummary { id: string; drugName: string; dosage: string; frequency: string; slots: MedSlot[]; allTaken: boolean }
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'];
const QUICK_ACTIONS = [ const QUICK_ACTIONS = [
{ label: '测血压', icon: '💓', path: '/health/records?type=blood_pressure' }, {
{ label: '记用药', icon: '💊', path: '/health/medications' }, key: 'bp',
{ label: '在线问诊', icon: '👨‍⚕️', path: '/services/consultation' }, label: '血压',
{ label: '报告解读', icon: '📋', path: '/services/reports' }, path: '/health/records?type=blood_pressure',
{ label: '健康日历', icon: '📅', path: '/health/calendar' }, svg: (
{ label: '运动饮食', icon: '🏃', path: '/health/exercise-diet' }, <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() { export function HomePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const { user } = useAuth(); const { user } = useAuth();
const { unreadCount, fetchNotifications } = useNotificationStore(); const { unreadCount, fetchNotifications } = useNotificationStore();
const [stats, setStats] = useState<HealthStats[]>([]); const [stats, setStats] = useState<HealthStats[]>([]);
const [tipIndex, setTipIndex] = useState(0); const [meds, setMeds] = useState<MedSummary[]>([]);
useEffect(() => { useEffect(() => {
healthService.getLatestStats().then(setStats); healthService.getLatestStats().then(setStats);
fetchNotifications(); 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 bpStats = stats.find((s) => s.type === 'blood_pressure');
const hrStats = stats.find((s) => s.type === 'heart_rate'); 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 bpValue = bpStats?.latest?.value;
const systolic = typeof bpValue === 'object' ? bpValue.systolic : null; const systolic = typeof bpValue === 'object' ? bpValue.systolic : null;
const diastolic = typeof bpValue === 'object' ? bpValue.diastolic : null; const diastolic = typeof bpValue === 'object' ? bpValue.diastolic : null;
const 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 ( return (
<div className="page"> <div className="page" style={{ paddingTop: 0 }}>
<PageHeader <div className={styles.greetingBar}>
title={`你好,${user?.nickname || '用户'}`} <div className={styles.dateText}>{todayDate}</div>
showBack={false} <button onClick={() => navigate('/notifications')} className={styles.notifyBtn}>
rightAction={ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-secondary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<button className={styles.notifyBtn} onClick={() => navigate('/notifications')}> <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" />
{unreadCount > 0 && <Badge count={unreadCount} />} </svg>
{unreadCount > 0 && <span className={styles.notifyBadge} />}
</button> </button>
} </div>
/>
{/* Health Overview */}
{bpStats?.latest && hrStats?.latest ? (
<Card className={styles.overviewCard}> <Card className={styles.overviewCard}>
<div className={styles.overviewHeader}> <div className={styles.overviewHeader}>
<span className={styles.overviewTitle}></span> <span className={styles.overviewTitle}></span>
<span className={styles.overviewTime}></span> <span className={styles.overviewTime}></span>
</div> </div>
<div className={styles.overviewData}> <div className={styles.overviewData}>
<div className={styles.bpSection}> <div className={styles.dataCol}>
<span className={styles.dataLabel}></span> <span className={styles.dataLabel}></span>
{systolic ? (
<div className={styles.bpValues}> <div className={styles.bpValues}>
<span className={`${styles.bpNum} ${styles[`risk_${riskLevel}`] || ''}`}> <span className={`${styles.bpNum} ${bpAbnormal ? styles.riskAbnormal : ''}`}>
{systolic} {systolic}
</span> </span>
<span className={styles.bpSep}>/</span> <span className={styles.bpSep}>/</span>
<span className={`${styles.bpNum} ${styles[`risk_${riskLevel}`] || ''}`}> <span className={`${styles.bpNum} ${bpAbnormal ? styles.riskAbnormal : ''}`}>
{diastolic} {diastolic}
</span> </span>
</div> </div>
) : <span className={styles.bpNum} style={{ fontSize: 22, opacity: 0.4 }}>--/--</span>}
<span className={styles.unit}>mmHg</span> <span className={styles.unit}>mmHg</span>
</div> </div>
<div className={styles.divider} /> <div className={styles.divider} />
<div className={styles.hrSection}> <div className={styles.dataCol}>
<span className={styles.dataLabel}></span> <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> <span className={styles.unit}>bpm</span>
</div> </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> </div>
</Card> </Card>
) : (
<Empty icon="💓" message="暂无健康数据" />
)}
{/* Quick Actions */}
<div className={styles.quickActions}> <div className={styles.quickActions}>
{QUICK_ACTIONS.map((action) => ( {QUICK_ACTIONS.map((action) => (
<button <button key={action.key} className={styles.quickAction} onClick={() => navigate(action.path)}>
key={action.label} <span className={styles.quickIcon} style={{ background: action.iconBg }}>
className={styles.quickAction} {action.svg}
onClick={() => navigate(action.path)} </span>
>
<span className={styles.quickIcon}>{action.icon}</span>
<span className={styles.quickLabel}>{action.label}</span> <span className={styles.quickLabel}>{action.label}</span>
</button> </button>
))} ))}
</div> </div>
{/* Health Tip */} <div className={styles.medSection}>
<Card <div className={styles.medSectionTitle}>
className={styles.tipCard} <span className={styles.medTitleIcon}>
onClick={() => setTipIndex((prev) => (prev + 1) % HEALTH_TIPS.length)} <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" />
<div className={styles.tipHeader}> <path d="M10 9v6M14 9v6" />
<span>💡</span> </svg>
<span className={styles.tipTitle}></span> </span>
<span className={styles.tipHint}></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> </div>
<p className={styles.tipContent}>{HEALTH_TIPS[tipIndex]}</p>
</Card> </Card>
))
)}
</div>
</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; } .heroHeader {
.infoRow {
display: flex; display: flex;
justify-content: space-between; align-items: flex-start;
padding: 8px 0; gap: 16px;
font-size: var(--font-size-sm); margin-bottom: 20px;
border-bottom: 1px solid var(--color-border-light); }
.heroIcon {
width: 52px;
height: 52px;
border-radius: 16px;
background: var(--color-primary-gradient);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4px 16px rgba(79,110,247,0.3);
}
.heroInfo { flex: 1; }
.heroName {
font-size: 18px;
font-weight: 800;
color: var(--color-text-primary);
margin-bottom: 4px;
}
.heroMeta {
font-size: 13px;
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.activeBadge { color: var(--color-success); font-weight: 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; } .infoLabel {
font-size: 11px;
.adherenceRate { color: var(--color-text-tertiary);
font-size: var(--font-size-3xl);
font-weight: 700;
color: var(--color-success);
margin-bottom: 4px; margin-bottom: 4px;
} }
.infoValue {
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
}
/* Today tracking */
.todayTitle {
font-size: 16px;
font-weight: 700;
margin-bottom: 4px;
color: var(--color-text-primary);
}
.todayDate {
font-size: 12px;
color: var(--color-text-tertiary);
margin-bottom: 14px;
}
.slotRow {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px;
border-radius: 14px;
margin-bottom: 8px;
}
.slotRowTaken {
background: linear-gradient(135deg, #ECFDF5, #F0FFF4);
border: 1px solid #A7F3D0;
}
.slotRowPending {
background: var(--color-bg);
border: 1.5px dashed var(--color-border);
}
.slotLeft {
display: flex;
align-items: center;
gap: 12px;
}
.slotCircle {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
}
.slotCircleTaken {
background: var(--color-success);
color: #fff;
}
.slotCirclePending {
background: var(--color-white);
color: var(--color-primary);
border: 2px solid var(--color-primary);
}
.slotTime {
font-size: 16px;
font-weight: 700;
color: var(--color-text-primary);
}
.slotLabel {
font-size: 12px;
color: var(--color-text-tertiary);
}
.todaySummary {
margin-top: 14px;
padding: 14px 16px;
background: var(--color-bg);
border-radius: 12px;
display: flex;
align-items: center;
}
.todayProgress {
font-size: 13px;
font-weight: 600;
color: var(--color-text-secondary);
white-space: nowrap;
}
.todayProgressBar {
flex: 1;
height: 6px;
border-radius: 3px;
background: var(--color-border);
margin: 0 12px;
overflow: hidden;
}
.todayProgressFill {
height: 100%;
border-radius: 3px;
background: var(--color-primary-gradient);
transition: width 0.3s;
}
/* 7-day chart */
.chartTitle {
font-size: 16px;
font-weight: 700;
margin-bottom: 14px;
color: var(--color-text-primary);
}
.chartBars {
display: flex;
gap: 6px;
align-items: flex-end;
margin-bottom: 6px;
}
.chartBarWrap {
flex: 1;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.chartBar {
width: 100%;
max-width: 32px;
border-radius: 8px;
min-height: 8px;
}
.chartBarFull {
background: var(--color-primary-gradient);
}
.chartBarPartial {
background: linear-gradient(180deg, #4F6EF7 0%, #B8C4FD 100%);
}
.chartBarEmpty {
background: var(--color-border);
}
.chartDate {
font-size: 10px;
color: var(--color-text-tertiary);
}
.chartLegend {
display: flex;
gap: 14px;
margin-top: 10px;
font-size: 11px;
color: var(--color-text-tertiary);
}
.chartLegendItem {
display: flex;
align-items: center;
gap: 4px;
}
.chartLegendDot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.chartLegendDotFull { background: var(--color-primary); }
.chartLegendDotPartial { background: #B8C4FD; }
.chartLegendDotEmpty { background: var(--color-border); }

View File

@@ -1,59 +1,174 @@
import { useEffect, useState } from 'react'; 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 { PageHeader } from '@/components/layout/PageHeader';
import { Card } from '@/components/common/Card'; import { Card } from '@/components/common/Card';
import { Button } from '@/components/common/Button'; 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 * as medicationService from '@/services/medication.service';
import type { Medication, MedicationAdherence } from '@/types'; import type { Medication, MedicationRecord } from '@/types';
import styles from './MedicationDetailPage.module.css'; import styles from './MedicationDetailPage.module.css';
export function MedicationDetailPage() { export function MedicationDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const [med, setMed] = useState<Medication | null>(null);
const [medications, setMedications] = useState<Medication[]>([]); const [records, setRecords] = useState<MedicationRecord[]>([]);
const [adherence, setAdherence] = useState<MedicationAdherence | null>(null); const [loading, setLoading] = useState(false);
useEffect(() => { const load = () => {
medicationService.getMedications().then(setMedications); if (!id) return;
if (id) medicationService.getAdherence(id).then(setAdherence).catch(() => {}); medicationService.getMedications().then((meds) => {
}, [id]); 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) { if (!med) {
return ( return <div className="page--no-tab"><PageHeader title="药品详情" /><div style={{ padding: 40, textAlign: 'center', color: '#9CA3AF' }}></div></div>;
<div className="page--no-tab"> }
<PageHeader title="药品详情" />
<div style={{ padding: 40, textAlign: 'center', color: '#9CA3AF' }}></div> const slotTaken = (slot: string) => todayRecords.some((r) => r.timeSlot === slot && r.isTaken);
</div>
); 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 ( return (
<div className="page--no-tab"> <div className="page--no-tab">
<PageHeader title={med.drugName} /> <PageHeader title={med.drugName} />
<Card className={styles.infoCard}> <Card className={styles.infoCard}>
<div className={styles.infoTitle}>{med.drugName}</div> <div className={styles.heroHeader}>
<div className={styles.infoRow}><span></span><span>{med.dosage}</span></div> <div className={styles.heroIcon}>
<div className={styles.infoRow}><span></span><span>{med.timeSlots.join(', ')}</span></div> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<div className={styles.infoRow}><span></span><span>{med.startDate} ~ {med.endDate || '长期'}</span></div> <rect x="4" y="5" width="16" height="14" rx="4" />
<div className={styles.infoRow}><span></span><span className={med.status === 'active' ? styles.activeBadge : ''}>{med.status === 'active' ? '进行中' : '已结束'}</span></div> <path d="M10 9v6M14 9v6" />
{med.notes && <div className={styles.infoRow}><span></span><span>{med.notes}</span></div>} </svg>
</div>
<div className={styles.heroInfo}>
<div className={styles.heroName}>{med.drugName}</div>
<div className={styles.heroMeta}>{med.dosage} · {med.frequency}</div>
</div>
</div>
<div className={styles.infoGrid}>
<div className={styles.infoItem}>
<div className={styles.infoLabel}></div>
<div className={styles.infoValue}>{med.dosage}</div>
</div>
<div className={styles.infoItem}>
<div className={styles.infoLabel}></div>
<div className={styles.infoValue}>{med.frequency}</div>
</div>
<div className={styles.infoItem}>
<div className={styles.infoLabel}></div>
<div className={styles.infoValue}>{med.timeSlots.join(', ')}</div>
</div>
<div className={styles.infoItem}>
<div className={styles.infoLabel}></div>
<div className={styles.infoValue}>{med.startDate} ~ {med.endDate || '长期'}</div>
</div>
</div>
</Card> </Card>
{adherence && ( {med.notes && (
<Card className={styles.adherenceCard}> <Card className={styles.infoCard}>
<div className={styles.adherenceTitle}>30</div> <div className={styles.infoLabel} style={{ marginBottom: 4 }}></div>
<div className={styles.adherenceRate}>{adherence.rate}%</div> <div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.6 }}>{med.notes}</div>
<PieChart
data={[
{ name: '已服用', value: adherence.rate, color: '#10B981' },
{ name: '未服用', value: 100 - adherence.rate, color: '#EF4444' },
]}
/>
</Card> </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> </div>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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; display: flex;
align-items: center; align-items: center;
gap: 16px; 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 { .avatar {
width: 56px; height: 56px; width: 60px;
border-radius: 50%; height: 60px;
background: var(--color-primary-bg); border-radius: 20px;
color: var(--color-primary); background: rgba(255,255,255,0.2);
color: #fff;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: var(--font-size-xl); font-size: 24px;
font-weight: 700; font-weight: 800;
flex-shrink: 0;
} }
.nickname { font-size: var(--font-size-lg); font-weight: 600; } .profileInfo { flex: 1; position: relative; z-index: 1; }
.phone { font-size: var(--font-size-xs); color: var(--color-text-tertiary); margin-top: 2px; }
.statsCard { .nickname { font-size: 20px; font-weight: 800; }
display: flex; .phone { font-size: 13px; opacity: 0.7; margin-top: 3px; }
.editBadge {
display: inline-flex;
align-items: center; 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; margin-bottom: 16px;
} }
.stat { text-align: center; } .menuSectionTitle {
.statValue { font-size: var(--font-size-sm); font-weight: 600; display: block; } font-size: 12px;
.statLabel { font-size: var(--font-size-xs); color: var(--color-text-tertiary); } font-weight: 600;
.statDivider { width: 1px; height: 32px; background: var(--color-border); } color: var(--color-text-tertiary);
padding: 0 4px 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.menuList { .menuList {
background: var(--color-white); background: var(--color-white);
border-radius: var(--radius-lg); border-radius: var(--radius-xl);
overflow: hidden; overflow: hidden;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
margin-bottom: 16px;
} }
.menuItem { .menuItem {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 14px 16px; padding: 16px 18px;
width: 100%; width: 100%;
font-size: var(--font-size-base); font-size: 15px;
border-bottom: 1px solid var(--color-border-light); 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:last-child { border-bottom: none; }
.menuItem:active { background: var(--color-bg); } .menuItem:active { background: var(--color-bg); }
.menuRight { display: flex; align-items: center; gap: 8px; } .menuItemLeft {
display: flex;
align-items: center;
gap: 12px;
}
.menuIcon {
width: 38px;
height: 38px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.menuArrow {
font-size: 18px;
color: var(--color-text-tertiary);
}
.menuBadge {
margin-right: 6px;
}
.logoutBtn { .logoutBtn {
display: block; display: block;
width: 100%; width: 100%;
padding: 14px; padding: 15px;
background: var(--color-white); background: var(--color-white);
color: var(--color-danger); color: var(--color-danger);
border-radius: var(--radius-lg); border-radius: var(--radius-xl);
font-size: var(--font-size-base); font-size: 15px;
font-weight: 600;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
margin-top: 24px;
} }
.logoutBtn:active { background: var(--color-danger-bg); }

View File

@@ -30,46 +30,67 @@ export function ProfilePage() {
</div> </div>
</Card> </Card>
<Card className={styles.statsCard}>
<div className={styles.stat}>
<span className={styles.statValue}>{user?.height || '-'}cm</span>
<span className={styles.statLabel}></span>
</div>
<div className={styles.statDivider} />
<div className={styles.stat}>
<span className={styles.statValue}>{user?.weight || '-'}kg</span>
<span className={styles.statLabel}></span>
</div>
<div className={styles.statDivider} />
<div className={styles.stat}>
<span className={styles.statValue}>{user?.medicalHistory?.join('、') || '-'}</span>
<span className={styles.statLabel}></span>
</div>
</Card>
<div className={styles.menuList}> <div className={styles.menuList}>
<button className={styles.menuItem} onClick={() => navigate('/health/medications')}> <button className={styles.menuItem} onClick={() => navigate('/profile/health-record')}>
<span>💊 </span> <span className={styles.menuItemLeft}>
<span></span> <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>
<button className={styles.menuItem} onClick={() => navigate('/notifications')}> <button className={styles.menuItem} onClick={() => navigate('/notifications')}>
<span>🔔 </span> <span className={styles.menuItemLeft}>
<div className={styles.menuRight}> <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} />} {unreadCount > 0 && <Badge count={unreadCount} />}
<span></span> </span>
</div>
</button> </button>
<button className={styles.menuItem} onClick={() => navigate('/home/device-binding')}> <button className={styles.menuItem} onClick={() => navigate('/home/device-binding')}>
<span>📡 </span> <span className={styles.menuItemLeft}>
<span></span> <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>
<button className={styles.menuItem} onClick={() => navigate('/profile/settings')}> <button className={styles.menuItem} onClick={() => navigate('/profile/settings')}>
<span> </span> <span className={styles.menuItemLeft}>
<span></span> <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>
<button className={styles.menuItem} onClick={() => navigate('/profile/settings/about')}> <button className={styles.menuItem} onClick={() => navigate('/profile/settings/about')}>
<span> </span> <span className={styles.menuItemLeft}>
<span></span> <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> </button>
</div> </div>

View File

@@ -46,7 +46,11 @@ export function AboutPage() {
<div className="page--no-tab"> <div className="page--no-tab">
<PageHeader title="关于" /> <PageHeader title="关于" />
<div style={{ textAlign: 'center', padding: '40px 20px' }}> <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: '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: '#9CA3AF', marginTop: 4 }}>v1.0.0-demo</div>
<div style={{ fontSize: '13px', color: '#6B7280', marginTop: 16 }}> H5 Web Demo</div> <div style={{ fontSize: '13px', color: '#6B7280', marginTop: 16 }}> H5 Web Demo</div>

View File

@@ -1,80 +1,135 @@
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { PageHeader } from '@/components/layout/PageHeader'; import { PageHeader } from '@/components/layout/PageHeader';
import { HubConnectionBuilder, HubConnection, HubConnectionState } from '@microsoft/signalr';
import { api } from '@/services/api-client';
import * as consultationService from '@/services/consultation.service'; import * as consultationService from '@/services/consultation.service';
import type { Consultation, ConsultationMessage, Doctor } from '@/types'; import type { Consultation, ConsultationMessage, Doctor } from '@/types';
import { formatRelative } from '@/utils/format'; import { formatRelative } from '@/utils/format';
import styles from './ChatPage.module.css'; import styles from './ChatPage.module.css';
function getToken(): string {
try {
const raw = localStorage.getItem('hrt_auth');
if (!raw) return '';
const state = JSON.parse(raw);
return state?.state?.token ?? '';
} catch { return ''; }
}
export function ChatPage() { export function ChatPage() {
const { doctorId } = useParams<{ doctorId: string }>();
const [doctor, setDoctor] = useState<Doctor | null>(null); const [doctor, setDoctor] = useState<Doctor | null>(null);
const [consultation, setConsultation] = useState<Consultation | null>(null); const [consultation, setConsultation] = useState<Consultation | null>(null);
const [messages, setMessages] = useState<ConsultationMessage[]>([]); const [messages, setMessages] = useState<ConsultationMessage[]>([]);
const [text, setText] = useState(''); const [text, setText] = useState('');
const [sending, setSending] = useState(false); const [connected, setConnected] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const connRef = useRef<HubConnection | null>(null);
const initRef = useRef(false);
// Init consultation once
useEffect(() => { useEffect(() => {
if (doctorId) { if (initRef.current) return;
consultationService.getDoctor(doctorId).then((d) => setDoctor(d || null)); initRef.current = true;
consultationService.getConsultation(doctorId).then(async (c) => {
if (c) { consultationService.getDoctors().then((docs) => {
setConsultation(c); if (docs.length > 0) {
const doc = docs[0];
setDoctor(doc);
api.get<Consultation[]>('/api/consultations').then((res) => {
const existing = (res.data as Record<string, unknown>[]).find(
(c) => c.doctorId === doc.id && c.status === 'active'
);
if (existing) {
setConsultation(existing as unknown as Consultation);
} else { } else {
const newC = await consultationService.startConsultation(doctorId); consultationService.startConsultation(doc.id, '在线咨询').then((c) => {
setConsultation(newC); setConsultation(c);
});
} }
}); });
} }
}, [doctorId]); });
}, []);
// Fetch messages when consultation is loaded // Load initial messages + set up SignalR when consultation is ready
useEffect(() => { useEffect(() => {
if (consultation?.id) { if (!consultation?.id) return;
consultationService.getDoctorReply(consultation.id).then(() => {
// The messages are fetched as a side effect; fetch them directly // Load message history
import('@/services/api-client').then(({ api }) => {
api.get<ConsultationMessage[]>(`/api/consultations/${consultation.id}/messages`) api.get<ConsultationMessage[]>(`/api/consultations/${consultation.id}/messages`)
.then((res) => setMessages(res.data)); .then((res) => setMessages(res.data))
.catch(() => {});
// Set up SignalR connection
const conn = new HubConnectionBuilder()
.withUrl('/hubs/chat', {
accessTokenFactory: () => getToken(),
})
.withAutomaticReconnect()
.build();
conn.on('ReceiveMessage', (msg: ConsultationMessage) => {
setMessages((prev) => {
if (prev.some((m) => m.id === msg.id)) return prev;
return [...prev, msg];
}); });
}); });
conn.onreconnected(() => {
conn.invoke('JoinConsultation', consultation.id).catch(() => {});
});
conn.start()
.then(() => {
setConnected(true);
return conn.invoke('JoinConsultation', consultation.id);
})
.catch(() => {});
connRef.current = conn;
return () => {
if (conn.state === HubConnectionState.Connected) {
conn.invoke('LeaveConsultation', consultation.id).catch(() => {});
} }
conn.stop();
};
}, [consultation?.id]); }, [consultation?.id]);
// Auto-scroll on new messages
useEffect(() => { useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]); }, [messages]);
const handleSend = async () => { const handleSend = useCallback(async () => {
if (!text.trim() || !consultation || sending) return; if (!text.trim() || !consultation?.id || !connRef.current) return;
setSending(true);
const msgText = text; const msgText = text;
setText(''); setText('');
const sent = await consultationService.sendMessage(consultation.id, msgText); try {
setMessages((prev) => [...prev, sent]); await connRef.current.invoke('SendMessage', consultation.id, msgText);
setSending(false); } catch { /* ignore */ }
// Poll for doctor reply after delay }, [text, consultation?.id]);
setTimeout(async () => {
const reply = await consultationService.getDoctorReply(consultation.id);
if (reply) {
setMessages((prev) => {
if (prev.find((m) => m.id === reply.id)) return prev;
return [...prev, reply];
});
}
}, 1500);
};
return ( return (
<div className={styles.page}> <div className={styles.page}>
<PageHeader title={doctor?.name || '咨询'} /> <PageHeader
title={doctor?.name || '在线问诊'}
rightAction={
<span style={{
display: 'inline-block', width: 8, height: 8, borderRadius: '50%',
background: connected ? '#4caf50' : '#ccc',
marginLeft: 8,
}} />
}
/>
<div className={styles.messages}> <div className={styles.messages}>
{messages.length === 0 && (
<div style={{ textAlign: 'center', color: '#9CA3AF', marginTop: 40, fontSize: 14 }}>
{doctor?.name || '医生'}
</div>
)}
{messages.map((msg) => ( {messages.map((msg) => (
<div <div key={msg.id} className={`${styles.bubble} ${msg.senderRole === 'patient' ? styles.patient : styles.doctor}`}>
key={msg.id}
className={`${styles.bubble} ${msg.senderRole === 'patient' ? styles.patient : styles.doctor}`}
>
<div className={styles.bubbleContent}>{msg.content}</div> <div className={styles.bubbleContent}>{msg.content}</div>
<div className={styles.bubbleTime}>{formatRelative(msg.createdAt)}</div> <div className={styles.bubbleTime}>{formatRelative(msg.createdAt)}</div>
</div> </div>
@@ -82,16 +137,9 @@ export function ChatPage() {
<div ref={bottomRef} /> <div ref={bottomRef} />
</div> </div>
<div className={styles.inputBar}> <div className={styles.inputBar}>
<input <input className={styles.input} value={text} onChange={(e) => setText(e.target.value)}
className={styles.input} placeholder="输入消息..." onKeyDown={(e) => e.key === 'Enter' && handleSend()} />
value={text} <button className={styles.sendBtn} onClick={handleSend}></button>
onChange={(e) => setText(e.target.value)}
placeholder="输入消息..."
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
/>
<button className={styles.sendBtn} onClick={handleSend} disabled={sending}>
{sending ? '...' : '发送'}
</button>
</div> </div>
</div> </div>
); );

View File

@@ -17,24 +17,26 @@
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
background: var(--color-bg-secondary); background: var(--color-bg-secondary);
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-weight: 500;
} }
.active { background: var(--color-primary-bg); color: var(--color-primary); } .active { background: var(--color-primary-bg); color: var(--color-primary); font-weight: 600; }
.docCard { margin-bottom: 8px; } .docCard { margin-bottom: 8px; }
.docHeader { display: flex; gap: 12px; margin-bottom: 12px; } .docHeader { display: flex; gap: 12px; margin-bottom: 12px; }
.avatar { .avatar {
width: 48px; height: 48px; width: 48px;
border-radius: 50%; height: 48px;
border-radius: 16px;
background: var(--color-primary-bg); background: var(--color-primary-bg);
color: var(--color-primary); color: var(--color-primary);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: var(--font-size-lg); font-size: var(--font-size-lg);
font-weight: 600; font-weight: 700;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -49,7 +51,8 @@
} }
.onlineDot { .onlineDot {
width: 8px; height: 8px; width: 8px;
height: 8px;
border-radius: 50%; border-radius: 50%;
background: var(--color-success); background: var(--color-success);
} }
@@ -63,7 +66,7 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding-top: 12px; padding-top: 12px;
border-top: 1px solid var(--color-border-light); border-top: 1px solid var(--color-divider);
} }
.fee { font-size: var(--font-size-sm); color: var(--color-danger); font-weight: 600; } .fee { font-size: var(--font-size-sm); color: var(--color-danger); font-weight: 600; }

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