Compare commits

...

5 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
82 changed files with 4075 additions and 847 deletions

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

@@ -1,15 +0,0 @@
# PostgreSQL
ConnectionStrings__Default=Host=localhost;Port=5432;Database=health_manager;Username=postgres;Password=your_password
# JWT
Jwt__Secret=your-jwt-secret-change-me
Jwt__Issuer=HealthManager
Jwt__Audience=HealthManagerApp
# Redis (reserved)
Redis__Connection=localhost:6379
# MinIO (reserved)
MinIO__Endpoint=localhost:9000
MinIO__AccessKey=minioadmin
MinIO__SecretKey=minioadmin

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

@@ -20,7 +20,14 @@ public class FollowUpService(AppDbContext db)
.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<List<FollowUp>> GetDoctorInitiatedFollowUpsAsync(Guid doctorId)
=> await db.FollowUps
.Include(f => f.Patient)
.Where(f => f.DoctorId == doctorId)
.OrderBy(f => f.ScheduledAt)
.ToListAsync();
public async Task<FollowUp> AddAsync(Guid patientId, string title, string? description, DateTime scheduledAt, bool reminderEnabled, Guid? doctorId = null, string? notes = null)
{ {
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

@@ -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,31 +14,54 @@ 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)
{ {
// Demo: auto-register new users
var db = HttpContext.RequestServices.GetRequiredService<Infrastructure.Data.AppDbContext>(); var db = HttpContext.RequestServices.GetRequiredService<Infrastructure.Data.AppDbContext>();
user = new User
var deleted = await db.Users.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.Phone == request.Phone && u.IsDeleted);
if (deleted != null)
{ {
Phone = request.Phone, deleted.IsDeleted = false;
Name = "用户" + request.Phone[^4..], deleted.DeletedAt = null;
Role = "patient", deleted.UpdatedAt = DateTime.UtcNow;
PasswordHash = AuthService.HashPassword("demo123"), user = deleted;
}; }
db.Users.Add(user); else
{
user = new User
{
Phone = request.Phone,
Name = "用户" + request.Phone[^4..],
Role = "patient",
PasswordHash = AuthService.HashPassword("demo123"),
};
db.Users.Add(user);
}
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
@@ -62,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();
@@ -74,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)
{ {
@@ -121,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
{ {
@@ -55,10 +66,19 @@ public class FollowUpController(FollowUpService followUpService) : ControllerBas
} }
var followUp = await followUpService.AddAsync(patientId, request.Title, request.Description, var followUp = await followUpService.AddAsync(patientId, request.Title, request.Description,
request.ScheduledAt, request.ReminderEnabled, doctorId); 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)
@@ -77,6 +97,7 @@ public class FollowUpCreateRequest
public DateTime ScheduledAt { get; set; } public DateTime ScheduledAt { get; set; }
public bool ReminderEnabled { get; set; } = true; public bool ReminderEnabled { get; set; } = true;
public Guid? PatientId { get; set; } public Guid? PatientId { get; set; }
public string? Notes { get; set; }
} }
public class FollowUpUpdateRequest public class FollowUpUpdateRequest

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,32 +1,16 @@
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;
using Swashbuckle.AspNetCore.SwaggerGen; using Swashbuckle.AspNetCore.SwaggerGen;
// Load .env file into environment variables
var envPath = Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", "..", ".env");
if (File.Exists(envPath))
{
foreach (var line in File.ReadAllLines(envPath))
{
var trimmed = line.Trim();
if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith('#')) continue;
var eq = trimmed.IndexOf('=');
if (eq > 0)
{
var key = trimmed[..eq].Trim();
var value = trimmed[(eq + 1)..].Trim();
Environment.SetEnvironmentVariable(key, value);
}
}
}
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Database // Database
@@ -57,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已注销");
} }
}; };
}); });
@@ -73,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();
@@ -117,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

@@ -7,19 +7,16 @@
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"ConnectionStrings": { "ConnectionStrings": {
"Default": "" "Default": "Host=localhost;Port=5432;Database=health_manager;Username=postgres;Password=postgres123"
}, },
"Jwt": { "Jwt": {
"Secret": "", "Secret": "health-manager-jwt-secret-key-2026-super-secure-long-enough!",
"Issuer": "HealthManager", "Issuer": "HealthManager",
"Audience": "HealthManagerApp" "Audience": "HealthManagerApp"
}, },
"Redis": {
"Connection": ""
},
"MinIO": { "MinIO": {
"Endpoint": "", "Endpoint": "localhost:9000",
"AccessKey": "", "AccessKey": "minioadmin",
"SecretKey": "" "SecretKey": "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

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

@@ -41,6 +41,13 @@ const SIDEBAR_ICONS: Record<string, React.ReactNode> = {
<line x1="3" y1="10" x2="21" y2="10" /> <line x1="3" y1="10" x2="21" y2="10" />
</svg> </svg>
), ),
visits: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
<rect x="8" y="2" width="8" height="4" rx="1" ry="1" />
<path d="M9 14l2 2 4-4" />
</svg>
),
}; };
const navItems = [ const navItems = [
@@ -49,6 +56,7 @@ const navItems = [
{ to: '/consultations', label: '在线问诊', ikey: 'consultations' }, { to: '/consultations', label: '在线问诊', ikey: 'consultations' },
{ to: '/reports', label: '报告审核', ikey: 'reports' }, { to: '/reports', label: '报告审核', ikey: 'reports' },
{ to: '/follow-ups', label: '复查管理', ikey: 'followups' }, { to: '/follow-ups', label: '复查管理', ikey: 'followups' },
{ to: '/visits', label: '随访管理', ikey: 'visits' },
]; ];
const sidebarStyles = { const sidebarStyles = {

View File

@@ -25,6 +25,7 @@ export function ChatPage() {
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const connRef = useRef<HubConnection | null>(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`)
@@ -32,11 +33,12 @@ export function ChatPage() {
.catch(() => {}); .catch(() => {});
}, [id]); }, [id]);
// Set up SignalR connection
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
const conn = new HubConnectionBuilder() const conn = new HubConnectionBuilder()
.withUrl(`${import.meta.env.VITE_API_URL}/hubs/chat`, { .withUrl('http://localhost:5000/hubs/chat', {
accessTokenFactory: () => getToken(), accessTokenFactory: () => getToken(),
}) })
.withAutomaticReconnect() .withAutomaticReconnect()
@@ -44,6 +46,7 @@ export function ChatPage() {
conn.on('ReceiveMessage', (msg: Message) => { conn.on('ReceiveMessage', (msg: Message) => {
setMessages((prev) => { setMessages((prev) => {
// Dedup — guard against reconnection replay
if (prev.some((m) => m.id === msg.id)) return prev; if (prev.some((m) => m.id === msg.id)) return prev;
return [...prev, msg]; return [...prev, msg];
}); });
@@ -70,6 +73,7 @@ export function ChatPage() {
}; };
}, [id]); }, [id]);
// Auto-scroll on new messages
useEffect(() => { useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]); }, [messages]);
@@ -85,37 +89,31 @@ export function ChatPage() {
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 0px)' }}> <div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 0px)' }}>
<div style={{ <div style={{ padding: '14px 20px', background: '#fff', borderBottom: '1px solid #eee', fontSize: 15, fontWeight: 500, display: 'flex', alignItems: 'center', gap: 8 }}>
padding: '15px 24px', background: '#fff', borderBottom: '1px solid #F0F2F5',
fontSize: 15, fontWeight: 600, color: '#1A1D28', display: 'flex', alignItems: 'center', gap: 8,
boxShadow: '0 1px 4px rgba(0,0,0,0.03)',
}}>
线 线
<span style={{ <span style={{
width: 8, height: 8, borderRadius: '50%', width: 8, height: 8, borderRadius: '50%',
background: connected ? '#20C997' : '#C0C5D2', background: connected ? '#4caf50' : '#ccc',
display: 'inline-block', display: 'inline-block',
}} /> }} />
</div> </div>
<div style={{ flex: 1, overflow: 'auto', padding: 24, background: '#F5F7FB' }}> <div style={{ flex: 1, overflow: 'auto', padding: 20, background: '#fafafa' }}>
{messages.map((msg) => ( {messages.map((msg) => (
<div key={msg.id} style={{ <div key={msg.id} style={{
display: 'flex', justifyContent: msg.senderRole === 'doctor' ? 'flex-end' : 'flex-start', display: 'flex', justifyContent: msg.senderRole === 'doctor' ? 'flex-end' : 'flex-start',
marginBottom: 14, marginBottom: 12,
}}> }}>
<div style={{ <div style={{
maxWidth: '70%', padding: '12px 16px', borderRadius: 14, fontSize: 14, maxWidth: '70%', padding: '10px 14px', borderRadius: 12, fontSize: 14,
background: msg.senderRole === 'doctor' background: msg.senderRole === 'doctor' ? '#1976d2' : '#fff',
? 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)' color: msg.senderRole === 'doctor' ? '#fff' : '#333',
: '#fff', boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
color: msg.senderRole === 'doctor' ? '#fff' : '#1A1D28',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
}}> }}>
<div>{msg.content}</div> <div>{msg.content}</div>
<div style={{ <div style={{
fontSize: 10, marginTop: 6, textAlign: 'right', fontSize: 10, marginTop: 4, textAlign: 'right',
opacity: 0.65, opacity: 0.7,
}}> }}>
{msg.createdAt?.split('T')[1]?.slice(0, 5)} {msg.createdAt?.split('T')[1]?.slice(0, 5)}
</div> </div>
@@ -125,23 +123,14 @@ export function ChatPage() {
<div ref={bottomRef} /> <div ref={bottomRef} />
</div> </div>
<div style={{ <div style={{ padding: '12px 20px', background: '#fff', borderTop: '1px solid #eee', display: 'flex', gap: 12 }}>
padding: '14px 24px', background: '#fff', borderTop: '1px solid #F0F2F5',
display: 'flex', gap: 12, boxShadow: '0 -1px 4px rgba(0,0,0,0.03)',
}}>
<input value={input} onChange={(e) => setInput(e.target.value)} <input value={input} onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()} onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder="输入回复..." placeholder="输入回复..."
style={{ style={{ flex: 1, padding: '10px 14px', border: '1px solid #ddd', borderRadius: 20, fontSize: 14 }} />
flex: 1, padding: '11px 16px', border: '1.5px solid #E1E5ED', borderRadius: 24,
fontSize: 14, outline: 'none',
}}
onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
<button onClick={handleSend} style={{ <button onClick={handleSend} style={{
padding: '11px 24px', background: 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)', color: '#fff', padding: '10px 24px', background: '#1976d2', color: '#fff',
border: 'none', borderRadius: 24, fontSize: 14, fontWeight: 600, border: 'none', borderRadius: 20, fontSize: 14,
boxShadow: '0 4px 14px rgba(79,110,247,0.3)',
}}> }}>
</button> </button>

View File

@@ -57,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({

View File

@@ -45,7 +45,7 @@ export function FollowUpEditPage() {
return ( return (
<div style={{ padding: 28 }}> <div style={{ padding: 28 }}>
<h2 style={{ marginBottom: 20, fontSize: 20, fontWeight: 700, color: '#1A1D28' }}>{isNew ? '新建随访' : '编辑随访'}</h2> <h2 style={{ marginBottom: 20, fontSize: 20, fontWeight: 700, color: '#1A1D28' }}>{isNew ? '新建复查' : '编辑复查'}</h2>
<form onSubmit={handleSubmit} style={{ background: '#fff', padding: 28, borderRadius: 16, maxWidth: 520, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}> <form onSubmit={handleSubmit} style={{ background: '#fff', padding: 28, borderRadius: 16, maxWidth: 520, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>

View File

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

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,6 +13,15 @@ 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> = { const typeLabels: Record<string, string> = {
blood_pressure: '血压', heart_rate: '心率', blood_sugar: '血糖', spo2: '血氧', blood_pressure: '血压', heart_rate: '心率', blood_sugar: '血糖', spo2: '血氧',
}; };
@@ -24,17 +34,52 @@ const typeBgs: Record<string, string> = {
blood_pressure: '#FEE9E9', heart_rate: '#FFF8E6', blood_sugar: '#EDF0FD', spo2: '#E6F9F2', 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;
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: 28, color: '#9BA0B4' }}>...</div>; if (!patient) return <div style={{ padding: 28, color: '#9BA0B4' }}>...</div>;
@@ -55,9 +100,10 @@ export function PatientDetailPage() {
}; };
return ( return (
<div style={{ padding: 28 }}> <div style={{ padding: 28, maxWidth: 1100 }}>
<Link to="/patients" style={{ fontSize: 13, color: '#4F6EF7', fontWeight: 500 }}> </Link> <Link to="/patients" style={{ fontSize: 13, color: '#4F6EF7', fontWeight: 500 }}> </Link>
{/* Patient info card */}
<div style={{ background: '#fff', marginTop: 16, padding: 28, borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}> <div style={{ background: '#fff', marginTop: 16, padding: 28, borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 20 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 20 }}>
<div style={{ <div style={{
@@ -73,40 +119,19 @@ export function PatientDetailPage() {
<p style={{ margin: '4px 0 0', fontSize: 12, color: '#9BA0B4' }}>{patient.phone}</p> <p style={{ margin: '4px 0 0', fontSize: 12, color: '#9BA0B4' }}>{patient.phone}</p>
</div> </div>
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px 32px', fontSize: 13 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px 32px', fontSize: 13 }}>
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}> <InfoRow label="手机号" value={patient.phone} />
<span style={{ color: '#9BA0B4' }}></span> <InfoRow label="性别" value={patient.gender || '-'} />
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{patient.phone}</span> <InfoRow label="出生日期" value={patient.birthday || '-'} />
</div> <InfoRow label="身高/体重" value={`${patient.heightCm}cm / ${patient.weightKg}kg`} />
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}> <InfoRow label="病史" value={(patient.medicalHistory || []).join('、') || '-'} />
<span style={{ color: '#9BA0B4' }}></span> <InfoRow label="支架日期" value={patient.stentDate || '-'} />
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{patient.gender || '-'}</span> <InfoRow label="支架类型" value={patient.stentType || '-'} />
</div>
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}>
<span style={{ color: '#9BA0B4' }}></span>
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{patient.birthday || '-'}</span>
</div>
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}>
<span style={{ color: '#9BA0B4' }}>/</span>
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{patient.heightCm}cm / {patient.weightKg}kg</span>
</div>
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}>
<span style={{ color: '#9BA0B4' }}></span>
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{(patient.medicalHistory || []).join('、') || '-'}</span>
</div>
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}>
<span style={{ color: '#9BA0B4' }}></span>
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{patient.stentDate || '-'}</span>
</div>
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}>
<span style={{ color: '#9BA0B4' }}></span>
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{patient.stentType || '-'}</span>
</div>
</div> </div>
</div> </div>
<h3 style={{ marginTop: 28, marginBottom: 14, fontSize: 17, fontWeight: 700, color: '#1A1D28' }}></h3> {/* Health vitals */}
<h3 style={{ marginTop: 28, marginBottom: 14, fontSize: 17, fontWeight: 700, color: '#1A1D28' }}></h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 14 }}> <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={{ <div key={type} style={{
@@ -132,6 +157,152 @@ export function PatientDetailPage() {
</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() {
@@ -47,6 +47,7 @@ export function PatientListPage() {
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}></th> <th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}></th>
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}></th> <th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}></th>
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}></th> <th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}></th>
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}></th>
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}></th> <th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}></th>
</tr> </tr>
</thead> </thead>
@@ -58,6 +59,7 @@ export function PatientListPage() {
<td style={{ padding: '12px 20px' }}>{p.gender || '-'}</td> <td style={{ padding: '12px 20px' }}>{p.gender || '-'}</td>
<td style={{ padding: '12px 20px', color: '#5A6072' }}>{(p.medicalHistory || []).slice(0, 3).join('、') || '-'}</td> <td style={{ padding: '12px 20px', color: '#5A6072' }}>{(p.medicalHistory || []).slice(0, 3).join('、') || '-'}</td>
<td style={{ padding: '12px 20px', color: '#5A6072' }}>{p.stentDate || '-'}</td> <td style={{ padding: '12px 20px', color: '#5A6072' }}>{p.stentDate || '-'}</td>
<td style={{ padding: '12px 20px', color: '#5A6072' }}>{p.stentType || '-'}</td>
<td style={{ padding: '12px 20px' }}> <td style={{ padding: '12px 20px' }}>
<Link to={`/patients/${p.id}`} style={{ <Link to={`/patients/${p.id}`} style={{
color: '#4F6EF7', fontSize: 12, fontWeight: 600, color: '#4F6EF7', fontSize: 12, fontWeight: 600,
@@ -67,7 +69,7 @@ export function PatientListPage() {
</tr> </tr>
))} ))}
{filtered.length === 0 && ( {filtered.length === 0 && (
<tr><td colSpan={6} style={{ padding: 32, textAlign: 'center', color: '#9BA0B4' }}></td></tr> <tr><td colSpan={7} style={{ padding: 32, textAlign: 'center', color: '#9BA0B4' }}></td></tr>
)} )}
</tbody> </tbody>
</table> </table>

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[];
} }
@@ -56,54 +56,50 @@ export function ReportDetailPage() {
finally { setSubmitting(false); } finally { setSubmitting(false); }
}; };
if (!report) return <div style={{ padding: 28, color: '#9BA0B4' }}>...</div>; if (!report) return <div style={{ padding: 24 }}>...</div>;
const isCompleted = report.status === 'completed'; const isCompleted = report.status === 'completed';
const riskMap: Record<string, { text: string; color: string }> = { const riskMap: Record<string, { text: string; color: string }> = {
normal: { text: '正常', color: '#20C997' }, normal: { text: '正常', color: '#2e7d32' },
attention: { text: '关注', color: '#F59E0B' }, attention: { text: '关注', color: '#f57c00' },
abnormal: { text: '异常', color: '#EF4444' }, abnormal: { text: '异常', color: '#c62828' },
};
const inputStyle: React.CSSProperties = {
width: '100%', padding: '10px 14px', border: '1.5px solid #E1E5ED',
borderRadius: 10, fontSize: 13, outline: 'none', boxSizing: 'border-box', fontFamily: 'inherit',
}; };
return ( return (
<div style={{ padding: 28 }}> <div style={{ padding: 24 }}>
<Link to="/reports" style={{ fontSize: 13, color: '#4F6EF7', fontWeight: 500 }}> </Link> <Link to="/reports" style={{ fontSize: 13, color: '#1976d2' }}> </Link>
<div style={{ background: '#fff', marginTop: 16, padding: 28, borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}> <div style={{ background: '#fff', marginTop: 16, padding: 24, borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div> <div>
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>{report.title}</h2> <h2 style={{ margin: 0 }}>{report.title}</h2>
<div style={{ marginTop: 8, fontSize: 13, color: '#9BA0B4' }}> <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={{
padding: '6px 14px', borderRadius: 12, fontSize: 12, fontWeight: 600, padding: '4px 12px', borderRadius: 12, fontSize: 12, fontWeight: 500,
background: isCompleted ? '#E6F9F2' : '#FFF8E6', background: isCompleted ? '#e8f5e9' : '#fff3e0',
color: isCompleted ? '#20C997' : '#F59E0B', color: isCompleted ? '#2e7d32' : '#f57c00',
}}> }}>
{isCompleted ? '已完成' : '待审核'} {isCompleted ? '已完成' : '待审核'}
</span> </span>
</div> </div>
{/* 图片 */}
{report.imageUrls && report.imageUrls.length > 0 && ( {report.imageUrls && report.imageUrls.length > 0 && (
<div style={{ marginTop: 24 }}> <div style={{ marginTop: 20 }}>
<h4 style={{ fontSize: 14, fontWeight: 600, marginBottom: 10, color: '#5A6072' }}>{report.imageUrls.length}</h4> <h4 style={{ fontSize: 14, marginBottom: 8 }}>{report.imageUrls.length}</h4>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{report.imageUrls.map((url, i) => ( {report.imageUrls.map((url, i) => (
<div key={i} onClick={() => setLightbox(url)} style={{ <div key={i} onClick={() => setLightbox(url)} style={{
width: 120, height: 120, borderRadius: 12, overflow: 'hidden', width: 120, height: 120, borderRadius: 8, overflow: 'hidden',
cursor: 'pointer', border: '2px solid #F0F2F5', background: '#F9FAFC', cursor: 'pointer', border: '2px solid #eee', background: '#f5f5f5',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}> }}>
<img src={`${import.meta.env.VITE_API_URL}${url}`} alt={`图片${i}`} <img src={`http://localhost:5000${url}`} alt={`图片${i}`}
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'cover' }} style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'cover' }}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} /> onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
</div> </div>
@@ -112,35 +108,37 @@ export function ReportDetailPage() {
</div> </div>
)} )}
{/* 灯箱 */}
{lightbox && ( {lightbox && (
<div onClick={() => setLightbox(null)} style={{ <div onClick={() => setLightbox(null)} style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)', position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 999, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 999, cursor: 'pointer',
}}> }}>
<img src={`${import.meta.env.VITE_API_URL}${lightbox}`} alt="预览" style={{ maxWidth: '90vw', maxHeight: '90vh', borderRadius: 12 }} /> <img src={`http://localhost:5000${lightbox}`} alt="预览" style={{ maxWidth: '90vw', maxHeight: '90vh', borderRadius: 8 }} />
</div> </div>
)} )}
{/* 已完成解读 */}
{isCompleted && ( {isCompleted && (
<div style={{ marginTop: 24, padding: 20, background: '#E6F9F2', borderRadius: 12 }}> <div style={{ marginTop: 20, padding: 16, background: '#e8f5e9', borderRadius: 8 }}>
<h4 style={{ fontSize: 15, fontWeight: 600, marginBottom: 12, color: '#20C997' }}></h4> <h4 style={{ fontSize: 14, marginBottom: 8 }}></h4>
<div style={{ fontSize: 13, color: '#5A6072' }}> <div style={{ fontSize: 13 }}>
<p style={{ margin: '6px 0' }}><strong style={{ color: '#1A1D28' }}></strong> <p><strong></strong>
<span style={{ color: riskMap[report.riskLevel || '']?.color, fontWeight: 600 }}> <span style={{ color: riskMap[report.riskLevel || '']?.color, fontWeight: 600 }}>
{riskMap[report.riskLevel || '']?.text || report.riskLevel || '-'} {riskMap[report.riskLevel || '']?.text || report.riskLevel || '-'}
</span> </span>
</p> </p>
<p style={{ margin: '6px 0' }}><strong style={{ color: '#1A1D28' }}></strong>{report.summary || '-'}</p> <p><strong></strong>{report.summary || '-'}</p>
{report.suggestions && <p style={{ margin: '6px 0' }}><strong style={{ color: '#1A1D28' }}></strong>{report.suggestions}</p>} {report.suggestions && <p><strong></strong>{report.suggestions}</p>}
</div> </div>
{report.items && report.items.length > 0 && ( {report.items && report.items.length > 0 && (
<table style={{ width: '100%', marginTop: 14, borderCollapse: 'collapse', fontSize: 12 }}> <table style={{ width: '100%', marginTop: 12, borderCollapse: 'collapse', fontSize: 12 }}>
<thead><tr style={{ textAlign: 'left', borderBottom: '2px solid #c8e6c9' }}> <thead><tr style={{ textAlign: 'left', borderBottom: '2px solid #c8e6c9' }}>
<th style={{ padding: '6px 8px', color: '#5A6072' }}></th> <th style={{ padding: '6px 8px' }}></th>
<th style={{ padding: '6px 8px', color: '#5A6072' }}></th> <th style={{ padding: '6px 8px' }}></th>
<th style={{ padding: '6px 8px', color: '#5A6072' }}></th> <th style={{ padding: '6px 8px' }}></th>
<th style={{ padding: '6px 8px', color: '#5A6072' }}></th> <th style={{ padding: '6px 8px' }}></th>
</tr></thead> </tr></thead>
<tbody> <tbody>
{report.items.map((item) => ( {report.items.map((item) => (
@@ -148,7 +146,7 @@ export function ReportDetailPage() {
<td style={{ padding: '6px 8px' }}>{item.itemName}</td> <td style={{ padding: '6px 8px' }}>{item.itemName}</td>
<td style={{ padding: '6px 8px' }}>{item.resultValue} {item.unit || ''}</td> <td style={{ padding: '6px 8px' }}>{item.resultValue} {item.unit || ''}</td>
<td style={{ padding: '6px 8px' }}>{item.referenceRange || '-'}</td> <td style={{ padding: '6px 8px' }}>{item.referenceRange || '-'}</td>
<td style={{ padding: '6px 8px', color: item.isAbnormal ? '#EF4444' : '#20C997', fontWeight: 600 }}> <td style={{ padding: '6px 8px', color: item.isAbnormal ? '#c62828' : '#2e7d32', fontWeight: 500 }}>
{item.isAbnormal ? '是' : '否'} {item.isAbnormal ? '是' : '否'}
</td> </td>
</tr> </tr>
@@ -159,66 +157,68 @@ export function ReportDetailPage() {
</div> </div>
)} )}
{/* 解读表单 */}
{!isCompleted && ( {!isCompleted && (
<div style={{ marginTop: 28, borderTop: '1px solid #F0F2F5', paddingTop: 24 }}> <div style={{ marginTop: 24, borderTop: '1px solid #eee', paddingTop: 20 }}>
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: 18, color: '#1A1D28' }}></h3> <h3 style={{ fontSize: 15, marginBottom: 16 }}></h3>
<div style={{ marginBottom: 14 }}> <div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 5 }}></label> <label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 4 }}></label>
<textarea value={summary} onChange={(e) => setSummary(e.target.value)} <textarea value={summary} onChange={(e) => setSummary(e.target.value)}
placeholder="请输入您的专业解读总结..." placeholder="请输入您的专业解读总结..."
rows={4} rows={4}
style={{ ...inputStyle, resize: 'vertical' }} /> style={{ width: '100%', padding: '10px 12px', border: '1px solid #ddd', borderRadius: 6, fontSize: 13, resize: 'vertical', fontFamily: 'inherit', boxSizing: 'border-box' }} />
</div> </div>
<div style={{ display: 'flex', gap: 16, marginBottom: 14 }}> <div style={{ display: 'flex', gap: 16, marginBottom: 12 }}>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 5 }}></label> <label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 4 }}></label>
<select value={riskLevel} onChange={(e) => setRiskLevel(e.target.value)} style={inputStyle}> <select value={riskLevel} onChange={(e) => setRiskLevel(e.target.value)}
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 6, fontSize: 13, fontFamily: 'inherit' }}>
<option value="normal"></option> <option value="normal"></option>
<option value="attention"></option> <option value="attention"></option>
<option value="abnormal"></option> <option value="abnormal"></option>
</select> </select>
</div> </div>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 5 }}></label> <label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 4 }}></label>
<input value={suggestions} onChange={(e) => setSuggestions(e.target.value)} <input value={suggestions} onChange={(e) => setSuggestions(e.target.value)}
placeholder="如:继续当前用药方案" placeholder="如:继续当前用药方案"
style={inputStyle} /> style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 6, fontSize: 13, fontFamily: 'inherit', boxSizing: 'border-box' }} />
</div> </div>
</div> </div>
<div style={{ marginBottom: 14 }}> <div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 8 }}></label> <label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}></label>
{items.map((item, i) => ( {items.map((item, i) => (
<div key={i} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}> <div key={i} style={{ display: 'flex', gap: 8, marginBottom: 6, alignItems: 'center' }}>
<input placeholder="项目名称" value={item.itemName} onChange={(e) => updateItem(i, 'itemName', e.target.value)} <input placeholder="项目名称" value={item.itemName} onChange={(e) => updateItem(i, 'itemName', e.target.value)}
style={{ flex: 2, padding: '8px 12px', border: '1.5px solid #E1E5ED', borderRadius: 8, fontSize: 12, outline: 'none' }} /> style={{ flex: 2, padding: '6px 10px', border: '1px solid #ddd', borderRadius: 4, fontSize: 12, fontFamily: 'inherit' }} />
<input placeholder="结果" value={item.resultValue} onChange={(e) => updateItem(i, 'resultValue', e.target.value)} <input placeholder="结果" value={item.resultValue} onChange={(e) => updateItem(i, 'resultValue', e.target.value)}
style={{ flex: 1, padding: '8px 12px', border: '1.5px solid #E1E5ED', borderRadius: 8, fontSize: 12, outline: 'none' }} /> style={{ flex: 1, padding: '6px 10px', border: '1px solid #ddd', borderRadius: 4, fontSize: 12, fontFamily: 'inherit' }} />
<input placeholder="单位" value={item.unit} onChange={(e) => updateItem(i, 'unit', e.target.value)} <input placeholder="单位" value={item.unit} onChange={(e) => updateItem(i, 'unit', e.target.value)}
style={{ width: 70, padding: '8px 12px', border: '1.5px solid #E1E5ED', borderRadius: 8, fontSize: 12, outline: 'none' }} /> style={{ width: 70, padding: '6px 10px', border: '1px solid #ddd', borderRadius: 4, fontSize: 12, fontFamily: 'inherit' }} />
<input placeholder="参考范围" value={item.referenceRange} onChange={(e) => updateItem(i, 'referenceRange', e.target.value)} <input placeholder="参考范围" value={item.referenceRange} onChange={(e) => updateItem(i, 'referenceRange', e.target.value)}
style={{ flex: 1, padding: '8px 12px', border: '1.5px solid #E1E5ED', borderRadius: 8, fontSize: 12, outline: 'none' }} /> style={{ flex: 1, padding: '6px 10px', border: '1px solid #ddd', borderRadius: 4, fontSize: 12, fontFamily: 'inherit' }} />
<label style={{ fontSize: 12, whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: 4, color: '#5A6072' }}> <label style={{ fontSize: 12, whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: 3 }}>
<input type="checkbox" checked={item.isAbnormal} onChange={(e) => updateItem(i, 'isAbnormal', e.target.checked)} /> <input type="checkbox" checked={item.isAbnormal} onChange={(e) => updateItem(i, 'isAbnormal', e.target.checked)} />
</label> </label>
<button onClick={() => removeItem(i)} <button onClick={() => removeItem(i)}
style={{ background: 'none', border: 'none', color: '#EF4444', cursor: 'pointer', fontSize: 18, fontWeight: 700 }} style={{ background: 'none', border: 'none', color: '#c62828', cursor: 'pointer', fontSize: 16 }}
disabled={items.length <= 1}>×</button> disabled={items.length <= 1}></button>
</div> </div>
))} ))}
<button onClick={addItem} style={{ <button onClick={addItem} style={{
padding: '6px 14px', border: '1.5px dashed #4F6EF7', borderRadius: 8, padding: '4px 12px', border: '1px dashed #1976d2', borderRadius: 4,
background: 'none', color: '#4F6EF7', cursor: 'pointer', fontSize: 12, fontWeight: 500, background: 'none', color: '#1976d2', cursor: 'pointer', fontSize: 12,
}}>+ </button> }}>+ </button>
</div> </div>
<button onClick={handleInterpret} disabled={submitting} style={{ <button onClick={handleInterpret} disabled={submitting} style={{
padding: '11px 32px', background: 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)', color: '#fff', padding: '10px 28px', background: '#1976d2', color: '#fff',
border: 'none', borderRadius: 10, fontSize: 14, cursor: 'pointer', fontWeight: 600, border: 'none', borderRadius: 6, fontSize: 14, cursor: 'pointer',
opacity: submitting ? 0.7 : 1, marginTop: 8, boxShadow: '0 4px 16px rgba(79,110,247,0.25)', opacity: submitting ? 0.7 : 1, marginTop: 8,
}}> }}>
{submitting ? '提交中...' : '提交解读'} {submitting ? '提交中...' : '提交解读'}
</button> </button>

View File

@@ -4,7 +4,7 @@ import { api } from '../../services/api-client';
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() {
@@ -52,7 +52,7 @@ export function ReportListPage() {
{s.text} {s.text}
</span> </span>
</td> </td>
<td style={{ padding: '12px 20px', color: '#9BA0B4' }}>{r.createdAt?.split('T')[0]}</td> <td style={{ padding: '12px 20px', color: '#9BA0B4' }}>{r.uploadedAt?.split('T')[0]}</td>
<td style={{ padding: '12px 20px' }}> <td style={{ padding: '12px 20px' }}>
<Link to={`/reports/${r.id}`} style={{ <Link to={`/reports/${r.id}`} style={{
color: '#4F6EF7', fontSize: 12, fontWeight: 600, color: '#4F6EF7', fontSize: 12, fontWeight: 600,

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

@@ -6,7 +6,7 @@ interface ApiResponse<T> {
message: string; message: string;
} }
const BASE_URL = import.meta.env.VITE_API_URL; const BASE_URL = 'http://localhost:5000';
// Endpoints that should NEVER include auth token // Endpoints that should NEVER include auth token
const PUBLIC_ENDPOINTS = ['/api/auth/login', '/api/auth/register', '/api/auth/send-sms', '/api/auth/refresh']; const PUBLIC_ENDPOINTS = ['/api/auth/login', '/api/auth/register', '/api/auth/send-sms', '/api/auth/refresh'];

View File

@@ -1,9 +0,0 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

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,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); font-weight: 500; } .tabs {
.tabActive { background: var(--color-primary); color: var(--color-text-inverse); } display: flex;
.sectionTitle { font-size: var(--font-size-base); font-weight: 700; 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); font-weight: 600; }
.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; line-height: 1.5; } 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-weight: 500; } 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)}> </button>
{t.label}
</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.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>
<div className={styles.recMeta}>{r.duration} · {r.frequency} · {r.intensity}</div> <div className={styles.summaryRight}>
</Card> <svg width="64" height="64" viewBox="0 0 64 64" fill="none">
))} <circle cx="32" cy="32" r="28" stroke="#E8ECF4" strokeWidth="6" />
<h3 className={styles.sectionTitle}></h3> <circle cx="32" cy="32" r="28" stroke="url(#exGrad)" strokeWidth="6" strokeLinecap="round"
{dietRecommendations.slice(0, 3).map((d, i) => ( strokeDasharray={`${Math.min(todayExKcal / 3, 175)} 176`} transform="rotate(-90 32 32)" />
<Card key={i} className={styles.recCard}> <defs><linearGradient id="exGrad"><stop stopColor="#4F6EF7"/><stop offset="1" stopColor="#6C8AFF"/></linearGradient></defs>
<div className={styles.recHeader}><span>{d.title}</span></div> </svg>
<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>
</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> <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>
))
)}
{/* Recommendations */}
<h3 className={styles.sectionTitle}></h3>
<div className={styles.recGrid}>
{exRecommendations.slice(0, 4).map((r, i) => (
<div key={i} className={`${styles.recCard} ${!r.suitable ? styles.recNo : ''}`}>
<div className={styles.recEmoji}>{r.name === '散步' ? '🚶' : r.name === '太极拳' ? '🤸' : r.name === '慢跑' ? '🏃' : r.name === '游泳' ? '🏊' : r.name === '骑自行车' ? '🚴' : r.name === '八段锦' ? '🧘' : '🏋️'}</div>
<div className={styles.recName}>{r.name}</div>
<div className={styles.recMeta}>{r.duration} · {r.frequency}</div>
<span className={`${styles.recBadge} ${r.suitable ? styles.recGood : styles.recBad}`}>
{r.suitable ? '适合' : '避免'}
</span>
</div>
))}
</div>
</>
)} )}
{subTab === 'diet' && ( {/* ============ DIET TAB ============ */}
<div> {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.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> <div className={styles.dayLabel}>{formatDate(date, 'MM月DD日')} · {items.reduce((s, d) => s + (d.totalCalories || 0), 0)}kcal</div>
{items.map((d, i) => (
<div key={i} className={`${styles.logCard} ${styles[`meal${d.mealType?.[0]?.toUpperCase()}${d.mealType?.slice(1)}`] || ''}`}>
<div className={styles.logIcon}>{MEAL_TYPES.find(m => m.key === d.mealType)?.icon || '🍽️'}</div>
<div className={styles.logInfo}>
<div className={styles.logTitle}>{d.foods?.map(f => f.name).join('、')}</div>
<div className={styles.logMeta}>
{MEAL_TYPES.find(m => m.key === d.mealType)?.label} · {d.totalCalories}kcal · {d.foods?.map(f => f.amount).join('、')}
</div>
</div>
<button className={styles.delBtn} onClick={() => deleteDiet(d.id)} title="删除">×</button>
</div>
))}
</div>
))
)}
{/* Recommendations */}
<h3 className={styles.sectionTitle}></h3>
<div className={styles.recGrid}>
{dietRecommendations.slice(0, 4).map((d, i) => (
<div key={i} className={styles.dietRecCard}>
<div className={styles.dietRecHeader}>
<span className={styles.dietRecIcon}>💡</span>
<span className={styles.dietRecTitle}>{d.title}</span>
</div>
<p className={styles.dietRecDesc}>{d.description.slice(0, 40)}...</p>
<div className={styles.dietRecFoods}>
{d.recommendedFoods.slice(0, 3).map((f, j) => (
<span key={j} className={styles.dietFoodTag}>{f}</span>
))}
</div>
</div>
))}
</div>
</>
)} )}
<ToastContainer /> <ToastContainer />

View File

@@ -1,57 +1,251 @@
.grid { /* Combined card */
display: grid; .combinedCard {
grid-template-columns: repeat(3, 1fr); display: flex;
gap: 12px; 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: 8px; gap: 8px;
padding: 18px 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.2s; box-shadow: 0 1px 8px rgba(0,0,0,0.04);
-webkit-tap-highlight-color: transparent; cursor: pointer;
position: relative; transition: transform 0.15s, box-shadow 0.15s;
overflow: hidden;
} }
.card:active { transform: scale(0.95); } .quickCard:active { transform: scale(0.95); }
.cardIcon { .quickIcon {
width: 50px; width: 42px;
height: 50px; height: 42px;
border-radius: 16px; border-radius: 12px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.cardTitle { font-size: var(--font-size-base); font-weight: 700; color: var(--color-text-primary); } .quickLabel {
.cardDesc { font-size: 11px; color: var(--color-text-tertiary); } font-size: 12px;
font-weight: 600;
.extraLinks { color: #1A1D28;
display: flex;
flex-direction: column;
gap: 8px;
} }
.linkCard { /* AI Assistant */
.aiCard {
display: flex;
flex-direction: column;
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;
}
.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-lg);
box-shadow: var(--shadow-sm);
font-size: var(--font-size-base);
font-weight: 600;
-webkit-tap-highlight-color: transparent;
transition: background 0.15s;
} }
.linkCard:active { background: #FAFBFC; } .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

@@ -2,117 +2,34 @@ import { useNavigate } from 'react-router-dom';
import { PageHeader } from '@/components/layout/PageHeader'; import { PageHeader } from '@/components/layout/PageHeader';
import styles from './HealthHubPage.module.css'; import styles from './HealthHubPage.module.css';
const HEALTH_ITEMS = [ const COMBINED = {
{ path: '/health/records/add',
path: '/health/records?type=blood_pressure', label: '健康指标',
label: '血压', desc: '血压 · 心率 · 血糖 · 血氧 · 体重',
desc: '记录和趋势', indicators: [
svg: ( { label: '血压', color: '#EF4444', bg: '#FEE9E9' },
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#EF4444" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> { label: '心率', color: '#F59E0B', bg: '#FFF4E5' },
<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" /> { label: '血糖', color: '#845EF7', bg: '#F3E8FF' },
<polyline points="12 7 12 13 15 15" /> { label: '血氧', color: '#339AF0', bg: '#E6F0FF' },
</svg> { label: '体重', color: '#20C997', bg: '#E6F9F2' },
), ],
bg: '#FEE9E9', };
},
{
path: '/health/records?type=heart_rate',
label: '心率',
desc: '记录和趋势',
svg: (
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#F59E0B" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
),
bg: '#FFF4E5',
},
{
path: '/health/records?type=blood_sugar',
label: '血糖',
desc: '记录和趋势',
svg: (
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#845EF7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" />
</svg>
),
bg: '#F3E8FF',
},
{
path: '/health/records?type=spo2',
label: '血氧',
desc: '记录和趋势',
svg: (
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#339AF0" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
),
bg: '#E6F0FF',
},
{
path: '/health/records?type=weight',
label: '体重',
desc: '记录和趋势',
svg: (
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#20C997" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 20V10" />
<path d="M18 20V4" />
<path d="M6 20v-4" />
</svg>
),
bg: '#E6F9F2',
},
{
path: '/health/records?type=steps',
label: '步数',
desc: '记录和趋势',
svg: (
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#6366F1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M13 4h3l3 7-4 2v7H9v-7l-4-2 3-7h3" />
<circle cx="12" cy="4" r="2" />
</svg>
),
bg: '#EEF2FF',
},
];
const QUICK_LINKS = [ const QUICK_LINKS = [
{ {
label: '健康日历', label: '健康日历', path: '/health/calendar',
path: '/health/calendar', color: '#F59E0B', bg: '#FFF8E6',
svg: ( 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>),
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#F59E0B" 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>
),
bg: '#FFF0E0',
}, },
{ {
label: '服药管理', label: '服药管理', path: '/health/medications',
path: '/health/medications', color: '#D67E0B', bg: '#FFF4E5',
svg: ( 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>),
<svg width="20" height="20" 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>
),
bg: '#FFF4E5',
}, },
{ {
label: '运动饮食', label: '运动饮食', path: '/health/exercise-diet',
path: '/health/exercise-diet', color: '#20C997', bg: '#E6F9F2',
svg: ( 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>),
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#20C997" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" />
</svg>
),
bg: '#E6F9F2',
}, },
]; ];
@@ -122,25 +39,81 @@ export function HealthHubPage() {
return ( return (
<div className="page"> <div className="page">
<PageHeader title="健康中心" showBack={false} /> <PageHeader title="健康中心" showBack={false} />
<div className={styles.grid}>
{HEALTH_ITEMS.map((item) => ( {/* Combined indicators card */}
<button key={item.path} className={styles.card} onClick={() => navigate(item.path)}> <button className={styles.combinedCard} onClick={() => navigate(COMBINED.path)}>
<span className={styles.cardIcon} style={{ background: item.bg }}>{item.svg}</span> <div className={styles.combinedRow}>
<span className={styles.cardTitle}>{item.label}</span> <div className={styles.combinedIcon}>
<span className={styles.cardDesc}>{item.desc}</span> <svg width="28" height="28" viewBox="0 0 32 32" fill="none">
<rect x="2" y="2" width="12" height="12" rx="3" fill="#EEF2FF" stroke="#4F6EF7" strokeWidth="1.5" />
<rect x="18" y="2" width="12" height="12" rx="3" fill="#FEE9E9" stroke="#EF4444" strokeWidth="1.5" />
<rect x="2" y="18" width="12" height="12" rx="3" fill="#FFF4E5" stroke="#F59E0B" strokeWidth="1.5" />
<rect x="18" y="18" width="12" height="12" rx="3" fill="#E6F9F2" stroke="#20C997" strokeWidth="1.5" />
</svg>
</div>
<div className={styles.combinedInfo}>
<span className={styles.combinedTitle}>{COMBINED.label}</span>
<span className={styles.combinedDesc}>{COMBINED.desc}</span>
</div>
<span className={styles.combinedArrow}></span>
</div>
<div className={styles.indicatorTags}>
{COMBINED.indicators.map((ind) => (
<span key={ind.label} className={styles.tag} style={{ background: ind.bg, color: ind.color }}>
{ind.label}
</span>
))}
</div>
</button>
{/* Quick links — horizontal row */}
<div className={styles.quickRow}>
{QUICK_LINKS.map((link) => (
<button key={link.path} className={styles.quickCard} onClick={() => navigate(link.path)}>
<span className={styles.quickIcon} style={{ background: link.bg, color: link.color }}>
{link.svg}
</span>
<span className={styles.quickLabel}>{link.label}</span>
</button> </button>
))} ))}
</div> </div>
<div className={styles.extraLinks}> {/* AI 健康助手 */}
{QUICK_LINKS.map((link) => ( <div className={styles.aiCard}>
<button key={link.path} className={styles.linkCard} onClick={() => navigate(link.path)}> <div className={styles.aiHeader}>
<span className={styles.cardIcon} style={{ background: link.bg, width: 40, height: 40, borderRadius: 12 }}> <div className={styles.aiAvatar}>
{link.svg} <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
</span> <circle cx="12" cy="12" r="4" />
{link.label} <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 className={styles.aiQuestion}>
<span className={styles.aiDot} />
</button>
</div>
<div className={styles.inputHint}>
<svg className={styles.inputHintIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
AI ...
</div>
</div> </div>
</div> </div>
); );

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 handleSubmit = async () => { const setVal = (key: string, v: string) => setValues((prev) => ({ ...prev, [key]: v }));
const numVal = parseFloat(value);
if (type === 'blood_pressure') { const saveOne = async (type: string) => {
const sys = parseFloat(systolic); setLoading(type);
const dia = parseFloat(diastolic); try {
if (!sys || !dia) { toast('请填写完整', 'error'); return; } const d = dates[type] || dayjs().format('YYYY-MM-DD');
await healthService.addRecord({ const recordedAt = `${d}T${dayjs().format('HH:mm:ss')}`;
type, const ind = INDICATORS.find((i) => i.type === type)!;
value: { systolic: sys, diastolic: dia },
unit: 'mmHg', if (type === 'blood_pressure') {
recordedAt: `${date}T${time}:00`, const sys = parseFloat(values.systolic);
recordedDate: date, const dia = parseFloat(values.diastolic);
source: 'manual', if (!sys || !dia) { toast('请填写收缩压和舒张压', 'error'); return; }
}); await healthService.addRecord({ type, value: { systolic: sys, diastolic: dia }, unit: 'mmHg', recordedAt, recordedDate: d, 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>
</>
) : (
<Input
label={`${config.label} (${config.unit})`}
value={value}
onChange={(e) => setValue(e.target.value)}
type="number"
step="0.1"
/>
)}
<div className={styles.row}> <div className={styles.cardInputs}>
<Input label="日期" value={date} onChange={(e) => setDate(e.target.value)} type="date" /> {ind.fields.map((f) => (
<Input label="时间" value={time} onChange={(e) => setTime(e.target.value)} type="time" /> <input
</div> key={f.key}
className={styles.cardInput}
<Button variant="primary" size="lg" fullWidth loading={loading} onClick={handleSubmit}> type="number"
step={ind.type === 'blood_sugar' || ind.type === 'weight' ? '0.1' : '1'}
</Button> 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>
</div>
))}
</div> </div>
{/* Trend entry */}
<button className={styles.trendCard} onClick={() => navigate('/health/trends')}>
<span className={styles.trendIcon}>📈</span>
<div className={styles.trendInfo}>
<span className={styles.trendTitle}></span>
<span className={styles.trendSub}> · · · · </span>
</div>
<span className={styles.trendArrow}></span>
</button>
<ToastContainer /> <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[] = [];
date: r.recordedDate, for (const ind of INDICATORS) {
value: isBP ? (typeof r.value === 'object' ? r.value.systolic : 0) : (r.value as number), if (!visible.has(ind.type)) continue;
value2: isBP ? (typeof r.value === 'object' ? r.value.diastolic : 0) : undefined, const sourceType = (ind as Record<string, string>).source || ind.type;
})); const raw = allRecords
.filter((r) => r.type === sourceType)
.sort((a, b) => a.recordedDate.localeCompare(b.recordedDate));
if ('field' in ind && ind.field) {
result.push({
name: ind.label,
color: ind.color,
data: raw.map((r) => ({
date: r.recordedDate,
value: typeof r.value === 'object' ? (r.value as Record<string, number>)[ind.field!] : 0,
})),
unit: ind.unit,
});
} else {
result.push({
name: ind.label,
color: ind.color,
data: raw.map((r) => ({ date: r.recordedDate, value: r.value as number })),
unit: ind.unit,
});
}
}
return result;
}, [allRecords, visible]);
const toggle = (type: string) => {
const next = new Set(visible);
if (next.has(type)) next.delete(type); else next.add(type);
setVisible(next);
};
const hasData = series.some((s) => s.data.length > 0);
return ( 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

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -62,7 +62,7 @@ export function ChatPage() {
// Set up SignalR connection // Set up SignalR connection
const conn = new HubConnectionBuilder() const conn = new HubConnectionBuilder()
.withUrl(`${import.meta.env.VITE_API_URL}/hubs/chat`, { .withUrl('/hubs/chat', {
accessTokenFactory: () => getToken(), accessTokenFactory: () => getToken(),
}) })
.withAutomaticReconnect() .withAutomaticReconnect()

View File

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

View File

@@ -53,7 +53,7 @@ export function ReportDetailPage() {
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8 }}></div> <div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8 }}></div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{report.imageUrls.map((url, i) => ( {report.imageUrls.map((url, i) => (
<img key={i} src={`${import.meta.env.VITE_API_URL}${url}`} alt="report" <img key={i} src={url} alt="report"
style={{ width: 80, height: 80, borderRadius: 8, objectFit: 'cover', border: '1px solid #eee' }} /> style={{ width: 80, height: 80, borderRadius: 8, objectFit: 'cover', border: '1px solid #eee' }} />
))} ))}
</div> </div>

View File

@@ -38,7 +38,7 @@ export function ReportUploadPage() {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
const token = JSON.parse(localStorage.getItem('hrt_auth') || '{}')?.state?.token; const token = JSON.parse(localStorage.getItem('hrt_auth') || '{}')?.state?.token;
const res = await fetch(`${import.meta.env.VITE_API_URL}/api/files/upload`, { const res = await fetch('/api/files/upload', {
method: 'POST', method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : {}, headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: formData, body: formData,

View File

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

View File

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

View File

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

View File

@@ -22,9 +22,11 @@ import { ReportUploadPage } from '@/pages/services/ReportUploadPage';
import { ReportDetailPage } from '@/pages/services/ReportDetailPage'; import { ReportDetailPage } from '@/pages/services/ReportDetailPage';
import { FollowUpListPage } from '@/pages/services/FollowUpListPage'; import { FollowUpListPage } from '@/pages/services/FollowUpListPage';
import { FollowUpEditPage } from '@/pages/services/FollowUpEditPage'; import { FollowUpEditPage } from '@/pages/services/FollowUpEditPage';
import { VisitListPage } from '@/pages/services/VisitListPage';
import { ExerciseDietPage } from '@/pages/exercise-diet/ExerciseDietPage'; import { ExerciseDietPage } from '@/pages/exercise-diet/ExerciseDietPage';
import { ProfilePage } from '@/pages/profile/ProfilePage'; import { ProfilePage } from '@/pages/profile/ProfilePage';
import { EditProfilePage } from '@/pages/profile/EditProfilePage'; import { EditProfilePage } from '@/pages/profile/EditProfilePage';
import { HealthRecordPage } from '@/pages/profile/HealthRecordPage';
import { SettingsPage } from '@/pages/profile/SettingsPage'; import { SettingsPage } from '@/pages/profile/SettingsPage';
import { import {
NotificationSettingsPage, NotificationSettingsPage,
@@ -69,7 +71,7 @@ export const router = createBrowserRouter([
{ path: 'home/device-binding', element: <DeviceBindingPage /> }, { path: 'home/device-binding', element: <DeviceBindingPage /> },
{ path: 'health/records', element: <HealthRecordListPage /> }, { path: 'health/records', element: <HealthRecordListPage /> },
{ path: 'health/records/add', element: <ManualEntryPage /> }, { path: 'health/records/add', element: <ManualEntryPage /> },
{ path: 'health/trends/:type', element: <TrendChartPage /> }, { path: 'health/trends', element: <TrendChartPage /> },
{ path: 'health/calendar', element: <HealthCalendarPage /> }, { path: 'health/calendar', element: <HealthCalendarPage /> },
{ path: 'health/medications', element: <MedicationListPage /> }, { path: 'health/medications', element: <MedicationListPage /> },
{ path: 'health/medications/add', element: <MedicationEditPage /> }, { path: 'health/medications/add', element: <MedicationEditPage /> },
@@ -81,7 +83,9 @@ export const router = createBrowserRouter([
{ path: 'services/reports/:id', element: <ReportDetailPage /> }, { path: 'services/reports/:id', element: <ReportDetailPage /> },
{ path: 'services/follow-ups', element: <FollowUpListPage /> }, { path: 'services/follow-ups', element: <FollowUpListPage /> },
{ path: 'services/follow-ups/add', element: <FollowUpEditPage /> }, { path: 'services/follow-ups/add', element: <FollowUpEditPage /> },
{ path: 'services/visits', element: <VisitListPage /> },
{ path: 'profile/edit', element: <EditProfilePage /> }, { path: 'profile/edit', element: <EditProfilePage /> },
{ path: 'profile/health-record', element: <HealthRecordPage /> },
{ path: 'profile/settings', element: <SettingsPage /> }, { path: 'profile/settings', element: <SettingsPage /> },
{ path: 'profile/settings/notifications', element: <NotificationSettingsPage /> }, { path: 'profile/settings/notifications', element: <NotificationSettingsPage /> },
{ path: 'profile/settings/privacy', element: <PrivacyPage /> }, { path: 'profile/settings/privacy', element: <PrivacyPage /> },

View File

@@ -1,15 +1,11 @@
/** // API client — uses Vite proxy in dev, same-origin in production.
* Real HTTP API client — replaces mockApiResponse with actual fetch calls.
* Backend base: http://localhost:5000
*/
interface ApiResponse<T> { interface ApiResponse<T> {
code: number; code: number;
data: T; data: T;
message: string; message: string;
} }
const BASE_URL = import.meta.env.VITE_API_URL; const BASE_URL = '';
// Endpoints that should NEVER include auth token // Endpoints that should NEVER include auth token
const PUBLIC_ENDPOINTS = ['/api/auth/login', '/api/auth/register', '/api/auth/send-sms', '/api/auth/refresh']; const PUBLIC_ENDPOINTS = ['/api/auth/login', '/api/auth/register', '/api/auth/send-sms', '/api/auth/refresh'];

View File

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

View File

@@ -93,6 +93,14 @@ export async function addDietLog(data: Omit<DietRecord, 'id' | 'userId'>): Promi
}; };
} }
export async function deleteExerciseLog(id: string): Promise<void> {
await api.del(`/api/health-records/${id}`);
}
export async function deleteDietLog(id: string): Promise<void> {
await api.del(`/api/health-records/${id}`);
}
export function getExerciseRecommendations() { export function getExerciseRecommendations() {
return EXERCISE_RECOMMENDATIONS; return EXERCISE_RECOMMENDATIONS;
} }

View File

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

View File

@@ -9,7 +9,7 @@ interface RawReport {
imageUrls: string[]; imageUrls: string[];
status: string; status: string;
result?: string; result?: string;
createdAt: string; uploadedAt: string;
interpretedAt?: string; interpretedAt?: string;
interpretedBy?: string; interpretedBy?: string;
} }
@@ -35,7 +35,7 @@ function mapReport(r: RawReport): Report {
userId: r.patientId, userId: r.patientId,
title: r.title, title: r.title,
imageUrls: r.imageUrls, imageUrls: r.imageUrls,
uploadAt: r.createdAt, uploadAt: r.uploadedAt,
status: r.status as Report['status'], status: r.status as Report['status'],
category: r.category, category: r.category,
result, result,

View File

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

View File

@@ -1,9 +0,0 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -12,5 +12,9 @@ export default defineConfig({
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',
port: 5173, port: 5173,
proxy: {
'/api': 'http://localhost:5000',
'/hubs': { target: 'http://localhost:5000', ws: true },
},
}, },
}) })

View File

@@ -6,12 +6,11 @@ echo ==========================================
echo HealthManager Dev Environment echo HealthManager Dev Environment
echo ========================================== echo ==========================================
set "REDIS=C:\Program Files\Redis\redis-server.exe"
set "PG_DATA=D:\APP\data\pgdata" set "PG_DATA=D:\APP\data\pgdata"
set "PG_BIN=D:\PostgreSQL\18\pgsql\bin" set "PG_BIN=D:\PostgreSQL\18\pgsql\bin"
set "MINIO_DATA=D:\APP\data\minio" set "MINIO_DATA=D:\APP\data\minio"
echo. echo.
echo [1/6] Starting PostgreSQL... echo [1/5] Starting PostgreSQL...
if exist "%PG_BIN%\pg_ctl.exe" ( if exist "%PG_BIN%\pg_ctl.exe" (
"%PG_BIN%\pg_ctl.exe" -D "%PG_DATA%" -l "%PG_DATA%\pg.log" start 2>nul "%PG_BIN%\pg_ctl.exe" -D "%PG_DATA%" -l "%PG_DATA%\pg.log" start 2>nul
if errorlevel 1 ( if errorlevel 1 (
@@ -24,17 +23,7 @@ if exist "%PG_BIN%\pg_ctl.exe" (
) )
echo. echo.
echo [2/6] Starting Redis... echo [2/5] Starting MinIO...
tasklist /fi "imagename eq redis-server.exe" | find /i "redis-server.exe" >nul
if errorlevel 1 (
start "Redis" /MIN "%REDIS%" "%ProgramFiles%\Redis\redis.windows.conf"
echo Redis started
) else (
echo Redis is already running
)
echo.
echo [3/6] Starting MinIO...
tasklist /fi "imagename eq minio.exe" | find /i "minio.exe" >nul tasklist /fi "imagename eq minio.exe" | find /i "minio.exe" >nul
if errorlevel 1 ( if errorlevel 1 (
if not exist "%MINIO_DATA%" mkdir "%MINIO_DATA%" if not exist "%MINIO_DATA%" mkdir "%MINIO_DATA%"
@@ -45,7 +34,7 @@ if errorlevel 1 (
) )
echo. echo.
echo [4/6] Starting Backend API... echo [3/5] Starting Backend API...
cd /d "%~dp0backend" cd /d "%~dp0backend"
start "HealthManager API" dotnet run --project src\HealthManager.WebApi --urls "http://localhost:5000" --environment Development start "HealthManager API" dotnet run --project src\HealthManager.WebApi --urls "http://localhost:5000" --environment Development
echo Backend API starting (http://localhost:5000) echo Backend API starting (http://localhost:5000)
@@ -54,12 +43,12 @@ echo Waiting 15s for backend to boot...
timeout /t 15 /nobreak >nul timeout /t 15 /nobreak >nul
echo. echo.
echo [5/6] Starting Patient Frontend... echo [4/5] Starting Patient Frontend...
start "Patient Frontend" cmd.exe /c "cd /d %~dp0frontend-patient && npm run dev" start "Patient Frontend" cmd.exe /c "cd /d %~dp0frontend-patient && npm run dev"
echo Patient Frontend starting on http://localhost:5173 echo Patient Frontend starting on http://localhost:5173
echo. echo.
echo [6/6] Starting Doctor Frontend... echo [5/5] Starting Doctor Frontend...
start "Doctor Frontend" cmd.exe /c "cd /d %~dp0frontend-doctor && npm run dev" start "Doctor Frontend" cmd.exe /c "cd /d %~dp0frontend-doctor && npm run dev"
echo Doctor Frontend starting on http://localhost:5174 echo Doctor Frontend starting on http://localhost:5174
@@ -73,10 +62,9 @@ echo Backend API: http://localhost:5000
echo Swagger: http://localhost:5000/swagger echo Swagger: http://localhost:5000/swagger
echo MinIO: http://localhost:9001 echo MinIO: http://localhost:9001
echo PostgreSQL: localhost:5432 echo PostgreSQL: localhost:5432
echo Redis: localhost:6379
echo ========================================== echo ==========================================
echo. echo.
echo All 6 services started. Close the 3 new echo All 5 services started. Close the 3 new
echo windows to stop the apps. echo windows to stop the apps.
echo. echo.
pause pause

View File

@@ -234,7 +234,6 @@ GET /api/health-records/export?format=pdf
需要安装的东西和本地一样: 需要安装的东西和本地一样:
- PostgreSQL 18 - PostgreSQL 18
- Redis
- MinIO - MinIO
- .NET 10 Runtime - .NET 10 Runtime
- Nginx作为反代和静态文件服务 - Nginx作为反代和静态文件服务
@@ -417,8 +416,8 @@ POST /api/auth/send-sms → 调短信平台API → 用户手机收到验证码
#### 8.2 健康检查 #### 8.2 健康检查
``` ```
GET /health → 检查数据库连接、Redis连接、MinIO连接 GET /health → 检查数据库连接、MinIO连接
返回 { status: "healthy", db: "ok", redis: "ok", minio: "ok" } 返回 { status: "healthy", db: "ok", minio: "ok" }
``` ```
自动化监控:每 30 秒检查一次,挂了自动发短信/邮件报警。 自动化监控:每 30 秒检查一次,挂了自动发短信/邮件报警。

View File

@@ -34,16 +34,13 @@ D:\APP\start-dev.bat
HealthManager 开发环境启动 HealthManager 开发环境启动
========================================== ==========================================
[1/4] 启动 PostgreSQL... [1/3] 启动 PostgreSQL...
PostgreSQL 已启动 PostgreSQL 已启动
[2/4] 启动 Redis... [2/3] 启动 MinIO...
Redis 已启动
[3/4] 启动 MinIO...
MinIO 已启动 MinIO 已启动
[4/4] 启动后端 API... [3/3] 启动后端 API...
后端 API 启动中 (http://localhost:5000) 后端 API 启动中 (http://localhost:5000)
Swagger: http://localhost:5000/swagger Swagger: http://localhost:5000/swagger
@@ -54,7 +51,6 @@ D:\APP\start-dev.bat
Swagger: http://localhost:5000/swagger Swagger: http://localhost:5000/swagger
MinIO: http://localhost:9001 MinIO: http://localhost:9001
PostgreSQL: localhost:5432 PostgreSQL: localhost:5432
Redis: localhost:6379
=========================================== ===========================================
``` ```
@@ -105,9 +101,9 @@ VITE v8.x.x ready in xxx ms
### 1.2 关闭系统 ### 1.2 关闭系统
1. 关闭三个命令行窗口(后端、患者前端、医生前端) 1. 关闭三个命令行窗口(后端、患者前端、医生前端)
2. PostgreSQL、Redis、MinIO 会继续在后台运行。如果想关闭它们: 2. PostgreSQL、MinIO 会继续在后台运行。如果想关闭它们:
- 打开任务管理器Ctrl+Shift+Esc - 打开任务管理器Ctrl+Shift+Esc
- 找到 `postgres.exe``redis-server.exe``minio.exe` 进程 - 找到 `postgres.exe``minio.exe` 进程
- 分别结束任务 - 分别结束任务
--- ---
@@ -861,8 +857,6 @@ VITE v8.x.x ready in xxx ms
| 医生前端 | http://localhost:5174 | | 医生前端 | http://localhost:5174 |
| MinIO 控制台 | http://localhost:9001 | | MinIO 控制台 | http://localhost:9001 |
| PostgreSQL | localhost:5432 | | PostgreSQL | localhost:5432 |
| Redis | localhost:6379 |
### 5.3 常见问题 ### 5.3 常见问题
**Q登录失败怎么办** **Q登录失败怎么办**