Compare commits
16 Commits
204bc19ce5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f412a474cd | ||
|
|
d5f167167a | ||
|
|
39ab6062b5 | ||
|
|
db443b258e | ||
|
|
ede4a8d29e | ||
|
|
d6a432aec4 | ||
|
|
722ee76d93 | ||
|
|
94da24572e | ||
|
|
9d384dc6fb | ||
|
|
90615a6cb3 | ||
|
|
8caa374699 | ||
|
|
4a525124c5 | ||
|
|
a9d70aa130 | ||
|
|
4c85cd50be | ||
|
|
0df75c35e9 | ||
|
|
bec65959a7 |
@@ -2,10 +2,18 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "健康管家 Web Demo",
|
"name": "健康管家-患者端",
|
||||||
"runtimeExecutable": "cmd.exe",
|
"runtimeExecutable": "cmd.exe",
|
||||||
"runtimeArgs": ["/c", "D:\\nodejs\\npm.cmd", "run", "dev"],
|
"runtimeArgs": ["/c", "D:\\nodejs\\npm.cmd", "run", "dev"],
|
||||||
"port": 5175
|
"cwd": "D:\\APP\\frontend-patient",
|
||||||
|
"port": 5173
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "健康管家-医生端",
|
||||||
|
"runtimeExecutable": "cmd.exe",
|
||||||
|
"runtimeArgs": ["/c", "D:\\nodejs\\npm.cmd", "run", "dev"],
|
||||||
|
"cwd": "D:\\APP\\frontend-doctor",
|
||||||
|
"port": 5174
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,7 +7,7 @@ bin/
|
|||||||
obj/
|
obj/
|
||||||
|
|
||||||
# Data (large files)
|
# Data (large files)
|
||||||
data/
|
/data/
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,12 @@ public class ConsultationService(AppDbContext db)
|
|||||||
|
|
||||||
public async Task<Consultation> StartAsync(Guid patientId, Guid doctorId, string subject)
|
public async Task<Consultation> StartAsync(Guid patientId, Guid doctorId, string subject)
|
||||||
{
|
{
|
||||||
|
// Reuse existing active consultation between this patient and doctor
|
||||||
|
var existing = await db.Consultations
|
||||||
|
.FirstOrDefaultAsync(c => c.PatientId == patientId && c.DoctorId == doctorId && c.Status == "active");
|
||||||
|
if (existing != null)
|
||||||
|
return existing;
|
||||||
|
|
||||||
var consultation = new Consultation
|
var consultation = new Consultation
|
||||||
{
|
{
|
||||||
PatientId = patientId,
|
PatientId = patientId,
|
||||||
@@ -35,7 +41,19 @@ public class ConsultationService(AppDbContext db)
|
|||||||
Subject = subject,
|
Subject = subject,
|
||||||
};
|
};
|
||||||
db.Consultations.Add(consultation);
|
db.Consultations.Add(consultation);
|
||||||
|
try
|
||||||
|
{
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
// Race condition: another request created one between our check and save
|
||||||
|
// The unique index on (PatientId, DoctorId) where Status='active' caught it
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
var retry = await db.Consultations
|
||||||
|
.FirstOrDefaultAsync(c => c.PatientId == patientId && c.DoctorId == doctorId && c.Status == "active");
|
||||||
|
return retry!;
|
||||||
|
}
|
||||||
return consultation;
|
return consultation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,20 @@ public class FollowUpService(AppDbContext db)
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
public async Task<List<FollowUp>> GetDoctorFollowUpsAsync(Guid doctorId)
|
public async Task<List<FollowUp>> GetDoctorFollowUpsAsync(Guid doctorId)
|
||||||
|
=> await db.FollowUps
|
||||||
|
.Include(f => f.Patient)
|
||||||
|
.Where(f => f.DoctorId == doctorId || f.DoctorId == null)
|
||||||
|
.OrderBy(f => f.ScheduledAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<List<FollowUp>> GetDoctorInitiatedFollowUpsAsync(Guid doctorId)
|
||||||
=> await db.FollowUps
|
=> await db.FollowUps
|
||||||
.Include(f => f.Patient)
|
.Include(f => f.Patient)
|
||||||
.Where(f => f.DoctorId == doctorId)
|
.Where(f => f.DoctorId == doctorId)
|
||||||
.OrderBy(f => f.ScheduledAt)
|
.OrderBy(f => f.ScheduledAt)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
public async Task<FollowUp> AddAsync(Guid patientId, string title, string? description, DateTime scheduledAt, bool reminderEnabled, Guid? doctorId = null)
|
public async Task<FollowUp> AddAsync(Guid patientId, string title, string? description, DateTime scheduledAt, bool reminderEnabled, Guid? doctorId = null, string? notes = null)
|
||||||
{
|
{
|
||||||
var followUp = new FollowUp
|
var followUp = new FollowUp
|
||||||
{
|
{
|
||||||
@@ -30,6 +37,7 @@ public class FollowUpService(AppDbContext db)
|
|||||||
Description = description,
|
Description = description,
|
||||||
ScheduledAt = DateTime.SpecifyKind(scheduledAt, DateTimeKind.Utc),
|
ScheduledAt = DateTime.SpecifyKind(scheduledAt, DateTimeKind.Utc),
|
||||||
ReminderEnabled = reminderEnabled,
|
ReminderEnabled = reminderEnabled,
|
||||||
|
Notes = notes,
|
||||||
};
|
};
|
||||||
db.FollowUps.Add(followUp);
|
db.FollowUps.Add(followUp);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
@@ -53,10 +61,18 @@ public class FollowUpService(AppDbContext db)
|
|||||||
if (scheduledAt.HasValue) followUp.ScheduledAt = DateTime.SpecifyKind(scheduledAt.Value, DateTimeKind.Utc);
|
if (scheduledAt.HasValue) followUp.ScheduledAt = DateTime.SpecifyKind(scheduledAt.Value, DateTimeKind.Utc);
|
||||||
if (status != null) followUp.Status = status;
|
if (status != null) followUp.Status = status;
|
||||||
if (notes != null) followUp.Notes = notes;
|
if (notes != null) followUp.Notes = notes;
|
||||||
followUp.DoctorId = doctorId;
|
|
||||||
followUp.UpdatedAt = DateTime.UtcNow;
|
followUp.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return followUp;
|
return followUp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteAsync(Guid id)
|
||||||
|
{
|
||||||
|
var followUp = await db.FollowUps.FindAsync(id);
|
||||||
|
if (followUp == null) return false;
|
||||||
|
db.FollowUps.Remove(followUp);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" };
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using HealthManager.Domain.Entities;
|
using HealthManager.Domain.Entities;
|
||||||
using HealthManager.Infrastructure.Data;
|
using HealthManager.Infrastructure.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Notification = HealthManager.Domain.Entities.Notification;
|
||||||
|
|
||||||
namespace HealthManager.Application.Services;
|
namespace HealthManager.Application.Services;
|
||||||
|
|
||||||
@@ -78,6 +79,16 @@ public class ReportService(AppDbContext db)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notify patient
|
||||||
|
db.Notifications.Add(new Notification
|
||||||
|
{
|
||||||
|
UserId = report.PatientId,
|
||||||
|
Type = "report",
|
||||||
|
Title = "报告已解读",
|
||||||
|
Content = $"您的报告「{report.Title}」已有解读结果",
|
||||||
|
RelatedId = reportId,
|
||||||
|
});
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return report;
|
return report;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/src/HealthManager.Domain/Entities/CacheEntry.cs
Normal file
12
backend/src/HealthManager.Domain/Entities/CacheEntry.cs
Normal 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;
|
||||||
|
}
|
||||||
10
backend/src/HealthManager.Domain/Entities/RateLimitEntry.cs
Normal file
10
backend/src/HealthManager.Domain/Entities/RateLimitEntry.cs
Normal 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; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
193
backend/src/HealthManager.Infrastructure/Data/AppDbContext.cs
Normal file
193
backend/src/HealthManager.Infrastructure/Data/AppDbContext.cs
Normal 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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
41
backend/src/HealthManager.Infrastructure/Data/DataSeeder.cs
Normal file
41
backend/src/HealthManager.Infrastructure/Data/DataSeeder.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using HealthManager.Application.DTOs.Auth;
|
using HealthManager.Application.DTOs.Auth;
|
||||||
using HealthManager.Domain.Interfaces;
|
using HealthManager.Domain.Interfaces;
|
||||||
@@ -5,6 +6,7 @@ using HealthManager.Application.Services;
|
|||||||
using HealthManager.Domain.Entities;
|
using HealthManager.Domain.Entities;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace HealthManager.WebApi.Controllers;
|
namespace HealthManager.WebApi.Controllers;
|
||||||
|
|
||||||
@@ -12,23 +14,57 @@ namespace HealthManager.WebApi.Controllers;
|
|||||||
[Route("api/auth")]
|
[Route("api/auth")]
|
||||||
public class AuthController(
|
public class AuthController(
|
||||||
AuthService authService,
|
AuthService authService,
|
||||||
IJwtProvider jwtProvider) : ControllerBase
|
IJwtProvider jwtProvider,
|
||||||
|
VerificationService verificationService,
|
||||||
|
RateLimitService rateLimit,
|
||||||
|
TokenBlacklistService tokenBlacklist) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpPost("send-sms")]
|
[HttpPost("send-sms")]
|
||||||
public IActionResult SendSms([FromBody] SendSmsRequest request)
|
public async Task<IActionResult> SendSms([FromBody] SendSmsRequest request)
|
||||||
{
|
{
|
||||||
// Demo: always succeed
|
if (!await rateLimit.CheckAsync($"sms:{request.Phone}", 1, 60))
|
||||||
|
return StatusCode(429, new { message = "发送过于频繁,请60秒后重试" });
|
||||||
|
|
||||||
|
var code = await verificationService.GenerateAsync(request.Phone, "login");
|
||||||
|
await rateLimit.IncrementAsync($"sms:{request.Phone}", 60);
|
||||||
|
|
||||||
|
// Demo: log code to console since no real SMS gateway
|
||||||
|
Console.WriteLine($"[SMS] Phone: {request.Phone}, Code: {code}");
|
||||||
return Ok(new { message = "验证码已发送" });
|
return Ok(new { message = "验证码已发送" });
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("login")]
|
[HttpPost("login")]
|
||||||
public async Task<IActionResult> Login([FromBody] LoginRequest request)
|
public async Task<IActionResult> Login([FromBody] LoginRequest request)
|
||||||
{
|
{
|
||||||
|
// Demo: skip SMS verification, accept any code
|
||||||
var user = await authService.GetUserByPhoneAsync(request.Phone);
|
var user = await authService.GetUserByPhoneAsync(request.Phone);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
return Unauthorized(new { message = "用户不存在" });
|
{
|
||||||
|
var db = HttpContext.RequestServices.GetRequiredService<Infrastructure.Data.AppDbContext>();
|
||||||
|
|
||||||
|
var deleted = await db.Users.IgnoreQueryFilters()
|
||||||
|
.FirstOrDefaultAsync(u => u.Phone == request.Phone && u.IsDeleted);
|
||||||
|
if (deleted != null)
|
||||||
|
{
|
||||||
|
deleted.IsDeleted = false;
|
||||||
|
deleted.DeletedAt = null;
|
||||||
|
deleted.UpdatedAt = DateTime.UtcNow;
|
||||||
|
user = deleted;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
user = new User
|
||||||
|
{
|
||||||
|
Phone = request.Phone,
|
||||||
|
Name = "用户" + request.Phone[^4..],
|
||||||
|
Role = "patient",
|
||||||
|
PasswordHash = AuthService.HashPassword("demo123"),
|
||||||
|
};
|
||||||
|
db.Users.Add(user);
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
// Demo: accept any SMS code
|
|
||||||
var accessToken = jwtProvider.GenerateAccessToken(user.Id, user.Name, user.Role);
|
var accessToken = jwtProvider.GenerateAccessToken(user.Id, user.Name, user.Role);
|
||||||
var refreshToken = jwtProvider.GenerateRefreshToken();
|
var refreshToken = jwtProvider.GenerateRefreshToken();
|
||||||
await authService.SaveRefreshTokenAsync(user.Id, refreshToken, DateTime.UtcNow.AddDays(7));
|
await authService.SaveRefreshTokenAsync(user.Id, refreshToken, DateTime.UtcNow.AddDays(7));
|
||||||
@@ -51,7 +87,6 @@ public class AuthController(
|
|||||||
PasswordHash = AuthService.HashPassword("demo123"),
|
PasswordHash = AuthService.HashPassword("demo123"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Access DbContext via DI
|
|
||||||
var db = HttpContext.RequestServices.GetRequiredService<Infrastructure.Data.AppDbContext>();
|
var db = HttpContext.RequestServices.GetRequiredService<Infrastructure.Data.AppDbContext>();
|
||||||
db.Users.Add(user);
|
db.Users.Add(user);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
@@ -63,6 +98,24 @@ public class AuthController(
|
|||||||
return Ok(new AuthResponse(user.Id, user.Name, user.Role, accessToken, refreshToken));
|
return Ok(new AuthResponse(user.Id, user.Name, user.Role, accessToken, refreshToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("logout")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> Logout()
|
||||||
|
{
|
||||||
|
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
var jti = User.FindFirstValue(JwtRegisteredClaimNames.Jti);
|
||||||
|
var expClaim = User.FindFirstValue(JwtRegisteredClaimNames.Exp);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(jti) && !string.IsNullOrEmpty(expClaim))
|
||||||
|
{
|
||||||
|
var exp = DateTimeOffset.FromUnixTimeSeconds(long.Parse(expClaim)).UtcDateTime;
|
||||||
|
await tokenBlacklist.AddAsync(jti, userId, exp);
|
||||||
|
}
|
||||||
|
|
||||||
|
await authService.RevokeRefreshTokenAsync(userId);
|
||||||
|
return Ok(new { message = "已登出" });
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("refresh")]
|
[HttpPost("refresh")]
|
||||||
public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequest request)
|
public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequest request)
|
||||||
{
|
{
|
||||||
@@ -110,6 +163,12 @@ public class AuthController(
|
|||||||
if (request.HeightCm.HasValue) user.HeightCm = request.HeightCm;
|
if (request.HeightCm.HasValue) user.HeightCm = request.HeightCm;
|
||||||
if (request.WeightKg.HasValue) user.WeightKg = request.WeightKg;
|
if (request.WeightKg.HasValue) user.WeightKg = request.WeightKg;
|
||||||
if (request.MedicalHistory != null) user.MedicalHistory = request.MedicalHistory;
|
if (request.MedicalHistory != null) user.MedicalHistory = request.MedicalHistory;
|
||||||
|
if (request.StentDate.HasValue) user.StentDate = request.StentDate;
|
||||||
|
if (request.StentType != null) user.StentType = request.StentType;
|
||||||
|
if (request.Department != null) user.Department = request.Department;
|
||||||
|
if (request.Title != null) user.Title = request.Title;
|
||||||
|
if (request.Introduction != null) user.Introduction = request.Introduction;
|
||||||
|
if (request.Specialty != null) user.Specialty = request.Specialty;
|
||||||
user.UpdatedAt = DateTime.UtcNow;
|
user.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|||||||
@@ -14,11 +14,22 @@ public class FollowUpController(FollowUpService followUpService) : ControllerBas
|
|||||||
private string Role => User.FindFirstValue(ClaimTypes.Role)!;
|
private string Role => User.FindFirstValue(ClaimTypes.Role)!;
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetFollowUps()
|
public async Task<IActionResult> GetFollowUps([FromQuery] string? type)
|
||||||
{
|
{
|
||||||
var followUps = Role == "doctor"
|
List<HealthManager.Domain.Entities.FollowUp> followUps;
|
||||||
? await followUpService.GetDoctorFollowUpsAsync(UserId)
|
|
||||||
: await followUpService.GetPatientFollowUpsAsync(UserId);
|
if (Role == "doctor" && type == "followup")
|
||||||
|
followUps = await followUpService.GetDoctorInitiatedFollowUpsAsync(UserId);
|
||||||
|
else if (Role == "doctor")
|
||||||
|
followUps = await followUpService.GetDoctorFollowUpsAsync(UserId);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
followUps = await followUpService.GetPatientFollowUpsAsync(UserId);
|
||||||
|
if (type == "followup")
|
||||||
|
followUps = followUps.Where(f => f.DoctorId != null).ToList();
|
||||||
|
else if (type == "recheck")
|
||||||
|
followUps = followUps.Where(f => f.DoctorId == null).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
return Ok(followUps.Select(f => new
|
return Ok(followUps.Select(f => new
|
||||||
{
|
{
|
||||||
@@ -45,11 +56,29 @@ public class FollowUpController(FollowUpService followUpService) : ControllerBas
|
|||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> AddFollowUp([FromBody] FollowUpCreateRequest request)
|
public async Task<IActionResult> AddFollowUp([FromBody] FollowUpCreateRequest request)
|
||||||
{
|
{
|
||||||
var followUp = await followUpService.AddAsync(UserId, request.Title, request.Description,
|
var patientId = UserId;
|
||||||
request.ScheduledAt, request.ReminderEnabled);
|
Guid? doctorId = null;
|
||||||
|
|
||||||
|
if (Role == "doctor" && request.PatientId.HasValue)
|
||||||
|
{
|
||||||
|
patientId = request.PatientId.Value;
|
||||||
|
doctorId = UserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
var followUp = await followUpService.AddAsync(patientId, request.Title, request.Description,
|
||||||
|
request.ScheduledAt, request.ReminderEnabled, doctorId, request.Notes);
|
||||||
return Ok(new { followUp.Id, followUp.Title, followUp.Status });
|
return Ok(new { followUp.Id, followUp.Title, followUp.Status });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}")]
|
||||||
|
[Authorize(Roles = "doctor")]
|
||||||
|
public async Task<IActionResult> DeleteFollowUp(Guid id)
|
||||||
|
{
|
||||||
|
var ok = await followUpService.DeleteAsync(id);
|
||||||
|
if (!ok) return NotFound(new { message = "复查不存在" });
|
||||||
|
return Ok(new { message = "删除成功" });
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
[Authorize(Roles = "doctor")]
|
[Authorize(Roles = "doctor")]
|
||||||
public async Task<IActionResult> UpdateFollowUp(Guid id, [FromBody] FollowUpUpdateRequest request)
|
public async Task<IActionResult> UpdateFollowUp(Guid id, [FromBody] FollowUpUpdateRequest request)
|
||||||
@@ -61,7 +90,21 @@ public class FollowUpController(FollowUpService followUpService) : ControllerBas
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public record FollowUpCreateRequest(string Title, string? Description, DateTime ScheduledAt, bool ReminderEnabled = true);
|
public class FollowUpCreateRequest
|
||||||
|
{
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public DateTime ScheduledAt { get; set; }
|
||||||
|
public bool ReminderEnabled { get; set; } = true;
|
||||||
|
public Guid? PatientId { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public record FollowUpUpdateRequest(
|
public class FollowUpUpdateRequest
|
||||||
string? Title, string? Description, DateTime? ScheduledAt, string? Status, string? Notes);
|
{
|
||||||
|
public string? Title { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public DateTime? ScheduledAt { get; set; }
|
||||||
|
public string? Status { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using HealthManager.Domain.Interfaces;
|
using HealthManager.Domain.Interfaces;
|
||||||
using HealthManager.Application.Services;
|
using HealthManager.Application.Services;
|
||||||
using HealthManager.Infrastructure.Data;
|
using HealthManager.Infrastructure.Data;
|
||||||
using HealthManager.Infrastructure.Services;
|
using HealthManager.Infrastructure.Services;
|
||||||
using HealthManager.WebApi.Hubs;
|
using HealthManager.WebApi.Hubs;
|
||||||
|
using HealthManager.WebApi.Services;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
@@ -39,6 +41,14 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|||||||
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
|
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
|
||||||
context.Token = accessToken;
|
context.Token = accessToken;
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
OnTokenValidated = async context =>
|
||||||
|
{
|
||||||
|
var blacklist = context.HttpContext.RequestServices
|
||||||
|
.GetRequiredService<TokenBlacklistService>();
|
||||||
|
var jti = context.Principal!.FindFirst(JwtRegisteredClaimNames.Jti)?.Value;
|
||||||
|
if (!string.IsNullOrEmpty(jti) && await blacklist.IsBlacklistedAsync(jti))
|
||||||
|
context.Fail("token已注销");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -55,6 +65,13 @@ builder.Services.AddScoped<FollowUpService>();
|
|||||||
builder.Services.AddScoped<PatientService>();
|
builder.Services.AddScoped<PatientService>();
|
||||||
builder.Services.AddScoped<NotificationService>();
|
builder.Services.AddScoped<NotificationService>();
|
||||||
|
|
||||||
|
// PG-based replacements for Redis
|
||||||
|
builder.Services.AddScoped<VerificationService>();
|
||||||
|
builder.Services.AddScoped<RateLimitService>();
|
||||||
|
builder.Services.AddScoped<TokenBlacklistService>();
|
||||||
|
builder.Services.AddScoped<CacheService>();
|
||||||
|
builder.Services.AddHostedService<CleanupBackgroundService>();
|
||||||
|
|
||||||
// SignalR
|
// SignalR
|
||||||
builder.Services.AddSignalR();
|
builder.Services.AddSignalR();
|
||||||
|
|
||||||
@@ -74,7 +91,11 @@ builder.Services.AddCors(options =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers()
|
||||||
|
.AddJsonOptions(options =>
|
||||||
|
{
|
||||||
|
options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
|
||||||
|
});
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
@@ -95,6 +116,7 @@ using (var scope = app.Services.CreateScope())
|
|||||||
{
|
{
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
await db.Database.EnsureCreatedAsync();
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
await MigrationHelper.EnsureNewTablesAsync(db);
|
||||||
await DataSeeder.SeedAsync(db);
|
await DataSeeder.SeedAsync(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,9 +14,6 @@
|
|||||||
"Issuer": "HealthManager",
|
"Issuer": "HealthManager",
|
||||||
"Audience": "HealthManagerApp"
|
"Audience": "HealthManagerApp"
|
||||||
},
|
},
|
||||||
"Redis": {
|
|
||||||
"Connection": "localhost:6379"
|
|
||||||
},
|
|
||||||
"MinIO": {
|
"MinIO": {
|
||||||
"Endpoint": "localhost:9000",
|
"Endpoint": "localhost:9000",
|
||||||
"AccessKey": "minioadmin",
|
"AccessKey": "minioadmin",
|
||||||
|
|||||||
@@ -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` | 启动配置:端口 5000,Development 环境,自动开 Swagger |
|
| `Properties/launchSettings.json` | 启动配置:端口 5000,Development 环境,自动开 Swagger |
|
||||||
|
|
||||||
|
|||||||
176
frontend-doctor/package-lock.json
generated
176
frontend-doctor/package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "frontend-doctor",
|
"name": "frontend-doctor",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@microsoft/signalr": "^10.0.0",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.6",
|
"echarts-for-react": "^3.0.6",
|
||||||
@@ -574,6 +575,19 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@microsoft/signalr": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"abort-controller": "^3.0.0",
|
||||||
|
"eventsource": "^2.0.2",
|
||||||
|
"fetch-cookie": "^2.0.3",
|
||||||
|
"node-fetch": "^2.6.7",
|
||||||
|
"ws": "^7.5.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||||
@@ -1224,6 +1238,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/abort-controller": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"event-target-shim": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
@@ -1674,6 +1700,24 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/event-target-shim": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/eventsource": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -1712,6 +1756,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fetch-cookie": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==",
|
||||||
|
"license": "Unlicense",
|
||||||
|
"dependencies": {
|
||||||
|
"set-cookie-parser": "^2.4.8",
|
||||||
|
"tough-cookie": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
@@ -2355,6 +2409,26 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.44",
|
"version": "2.0.44",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz",
|
||||||
@@ -2491,16 +2565,33 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/psl": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"punycode": "^2.3.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/lupomontero"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/querystringify": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.2.6",
|
"version": "19.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||||
@@ -2560,6 +2651,12 @@
|
|||||||
"react-dom": ">=18"
|
"react-dom": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/requires-port": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
|
||||||
@@ -2672,6 +2769,27 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tough-cookie": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"psl": "^1.1.33",
|
||||||
|
"punycode": "^2.1.1",
|
||||||
|
"universalify": "^0.2.0",
|
||||||
|
"url-parse": "^1.5.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||||
@@ -2749,6 +2867,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/universalify": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
@@ -2790,6 +2917,16 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/url-parse": {
|
||||||
|
"version": "1.5.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
|
||||||
|
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"querystringify": "^2.1.1",
|
||||||
|
"requires-port": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.0.13",
|
"version": "8.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
|
||||||
@@ -2868,6 +3005,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "~0.0.3",
|
||||||
|
"webidl-conversions": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -2894,6 +3047,27 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "7.5.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
|
||||||
|
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": "^5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@microsoft/signalr": "^10.0.0",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.6",
|
"echarts-for-react": "^3.0.6",
|
||||||
|
|||||||
@@ -1,4 +1,20 @@
|
|||||||
|
@import './variables.css';
|
||||||
|
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #333; }
|
|
||||||
a { color: inherit; }
|
body {
|
||||||
button { cursor: pointer; }
|
font-family: var(--font-family);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background: var(--color-bg);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
button { cursor: pointer; font-family: inherit; }
|
||||||
|
input, select, textarea { font-family: inherit; }
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|||||||
51
frontend-doctor/src/assets/styles/variables.css
Normal file
51
frontend-doctor/src/assets/styles/variables.css
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
:root {
|
||||||
|
--color-primary: #4F6EF7;
|
||||||
|
--color-primary-hover: #3D56D6;
|
||||||
|
--color-primary-bg: #EDF0FD;
|
||||||
|
--color-primary-gradient: linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%);
|
||||||
|
|
||||||
|
--color-danger: #EF4444;
|
||||||
|
--color-danger-bg: #FEE9E9;
|
||||||
|
--color-success: #20C997;
|
||||||
|
--color-success-bg: #E6F9F2;
|
||||||
|
--color-warning: #F59E0B;
|
||||||
|
--color-warning-bg: #FFF8E6;
|
||||||
|
--color-info: #339AF0;
|
||||||
|
--color-info-bg: #EFF6FF;
|
||||||
|
--color-purple: #845EF7;
|
||||||
|
--color-purple-bg: #F3E8FF;
|
||||||
|
--color-pink: #F06595;
|
||||||
|
--color-pink-bg: #FFF0F5;
|
||||||
|
|
||||||
|
--color-white: #FFFFFF;
|
||||||
|
--color-bg: #F5F7FB;
|
||||||
|
--color-bg-secondary: #EDF0F7;
|
||||||
|
--color-text-primary: #1A1D28;
|
||||||
|
--color-text-secondary: #5A6072;
|
||||||
|
--color-text-tertiary: #9BA0B4;
|
||||||
|
--color-text-inverse: #FFFFFF;
|
||||||
|
--color-border: #E1E5ED;
|
||||||
|
--color-divider: #EEF0F5;
|
||||||
|
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||||
|
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.06);
|
||||||
|
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.08);
|
||||||
|
--shadow-primary: 0 4px 16px rgba(79, 110, 247, 0.25);
|
||||||
|
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--radius-md: 12px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
--radius-xl: 20px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
--font-size-xs: 11px;
|
||||||
|
--font-size-sm: 13px;
|
||||||
|
--font-size-base: 14px;
|
||||||
|
--font-size-md: 15px;
|
||||||
|
--font-size-lg: 17px;
|
||||||
|
--font-size-xl: 20px;
|
||||||
|
--font-size-2xl: 24px;
|
||||||
|
--font-size-3xl: 30px;
|
||||||
|
|
||||||
|
--font-family: 'PingFang SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||||||
|
}
|
||||||
63
frontend-doctor/src/components/charts/MultiLineChart.tsx
Normal file
63
frontend-doctor/src/components/charts/MultiLineChart.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
@@ -1,17 +1,76 @@
|
|||||||
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
|
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../../stores/auth.store';
|
import { useAuthStore } from '../../stores/auth.store';
|
||||||
|
|
||||||
|
const SIDEBAR_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
dashboard: (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||||
|
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||||
|
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||||
|
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
patients: (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
consultations: (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||||
|
<line x1="9" y1="10" x2="15" y2="10" />
|
||||||
|
<line x1="12" y1="7" x2="12" y2="13" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
reports: (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
followups: (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||||
|
<line x1="16" y1="2" x2="16" y2="6" />
|
||||||
|
<line x1="8" y1="2" x2="8" y2="6" />
|
||||||
|
<line x1="3" y1="10" x2="21" y2="10" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
visits: (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
|
||||||
|
<rect x="8" y="2" width="8" height="4" rx="1" ry="1" />
|
||||||
|
<path d="M9 14l2 2 4-4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/dashboard', label: '工作台', icon: '📊' },
|
{ to: '/dashboard', label: '工作台', ikey: 'dashboard' },
|
||||||
{ to: '/patients', label: '患者管理', icon: '👥' },
|
{ to: '/patients', label: '患者管理', ikey: 'patients' },
|
||||||
{ to: '/consultations', label: '在线问诊', icon: '💬' },
|
{ to: '/consultations', label: '在线问诊', ikey: 'consultations' },
|
||||||
{ to: '/reports', label: '报告审核', icon: '📋' },
|
{ to: '/reports', label: '报告审核', ikey: 'reports' },
|
||||||
{ to: '/follow-ups', label: '随访管理', icon: '📅' },
|
{ to: '/follow-ups', label: '复查管理', ikey: 'followups' },
|
||||||
|
{ to: '/visits', label: '随访管理', ikey: 'visits' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const sidebarBg = '#0F1D3D';
|
const sidebarStyles = {
|
||||||
const accentColor = '#4D8FFF';
|
bg: '#FFFFFF',
|
||||||
const textMuted = '#8E9DB5';
|
cardBg: 'linear-gradient(145deg, #4F6EF7 0%, #6988FF 100%)',
|
||||||
|
accentColor: '#4F6EF7',
|
||||||
|
textMuted: '#9BA0B4',
|
||||||
|
textPrimary: '#1A1D28',
|
||||||
|
borderColor: '#EEF0F5',
|
||||||
|
hoverBg: '#F5F7FB',
|
||||||
|
activeBg: '#EDF0FD',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { accentColor, textMuted, textPrimary } = sidebarStyles;
|
||||||
|
|
||||||
export function DoctorLayout() {
|
export function DoctorLayout() {
|
||||||
const { user, logout } = useAuthStore();
|
const { user, logout } = useAuthStore();
|
||||||
@@ -24,60 +83,85 @@ export function DoctorLayout() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', minHeight: '100vh', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif' }}>
|
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||||
{/* Sidebar */}
|
|
||||||
<aside style={{
|
<aside style={{
|
||||||
width: 220, background: sidebarBg, color: '#fff',
|
width: 224, background: sidebarStyles.bg, color: textPrimary,
|
||||||
display: 'flex', flexDirection: 'column', flexShrink: 0,
|
display: 'flex', flexDirection: 'column', flexShrink: 0,
|
||||||
|
boxShadow: '2px 0 24px rgba(0,0,0,0.04)',
|
||||||
|
borderRight: '1px solid #F0F2F5',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ padding: '24px 20px 20px', borderBottom: '1px solid rgba(255,255,255,0.08)' }}>
|
<div style={{ padding: '24px 20px 20px', borderBottom: `1px solid ${sidebarStyles.borderColor}` }}>
|
||||||
<h1 style={{ fontSize: 17, margin: 0, fontWeight: 600, color: '#fff', letterSpacing: 1 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
<span style={{ color: accentColor }}>♥</span> 健康管家
|
<div style={{
|
||||||
</h1>
|
width: 38, height: 38, borderRadius: 12,
|
||||||
<p style={{ fontSize: 12, margin: '6px 0 0', color: textMuted }}>医生工作台</p>
|
background: sidebarStyles.cardBg,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="#fff" stroke="none">
|
||||||
|
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 style={{ fontSize: 17, margin: 0, fontWeight: 700, color: textPrimary, letterSpacing: 0.5 }}>健康管家</h1>
|
||||||
|
<p style={{ fontSize: 11, margin: '4px 0 0', color: textMuted }}>医生工作台</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav style={{ flex: 1, padding: '12px 0' }}>
|
<nav style={{ flex: 1, padding: '8px 0' }}>
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
style={({ isActive }) => ({
|
style={({ isActive }) => ({
|
||||||
display: 'flex', alignItems: 'center', gap: 10,
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
padding: '11px 20px', margin: '2px 8px',
|
padding: '11px 16px', margin: '2px 10px',
|
||||||
borderRadius: 8,
|
borderRadius: 10,
|
||||||
color: isActive ? '#fff' : textMuted,
|
color: isActive ? accentColor : textMuted,
|
||||||
background: isActive ? accentColor : 'transparent',
|
background: isActive ? sidebarStyles.activeBg : 'transparent',
|
||||||
textDecoration: 'none', fontSize: 14,
|
textDecoration: 'none', fontSize: 14,
|
||||||
fontWeight: isActive ? 500 : 400,
|
fontWeight: isActive ? 600 : 400,
|
||||||
transition: 'all 0.15s',
|
transition: 'all 0.2s',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 16 }}>{item.icon}</span>
|
{SIDEBAR_ICONS[item.ikey]}
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div style={{ padding: '16px 20px', borderTop: '1px solid rgba(255,255,255,0.08)' }}>
|
<div style={{ padding: '16px 16px', borderTop: `1px solid ${sidebarStyles.borderColor}`, background: '#FAFBFD' }}>
|
||||||
<div style={{ fontSize: 13, color: '#fff', fontWeight: 500 }}>{user?.name}</div>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
|
||||||
<div style={{ fontSize: 11, color: textMuted, marginTop: 2 }}>{user?.department} · {user?.title}</div>
|
<div style={{
|
||||||
|
width: 38, height: 38, borderRadius: 12,
|
||||||
|
background: sidebarStyles.cardBg,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 15, fontWeight: 700, color: '#fff',
|
||||||
|
}}>
|
||||||
|
{user?.name?.charAt(0) || 'D'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, color: textPrimary, fontWeight: 600 }}>{user?.name}</div>
|
||||||
|
<div style={{ fontSize: 11, color: textMuted, marginTop: 1 }}>{user?.department} · {user?.title}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button onClick={handleLogout}
|
<button onClick={handleLogout}
|
||||||
style={{
|
style={{
|
||||||
marginTop: 10, padding: '6px 14px', fontSize: 12,
|
width: '100%', padding: '8px 0', fontSize: 12,
|
||||||
background: 'transparent', color: textMuted, border: '1px solid rgba(255,255,255,0.15)',
|
background: 'transparent', color: '#EF4444',
|
||||||
borderRadius: 6, cursor: 'pointer', transition: 'all 0.15s',
|
border: '1px solid #FEE9E9', borderRadius: 8,
|
||||||
|
cursor: 'pointer', transition: 'all 0.2s',
|
||||||
|
fontWeight: 500,
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; e.currentTarget.style.borderColor = '#fff'; }}
|
onMouseEnter={(e) => { e.currentTarget.style.background = '#FEF2F2'; }}
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.color = textMuted; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.15)'; }}>
|
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}>
|
||||||
退出登录
|
退出登录
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main content */}
|
|
||||||
<main style={{ flex: 1, background: '#F2F5FA', overflow: 'auto' }}>
|
<main style={{ flex: 1, background: '#F2F5FA', overflow: 'auto' }}>
|
||||||
<div key={location.pathname} style={{ animation: 'fadeIn 0.2s ease-out' }}>
|
<div key={location.pathname} style={{ animation: 'fadeIn 0.25s ease-out' }}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -4,108 +4,26 @@
|
|||||||
--bg: #fff;
|
--bg: #fff;
|
||||||
--border: #e5e4e7;
|
--border: #e5e4e7;
|
||||||
--code-bg: #f4f3ec;
|
--code-bg: #f4f3ec;
|
||||||
--accent: #aa3bff;
|
--accent: #4F6EF7;
|
||||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
--accent-bg: rgba(79, 110, 247, 0.1);
|
||||||
--accent-border: rgba(170, 59, 255, 0.5);
|
--accent-border: rgba(79, 110, 247, 0.5);
|
||||||
--social-bg: rgba(244, 243, 236, 0.5);
|
--social-bg: rgba(244, 243, 236, 0.5);
|
||||||
--shadow:
|
--shadow: rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
|
||||||
|
|
||||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||||
--mono: ui-monospace, Consolas, monospace;
|
--mono: ui-monospace, Consolas, monospace;
|
||||||
|
|
||||||
font: 18px/145% var(--sans);
|
|
||||||
letter-spacing: 0.18px;
|
|
||||||
color-scheme: light dark;
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--bg);
|
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--text: #9ca3af;
|
|
||||||
--text-h: #f3f4f6;
|
|
||||||
--bg: #16171d;
|
|
||||||
--border: #2e303a;
|
|
||||||
--code-bg: #1f2028;
|
|
||||||
--accent: #c084fc;
|
|
||||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
|
||||||
--accent-border: rgba(192, 132, 252, 0.5);
|
|
||||||
--social-bg: rgba(47, 48, 58, 0.5);
|
|
||||||
--shadow:
|
|
||||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#social .button-icon {
|
|
||||||
filter: invert(1) brightness(2);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
width: 1126px;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0;
|
||||||
text-align: center;
|
text-align: left;
|
||||||
border-inline: 1px solid var(--border);
|
min-height: 100vh;
|
||||||
min-height: 100svh;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body { margin: 0; }
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2 {
|
|
||||||
font-family: var(--heading);
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-h);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 56px;
|
|
||||||
letter-spacing: -1.68px;
|
|
||||||
margin: 32px 0;
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 36px;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
font-size: 24px;
|
|
||||||
line-height: 118%;
|
|
||||||
letter-spacing: -0.24px;
|
|
||||||
margin: 0 0 8px;
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
code,
|
|
||||||
.counter {
|
|
||||||
font-family: var(--mono);
|
|
||||||
display: inline-flex;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: var(--text-h);
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 135%;
|
|
||||||
padding: 4px 8px;
|
|
||||||
background: var(--code-bg);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
|||||||
import { RouterProvider } from 'react-router-dom';
|
import { RouterProvider } from 'react-router-dom';
|
||||||
import { router } from './router';
|
import { router } from './router';
|
||||||
import './assets/styles/global.css';
|
import './assets/styles/global.css';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
@@ -27,37 +27,48 @@ export function LoginPage() {
|
|||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex', justifyContent: 'center', alignItems: 'center',
|
display: 'flex', justifyContent: 'center', alignItems: 'center',
|
||||||
minHeight: '100vh', background: '#f0f2f5',
|
minHeight: '100vh', background: 'linear-gradient(135deg, #EBF0FD 0%, #F5F7FB 50%, #EDF0FD 100%)',
|
||||||
}}>
|
}}>
|
||||||
<form onSubmit={handleLogin} style={{
|
<form onSubmit={handleLogin} style={{
|
||||||
width: 400, padding: 40, background: '#fff', borderRadius: 8,
|
width: 400, padding: 40, background: '#fff', borderRadius: 20,
|
||||||
boxShadow: '0 2px 12px rgba(0,0,0,0.1)',
|
boxShadow: '0 8px 30px rgba(0,0,0,0.08)',
|
||||||
}}>
|
}}>
|
||||||
<h2 style={{ textAlign: 'center', marginBottom: 24 }}>医生登录</h2>
|
<div style={{ textAlign: 'center', marginBottom: 28 }}>
|
||||||
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="#4F6EF7" stroke="none" style={{ marginBottom: 12 }}>
|
||||||
|
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
||||||
|
</svg>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 22, fontWeight: 700, color: '#1A1D28' }}>医生登录</h2>
|
||||||
|
<p style={{ margin: '6px 0 0', fontSize: 13, color: '#9BA0B4' }}>健康管家 · 医生工作台</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <div style={{ color: '#f44336', marginBottom: 12, fontSize: 13 }}>{error}</div>}
|
{error && <div style={{ color: '#EF4444', marginBottom: 12, fontSize: 13, background: '#FEE9E9', padding: '8px 12px', borderRadius: 8 }}>{error}</div>}
|
||||||
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<label style={{ display: 'block', marginBottom: 4, fontSize: 13 }}>手机号</label>
|
<label style={{ display: 'block', marginBottom: 4, fontSize: 13, fontWeight: 500, color: '#5A6072' }}>手机号</label>
|
||||||
<input value={phone} onChange={(e) => setPhone(e.target.value)}
|
<input value={phone} onChange={(e) => setPhone(e.target.value)}
|
||||||
style={{ width: '100%', padding: '10px 12px', border: '1px solid #ddd', borderRadius: 4, fontSize: 14 }} />
|
style={{ width: '100%', padding: '12px 14px', border: '1.5px solid #E1E5ED', borderRadius: 10, fontSize: 14, outline: 'none', boxSizing: 'border-box', transition: 'border-color 0.2s' }}
|
||||||
|
onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
|
||||||
|
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: 20 }}>
|
<div style={{ marginBottom: 20 }}>
|
||||||
<label style={{ display: 'block', marginBottom: 4, fontSize: 13 }}>验证码 (演示环境任意输入)</label>
|
<label style={{ display: 'block', marginBottom: 4, fontSize: 13, fontWeight: 500, color: '#5A6072' }}>验证码 (演示环境任意输入)</label>
|
||||||
<input value={code} onChange={(e) => setCode(e.target.value)}
|
<input value={code} onChange={(e) => setCode(e.target.value)}
|
||||||
placeholder="输入任意验证码"
|
placeholder="输入任意验证码"
|
||||||
style={{ width: '100%', padding: '10px 12px', border: '1px solid #ddd', borderRadius: 4, fontSize: 14 }} />
|
style={{ width: '100%', padding: '12px 14px', border: '1.5px solid #E1E5ED', borderRadius: 10, fontSize: 14, outline: 'none', boxSizing: 'border-box', transition: 'border-color 0.2s' }}
|
||||||
|
onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
|
||||||
|
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" disabled={loading} style={{
|
<button type="submit" disabled={loading} style={{
|
||||||
width: '100%', padding: '12px', background: '#1976d2', color: '#fff',
|
width: '100%', padding: '13px', background: 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)', color: '#fff',
|
||||||
border: 'none', borderRadius: 4, fontSize: 15, opacity: loading ? 0.7 : 1,
|
border: 'none', borderRadius: 10, fontSize: 15, fontWeight: 600,
|
||||||
|
opacity: loading ? 0.7 : 1, boxShadow: '0 4px 16px rgba(79,110,247,0.3)',
|
||||||
}}>
|
}}>
|
||||||
{loading ? '登录中...' : '登录'}
|
{loading ? '登录中...' : '登录'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p style={{ marginTop: 16, fontSize: 12, color: '#999', textAlign: 'center' }}>
|
<p style={{ marginTop: 16, fontSize: 12, color: '#9BA0B4', textAlign: 'center' }}>
|
||||||
演示账号:13700137000 (王建国 主任医师)
|
演示账号:13700137000 (王建国 主任医师)
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { HubConnectionBuilder, HubConnection, HubConnectionState } from '@microsoft/signalr';
|
||||||
import { api } from '../../services/api-client';
|
import { api } from '../../services/api-client';
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
@@ -7,12 +8,24 @@ interface Message {
|
|||||||
content: string; contentType: string; createdAt: string;
|
content: string; contentType: string; createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getToken(): string {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('doc_auth');
|
||||||
|
if (!raw) return '';
|
||||||
|
const state = JSON.parse(raw);
|
||||||
|
return state?.state?.token ?? '';
|
||||||
|
} catch { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
export function ChatPage() {
|
export function ChatPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
const connRef = useRef<HubConnection | null>(null);
|
||||||
|
|
||||||
|
// Load initial messages via HTTP
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
api.get<Message[]>(`/api/consultations/${id}/messages`)
|
api.get<Message[]>(`/api/consultations/${id}/messages`)
|
||||||
@@ -20,23 +33,69 @@ export function ChatPage() {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
// Set up SignalR connection
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const conn = new HubConnectionBuilder()
|
||||||
|
.withUrl('http://localhost:5000/hubs/chat', {
|
||||||
|
accessTokenFactory: () => getToken(),
|
||||||
|
})
|
||||||
|
.withAutomaticReconnect()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
conn.on('ReceiveMessage', (msg: Message) => {
|
||||||
|
setMessages((prev) => {
|
||||||
|
// Dedup — guard against reconnection replay
|
||||||
|
if (prev.some((m) => m.id === msg.id)) return prev;
|
||||||
|
return [...prev, msg];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
conn.onreconnected(() => {
|
||||||
|
conn.invoke('JoinConsultation', id).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
conn.start()
|
||||||
|
.then(() => {
|
||||||
|
setConnected(true);
|
||||||
|
return conn.invoke('JoinConsultation', id);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
connRef.current = conn;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (conn.state === HubConnectionState.Connected) {
|
||||||
|
conn.invoke('LeaveConsultation', id).catch(() => {});
|
||||||
|
}
|
||||||
|
conn.stop();
|
||||||
|
};
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
// Auto-scroll on new messages
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = useCallback(async () => {
|
||||||
if (!input.trim() || !id) return;
|
if (!input.trim() || !id || !connRef.current) return;
|
||||||
try {
|
const text = input;
|
||||||
const res = await api.post<Message>(`/api/consultations/${id}/messages`, { content: input });
|
|
||||||
setMessages((prev) => [...prev, res.data]);
|
|
||||||
setInput('');
|
setInput('');
|
||||||
|
try {
|
||||||
|
await connRef.current.invoke('SendMessage', id, text);
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
};
|
}, [input, id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 0px)' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 0px)' }}>
|
||||||
<div style={{ padding: '14px 20px', background: '#fff', borderBottom: '1px solid #eee', fontSize: 15, fontWeight: 500 }}>
|
<div style={{ padding: '14px 20px', background: '#fff', borderBottom: '1px solid #eee', fontSize: 15, fontWeight: 500, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
在线问诊
|
在线问诊
|
||||||
|
<span style={{
|
||||||
|
width: 8, height: 8, borderRadius: '50%',
|
||||||
|
background: connected ? '#4caf50' : '#ccc',
|
||||||
|
display: 'inline-block',
|
||||||
|
}} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ flex: 1, overflow: 'auto', padding: 20, background: '#fafafa' }}>
|
<div style={{ flex: 1, overflow: 'auto', padding: 20, background: '#fafafa' }}>
|
||||||
|
|||||||
@@ -2,64 +2,54 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { api } from '../../services/api-client';
|
import { api } from '../../services/api-client';
|
||||||
|
|
||||||
interface ConsultationItem {
|
|
||||||
id: string; patientId: string; patientName: string; subject: string;
|
|
||||||
status: string; startedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RawConsultation {
|
interface RawConsultation {
|
||||||
id: string; patientId: string; patientName?: string; subject?: string;
|
id: string; patientId: string; patientName?: string; subject?: string;
|
||||||
status: string; startedAt: string;
|
status: string; startedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConsultationListPage() {
|
export function ConsultationListPage() {
|
||||||
const [consultations, setConsultations] = useState<ConsultationItem[]>([]);
|
const [consultations, setConsultations] = useState<RawConsultation[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get<RawConsultation[]>('/api/consultations').then((r) => {
|
api.get<RawConsultation[]>('/api/consultations').then((r) => {
|
||||||
const mapped = r.data.map((c) => ({
|
setConsultations(r.data);
|
||||||
id: c.id,
|
|
||||||
patientId: c.patientId,
|
|
||||||
patientName: c.patientName || 'unknown',
|
|
||||||
subject: c.subject || 'online consult',
|
|
||||||
status: c.status,
|
|
||||||
startedAt: c.startedAt,
|
|
||||||
}));
|
|
||||||
setConsultations(mapped);
|
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24 }}>
|
<div style={{ padding: 28 }}>
|
||||||
<h2 style={{ marginBottom: 16 }}>在线问诊</h2>
|
<h2 style={{ marginBottom: 6, fontSize: 20, fontWeight: 700, color: '#1A1D28' }}>在线问诊</h2>
|
||||||
|
<p style={{ marginBottom: 18, fontSize: 13, color: '#9BA0B4' }}>共 {consultations.length} 条问诊记录</p>
|
||||||
|
|
||||||
<div style={{ background: '#fff', borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}>
|
<div style={{ background: '#fff', borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)', overflow: 'hidden' }}>
|
||||||
{consultations.map((c) => (
|
{consultations.map((c) => (
|
||||||
<Link key={c.id} to={`/consultations/${c.id}`} style={{
|
<Link key={c.id} to={`/consultations/${c.id}`} style={{
|
||||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
padding: '14px 20px', borderBottom: '1px solid #f5f5f5',
|
padding: '16px 22px', borderBottom: '1px solid #F5F6F9',
|
||||||
textDecoration: 'none', color: 'inherit',
|
textDecoration: 'none', color: 'inherit', transition: 'background 0.15s',
|
||||||
}}>
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.background = '#F9FAFC'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.background = ''; }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 14, fontWeight: 500 }}>{c.patientName}</div>
|
<div style={{ fontSize: 14, fontWeight: 600, color: '#1A1D28' }}>{c.patientName || '未知'}</div>
|
||||||
<div style={{ fontSize: 12, color: '#888', marginTop: 2 }}>{c.subject}</div>
|
<div style={{ fontSize: 12, color: '#9BA0B4', marginTop: 3 }}>{c.subject || '在线问诊'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: 'right' }}>
|
<div style={{ textAlign: 'right' }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
padding: '2px 8px', borderRadius: 10, fontSize: 11,
|
padding: '4px 12px', borderRadius: 10, fontSize: 11, fontWeight: 500,
|
||||||
background: c.status === 'active' ? '#e8f5e9' : '#f5f5f5',
|
background: c.status === 'active' ? '#E6F9F2' : '#F5F6F9',
|
||||||
color: c.status === 'active' ? '#2e7d32' : '#999',
|
color: c.status === 'active' ? '#20C997' : '#9BA0B4',
|
||||||
}}>
|
}}>
|
||||||
{c.status === 'active' ? '进行中' : '已结束'}
|
{c.status === 'active' ? '进行中' : '已结束'}
|
||||||
</span>
|
</span>
|
||||||
<div style={{ fontSize: 11, color: '#bbb', marginTop: 4 }}>
|
<div style={{ fontSize: 11, color: '#C0C5D2', marginTop: 4 }}>
|
||||||
{c.startedAt?.split('T')[0]}
|
{c.startedAt?.split('T')[0]}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
{consultations.length === 0 && (
|
{consultations.length === 0 && (
|
||||||
<div style={{ padding: 40, textAlign: 'center', color: '#999' }}>暂无问诊记录</div>
|
<div style={{ padding: 40, textAlign: 'center', color: '#9BA0B4', fontSize: 13 }}>暂无问诊记录</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,41 @@ interface RawConsultation { id: string; status: string; patientName: string; sub
|
|||||||
interface RawFollowUp { id: string; scheduledAt: string; title: string; status: string; }
|
interface RawFollowUp { id: string; scheduledAt: string; title: string; status: string; }
|
||||||
interface RawReport { id: string; title: string; status: string; }
|
interface RawReport { id: string; title: string; status: string; }
|
||||||
|
|
||||||
|
const statCardStyle: React.CSSProperties = {
|
||||||
|
background: '#fff', padding: 22, borderRadius: 16,
|
||||||
|
boxShadow: '0 2px 12px rgba(0,0,0,0.04)',
|
||||||
|
position: 'relative', overflow: 'hidden',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statColorBar = (color: string): React.CSSProperties => ({
|
||||||
|
position: 'absolute', top: 0, left: 0, width: 4, height: '100%',
|
||||||
|
background: color, borderRadius: '4px 0 0 4px',
|
||||||
|
});
|
||||||
|
|
||||||
|
const todoIcons: Record<string, React.ReactNode> = {
|
||||||
|
reports: (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#F59E0B" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
consultations: (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#4F6EF7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
followups: (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#845EF7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||||
|
<line x1="16" y1="2" x2="16" y2="6" />
|
||||||
|
<line x1="8" y1="2" x2="8" y2="6" />
|
||||||
|
<line x1="3" y1="10" x2="21" y2="10" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const [stats, setStats] = useState<DashboardStats>({
|
const [stats, setStats] = useState<DashboardStats>({
|
||||||
@@ -22,7 +57,7 @@ export function DashboardPage() {
|
|||||||
const [patients, consultations, reports, followUps] = await Promise.all([
|
const [patients, consultations, reports, followUps] = await Promise.all([
|
||||||
api.get<RawPatient[]>('/api/patients'),
|
api.get<RawPatient[]>('/api/patients'),
|
||||||
api.get<RawConsultation[]>('/api/consultations'),
|
api.get<RawConsultation[]>('/api/consultations'),
|
||||||
api.get<RawReport[]>('/api/reports?status=pending'),
|
api.get<RawReport[]>('/api/reports/pending'),
|
||||||
api.get<RawFollowUp[]>('/api/follow-ups'),
|
api.get<RawFollowUp[]>('/api/follow-ups'),
|
||||||
]);
|
]);
|
||||||
setStats({
|
setStats({
|
||||||
@@ -41,58 +76,69 @@ export function DashboardPage() {
|
|||||||
loadStats();
|
loadStats();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
const statItems = [
|
||||||
<div style={{ padding: 24 }}>
|
{ label: '患者总数', value: stats.totalPatients, color: '#4F6EF7', bg: '#EDF0FD' },
|
||||||
<h2 style={{ marginBottom: 20 }}>欢迎回来,{user?.name}</h2>
|
{ label: '进行中问诊', value: stats.activeConsultations, color: '#20C997', bg: '#E6F9F2' },
|
||||||
|
{ label: '待审核报告', value: stats.pendingReports, color: '#F59E0B', bg: '#FFF8E6' },
|
||||||
|
{ label: '今日随访', value: stats.todayFollowUps, color: '#845EF7', bg: '#F3E8FF' },
|
||||||
|
];
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, marginBottom: 32 }}>
|
const quickActions = [
|
||||||
{[
|
{ label: '患者列表', href: '/patients', color: '#4F6EF7', bg: '#EDF0FD' },
|
||||||
{ label: '患者总数', value: stats.totalPatients, color: '#1976d2' },
|
{ label: '在线问诊', href: '/consultations', color: '#20C997', bg: '#E6F9F2' },
|
||||||
{ label: '进行中问诊', value: stats.activeConsultations, color: '#388e3c' },
|
{ label: '报告审核', href: '/reports', color: '#F59E0B', bg: '#FFF8E6' },
|
||||||
{ label: '待审核报告', value: stats.pendingReports, color: '#f57c00' },
|
{ label: '随访管理', href: '/follow-ups', color: '#845EF7', bg: '#F3E8FF' },
|
||||||
{ label: '今日随访', value: stats.todayFollowUps, color: '#7b1fa2' },
|
];
|
||||||
].map((item) => (
|
|
||||||
<div key={item.label} style={{
|
return (
|
||||||
background: '#fff', padding: 20, borderRadius: 8,
|
<div style={{ padding: 28 }}>
|
||||||
borderLeft: `4px solid ${item.color}`, boxShadow: '0 1px 4px rgba(0,0,0,0.08)',
|
<h2 style={{ marginBottom: 4, fontSize: 22, fontWeight: 700, color: '#1A1D28' }}>欢迎回来,{user?.name}</h2>
|
||||||
}}>
|
<p style={{ marginBottom: 24, fontSize: 13, color: '#9BA0B4' }}>{user?.department} · {user?.title}</p>
|
||||||
<div style={{ fontSize: 28, fontWeight: 700, color: item.color }}>{item.value}</div>
|
|
||||||
<div style={{ fontSize: 13, color: '#888', marginTop: 4 }}>{item.label}</div>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, marginBottom: 28 }}>
|
||||||
|
{statItems.map((item) => (
|
||||||
|
<div key={item.label} style={statCardStyle}>
|
||||||
|
<div style={statColorBar(item.color)} />
|
||||||
|
<div style={{ paddingLeft: 8 }}>
|
||||||
|
<div style={{ fontSize: 30, fontWeight: 800, color: item.color, lineHeight: 1.1 }}>{item.value}</div>
|
||||||
|
<div style={{ fontSize: 13, color: '#5A6072', marginTop: 6, fontWeight: 500 }}>{item.label}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 16 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 16 }}>
|
||||||
<div style={{ background: '#fff', padding: 20, borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}>
|
<div style={{ background: '#fff', padding: 22, borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
|
||||||
<h3 style={{ marginBottom: 12, fontSize: 15 }}>快捷操作</h3>
|
<h3 style={{ marginBottom: 16, fontSize: 16, fontWeight: 600, color: '#1A1D28' }}>快捷操作</h3>
|
||||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||||
{[
|
{quickActions.map((action) => (
|
||||||
{ label: '患者列表', href: '/patients' },
|
|
||||||
{ label: '在线问诊', href: '/consultations' },
|
|
||||||
{ label: '报告审核', href: '/reports' },
|
|
||||||
{ label: '随访管理', href: '/follow-ups' },
|
|
||||||
].map((action) => (
|
|
||||||
<Link key={action.label} to={action.href} style={{
|
<Link key={action.label} to={action.href} style={{
|
||||||
padding: '8px 16px', background: '#f0f2f5', borderRadius: 4,
|
padding: '10px 18px', background: action.bg, borderRadius: 10,
|
||||||
textDecoration: 'none', color: '#1976d2', fontSize: 13,
|
textDecoration: 'none', color: action.color, fontSize: 13,
|
||||||
}}>
|
fontWeight: 600, transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = ''; }}>
|
||||||
{action.label} →
|
{action.label} →
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ background: '#fff', padding: 20, borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}>
|
<div style={{ background: '#fff', padding: 22, borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
|
||||||
<h3 style={{ marginBottom: 12, fontSize: 15 }}>今日待办</h3>
|
<h3 style={{ marginBottom: 14, fontSize: 16, fontWeight: 600, color: '#1A1D28' }}>今日待办</h3>
|
||||||
<ul style={{ fontSize: 13, color: '#666', listStyle: 'none', padding: 0 }}>
|
<ul style={{ fontSize: 13, color: '#5A6072', listStyle: 'none', padding: 0 }}>
|
||||||
<li style={{ padding: '6px 0', borderBottom: '1px solid #f0f0f0' }}>
|
<li style={{ padding: '10px 0', borderBottom: '1px solid #F0F2F5', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
📋 待审核报告: {stats.pendingReports} 份
|
{todoIcons.reports}
|
||||||
|
待审核报告: <strong style={{ color: '#F59E0B' }}>{stats.pendingReports}</strong> 份
|
||||||
</li>
|
</li>
|
||||||
<li style={{ padding: '6px 0', borderBottom: '1px solid #f0f0f0' }}>
|
<li style={{ padding: '10px 0', borderBottom: '1px solid #F0F2F5', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
💬 进行中问诊: {stats.activeConsultations} 个
|
{todoIcons.consultations}
|
||||||
|
进行中问诊: <strong style={{ color: '#4F6EF7' }}>{stats.activeConsultations}</strong> 个
|
||||||
</li>
|
</li>
|
||||||
<li style={{ padding: '6px 0' }}>
|
<li style={{ padding: '10px 0', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
📅 今日随访: {stats.todayFollowUps} 项
|
{todoIcons.followups}
|
||||||
|
今日随访: <strong style={{ color: '#845EF7' }}>{stats.todayFollowUps}</strong> 项
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,30 +29,35 @@ export function FollowUpEditPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const body = { title, patientId, scheduledAt, notes };
|
const body = { title, patientId, scheduledAt, notes };
|
||||||
try {
|
try {
|
||||||
if (isNew) {
|
if (isNew) { await api.post('/api/follow-ups', body); }
|
||||||
await api.post('/api/follow-ups', body);
|
else { await api.put(`/api/follow-ups/${id}`, body); }
|
||||||
} else {
|
|
||||||
await api.put(`/api/follow-ups/${id}`, body);
|
|
||||||
}
|
|
||||||
navigate('/follow-ups');
|
navigate('/follow-ups');
|
||||||
} catch { alert('操作失败'); }
|
} catch { alert('操作失败'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const inputStyle: React.CSSProperties = {
|
||||||
<div style={{ padding: 24 }}>
|
width: '100%', padding: '10px 14px', border: '1.5px solid #E1E5ED',
|
||||||
<h2 style={{ marginBottom: 16 }}>{isNew ? '新建随访' : '编辑随访'}</h2>
|
borderRadius: 10, fontSize: 13, outline: 'none', boxSizing: 'border-box',
|
||||||
|
};
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
display: 'block', fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 5,
|
||||||
|
};
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} style={{ background: '#fff', padding: 24, borderRadius: 8, maxWidth: 500 }}>
|
return (
|
||||||
<div style={{ marginBottom: 14 }}>
|
<div style={{ padding: 28 }}>
|
||||||
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}>标题</label>
|
<h2 style={{ marginBottom: 20, fontSize: 20, fontWeight: 700, color: '#1A1D28' }}>{isNew ? '新建复查' : '编辑复查'}</h2>
|
||||||
<input value={title} onChange={(e) => setTitle(e.target.value)} required
|
|
||||||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4 }} />
|
<form onSubmit={handleSubmit} style={{ background: '#fff', padding: 28, borderRadius: 16, maxWidth: 520, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label style={labelStyle}>标题</label>
|
||||||
|
<input value={title} onChange={(e) => setTitle(e.target.value)} required style={inputStyle}
|
||||||
|
onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
|
||||||
|
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: 14 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}>患者</label>
|
<label style={labelStyle}>患者</label>
|
||||||
<select value={patientId} onChange={(e) => setPatientId(e.target.value)} required
|
<select value={patientId} onChange={(e) => setPatientId(e.target.value)} required style={inputStyle}>
|
||||||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4 }}>
|
|
||||||
<option value="">请选择</option>
|
<option value="">请选择</option>
|
||||||
{patients.map((p) => (
|
{patients.map((p) => (
|
||||||
<option key={p.id} value={p.id}>{p.name}</option>
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
@@ -60,21 +65,23 @@ export function FollowUpEditPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: 14 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}>计划时间</label>
|
<label style={labelStyle}>计划时间</label>
|
||||||
<input type="datetime-local" value={scheduledAt} onChange={(e) => setScheduledAt(e.target.value)} required
|
<input type="datetime-local" value={scheduledAt} onChange={(e) => setScheduledAt(e.target.value)} required style={inputStyle}
|
||||||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4 }} />
|
onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
|
||||||
|
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: 18 }}>
|
<div style={{ marginBottom: 20 }}>
|
||||||
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}>备注</label>
|
<label style={labelStyle}>备注</label>
|
||||||
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={3}
|
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={3}
|
||||||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4, resize: 'vertical' }} />
|
style={{ ...inputStyle, resize: 'vertical', fontFamily: 'inherit' }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" style={{
|
<button type="submit" style={{
|
||||||
padding: '10px 24px', background: '#1976d2', color: '#fff',
|
padding: '11px 28px', background: 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)', color: '#fff',
|
||||||
border: 'none', borderRadius: 4, fontSize: 14,
|
border: 'none', borderRadius: 10, fontSize: 14, fontWeight: 600,
|
||||||
|
boxShadow: '0 4px 16px rgba(79,110,247,0.25)',
|
||||||
}}>
|
}}>
|
||||||
{isNew ? '创建' : '保存'}
|
{isNew ? '创建' : '保存'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,81 +2,122 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { api } from '../../services/api-client';
|
import { api } from '../../services/api-client';
|
||||||
|
|
||||||
interface FollowUpItem {
|
|
||||||
id: string; patientId: string; patientName: string;
|
|
||||||
title: string; scheduledAt: string; status: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RawFollowUpItem {
|
interface RawFollowUpItem {
|
||||||
id: string; patientId: string; patientName?: string;
|
id: string; patientId: string; patientName?: string;
|
||||||
title: string; scheduledAt: string; status: string;
|
title: string; scheduledAt: string; status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FollowUpListPage() {
|
export function FollowUpListPage() {
|
||||||
const [followUps, setFollowUps] = useState<FollowUpItem[]>([]);
|
const [followUps, setFollowUps] = useState<RawFollowUpItem[]>([]);
|
||||||
|
const [showCompleted, setShowCompleted] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const load = () => {
|
||||||
api.get<RawFollowUpItem[]>('/api/follow-ups').then((r) => {
|
api.get<RawFollowUpItem[]>('/api/follow-ups?type=recheck')
|
||||||
const mapped = r.data.map((f) => ({
|
.then((r) => setFollowUps(r.data)).catch(() => {});
|
||||||
id: f.id,
|
|
||||||
patientId: f.patientId,
|
|
||||||
patientName: f.patientName || 'unknown',
|
|
||||||
title: f.title,
|
|
||||||
scheduledAt: f.scheduledAt,
|
|
||||||
status: f.status,
|
|
||||||
}));
|
|
||||||
setFollowUps(mapped);
|
|
||||||
}).catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const statusLabel = (s: string) => {
|
|
||||||
switch (s) {
|
|
||||||
case 'pending': return { text: '待随访', color: '#f57c00', bg: '#fff3e0' };
|
|
||||||
case 'completed': return { text: '已完成', color: '#2e7d32', bg: '#e8f5e9' };
|
|
||||||
case 'missed': return { text: '已错过', color: '#c62828', bg: '#ffebee' };
|
|
||||||
default: return { text: s, color: '#666', bg: '#f5f5f5' };
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
const handleComplete = async (e: React.MouseEvent, id: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
await api.put(`/api/follow-ups/${id}`, { status: 'completed' });
|
||||||
|
load();
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (e: React.MouseEvent, id: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!confirm('确定删除?')) return;
|
||||||
|
try {
|
||||||
|
await api.del(`/api/follow-ups/${id}`);
|
||||||
|
load();
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const statusLabel = (f: RawFollowUpItem) => {
|
||||||
|
if (f.status === 'completed') return { text: '已完成', color: '#20C997', bg: '#E6F9F2' };
|
||||||
|
if (f.status === 'cancelled') return { text: '已取消', color: '#EF4444', bg: '#FEE9E9' };
|
||||||
|
if (f.scheduledAt && new Date(f.scheduledAt) < now) return { text: '已过期', color: '#EF4444', bg: '#FEE9E9' };
|
||||||
|
return { text: '待复查', color: '#F59E0B', bg: '#FFF8E6' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const active = followUps.filter((f) => f.status !== 'completed');
|
||||||
|
const completed = followUps.filter((f) => f.status === 'completed');
|
||||||
|
const displayed = [...active, ...(showCompleted ? completed : [])];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24 }}>
|
<div style={{ padding: 28 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||||
<h2>随访管理</h2>
|
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1A1D28', margin: 0 }}>复查管理</h2>
|
||||||
<Link to="/follow-ups/new/edit" style={{
|
<Link to="/follow-ups/new/edit" style={{
|
||||||
padding: '8px 16px', background: '#1976d2', color: '#fff',
|
padding: '10px 20px', background: 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)', color: '#fff',
|
||||||
borderRadius: 4, textDecoration: 'none', fontSize: 13,
|
borderRadius: 10, textDecoration: 'none', fontSize: 13, fontWeight: 600,
|
||||||
|
boxShadow: '0 4px 16px rgba(79,110,247,0.25)',
|
||||||
}}>
|
}}>
|
||||||
+ 新建随访
|
+ 新建复查
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
<p style={{ marginBottom: 18, fontSize: 13, color: '#9BA0B4' }}>共 {followUps.length} 条复查记录</p>
|
||||||
|
|
||||||
<div style={{ background: '#fff', borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
{followUps.map((f) => {
|
{displayed.map((f) => {
|
||||||
const s = statusLabel(f.status);
|
const s = statusLabel(f);
|
||||||
return (
|
return (
|
||||||
<div key={f.id} style={{
|
<div key={f.id} style={{
|
||||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
padding: '14px 20px', borderBottom: '1px solid #f5f5f5',
|
padding: '16px 22px', background: '#fff', borderRadius: 14,
|
||||||
|
boxShadow: '0 1px 4px rgba(0,0,0,0.04)', border: '1px solid #F0F2F5',
|
||||||
}}>
|
}}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 14, fontWeight: 500 }}>{f.title}</div>
|
<div style={{ fontSize: 14, fontWeight: 600, color: '#1A1D28' }}>{f.title}</div>
|
||||||
<div style={{ fontSize: 12, color: '#888', marginTop: 2 }}>
|
<div style={{ fontSize: 12, color: '#9BA0B4', marginTop: 3 }}>
|
||||||
{f.patientName} · {f.scheduledAt?.split('T')[0]}
|
{f.patientName || '未知'} · {f.scheduledAt?.split('T')[0]}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<span style={{ padding: '2px 8px', borderRadius: 10, fontSize: 11, background: s.bg, color: s.color }}>
|
<span style={{ padding: '4px 12px', borderRadius: 10, fontSize: 11, fontWeight: 500, background: s.bg, color: s.color }}>
|
||||||
{s.text}
|
{s.text}
|
||||||
</span>
|
</span>
|
||||||
<Link to={`/follow-ups/${f.id}/edit`} style={{ color: '#1976d2', fontSize: 13 }}>编辑</Link>
|
{f.status === 'upcoming' && (
|
||||||
|
<button onClick={(e) => handleComplete(e, f.id)} style={{
|
||||||
|
padding: '4px 10px', borderRadius: 6, fontSize: 11, fontWeight: 600,
|
||||||
|
color: '#4F6EF7', background: '#EDF0FD', border: '1px solid #D0D5FD',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}>标记完成</button>
|
||||||
|
)}
|
||||||
|
<Link to={`/follow-ups/${f.id}/edit`} style={{
|
||||||
|
color: '#4F6EF7', fontSize: 12, fontWeight: 600,
|
||||||
|
padding: '4px 10px', background: '#EDF0FD', borderRadius: 6, textDecoration: 'none',
|
||||||
|
}}>编辑</Link>
|
||||||
|
<button onClick={(e) => handleDelete(e, f.id)} style={{
|
||||||
|
width: 26, height: 26, borderRadius: 6, border: 'none',
|
||||||
|
background: '#FEF2F2', color: '#EF4444', cursor: 'pointer',
|
||||||
|
fontSize: 14, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}} title="删除">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{followUps.length === 0 && (
|
{followUps.length === 0 && (
|
||||||
<div style={{ padding: 40, textAlign: 'center', color: '#999' }}>暂无随访记录</div>
|
<div style={{ padding: 40, textAlign: 'center', color: '#9BA0B4', fontSize: 13 }}>暂无复查记录</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{completed.length > 0 && (
|
||||||
|
<button onClick={() => setShowCompleted(!showCompleted)} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6, margin: '12px auto 0',
|
||||||
|
padding: '8px 20px', borderRadius: 20, border: '1px solid #E4E8EE',
|
||||||
|
background: '#fff', color: '#9BA0B4', fontSize: 12, cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
{showCompleted ? '收起已完成' : `查看已完成 (${completed.length})`}
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
style={{ transform: showCompleted ? 'rotate(180deg)' : '' }}>
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
82
frontend-doctor/src/pages/followups/VisitEditPage.tsx
Normal file
82
frontend-doctor/src/pages/followups/VisitEditPage.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { api } from '../../services/api-client';
|
||||||
|
|
||||||
|
export function VisitEditPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isNew = !id || id === 'new';
|
||||||
|
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [patientId, setPatientId] = useState('');
|
||||||
|
const [scheduledAt, setScheduledAt] = useState('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [patients, setPatients] = useState<{ id: string; name: string }[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<{ id: string; name: string }[]>('/api/patients').then((r) => setPatients(r.data));
|
||||||
|
if (!isNew) {
|
||||||
|
api.get<Record<string, unknown>>(`/api/follow-ups/${id}`).then((r) => {
|
||||||
|
setTitle(r.data.title as string);
|
||||||
|
setPatientId(r.data.patientId as string);
|
||||||
|
setScheduledAt((r.data.scheduledAt as string)?.slice(0, 16) || '');
|
||||||
|
setNotes((r.data.notes as string) || '');
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}, [id, isNew]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
if (isNew) {
|
||||||
|
await api.post('/api/follow-ups', { title, patientId, scheduledAt, notes });
|
||||||
|
} else {
|
||||||
|
await api.put(`/api/follow-ups/${id}`, { title, patientId, scheduledAt, notes });
|
||||||
|
}
|
||||||
|
navigate('/visits');
|
||||||
|
} catch { alert('操作失败'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: '100%', padding: '10px 14px', border: '1.5px solid #E1E5ED',
|
||||||
|
borderRadius: 10, fontSize: 13, outline: 'none', boxSizing: 'border-box',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 28 }}>
|
||||||
|
<h2 style={{ marginBottom: 20, fontSize: 20, fontWeight: 700, color: '#1A1D28' }}>
|
||||||
|
{isNew ? '新建随访' : '编辑随访'}
|
||||||
|
</h2>
|
||||||
|
<form onSubmit={handleSubmit} style={{ background: '#fff', padding: 28, borderRadius: 16, maxWidth: 520, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 5 }}>随访标题</label>
|
||||||
|
<input value={title} onChange={(e) => setTitle(e.target.value)} required style={inputStyle}
|
||||||
|
placeholder="如:PCI术后1个月随访" />
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 5 }}>随访患者</label>
|
||||||
|
<select value={patientId} onChange={(e) => setPatientId(e.target.value)} required style={inputStyle}>
|
||||||
|
<option value="">请选择</option>
|
||||||
|
{patients.map((p) => (<option key={p.id} value={p.id}>{p.name}</option>))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 5 }}>随访时间</label>
|
||||||
|
<input type="datetime-local" value={scheduledAt} onChange={(e) => setScheduledAt(e.target.value)} required style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 5 }}>随访内容 / 备注</label>
|
||||||
|
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={4}
|
||||||
|
style={{ ...inputStyle, resize: 'vertical', fontFamily: 'inherit' }} placeholder="告知患者本次随访的目的、需要准备的材料等" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" style={{
|
||||||
|
padding: '11px 28px', background: 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)', color: '#fff',
|
||||||
|
border: 'none', borderRadius: 10, fontSize: 14, fontWeight: 600,
|
||||||
|
boxShadow: '0 4px 16px rgba(79,110,247,0.25)',
|
||||||
|
}}>
|
||||||
|
{isNew ? '创建随访' : '保存'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
frontend-doctor/src/pages/followups/VisitListPage.tsx
Normal file
73
frontend-doctor/src/pages/followups/VisitListPage.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { api } from '../../services/api-client';
|
||||||
|
|
||||||
|
interface VisitItem {
|
||||||
|
id: string; patientId: string; patientName: string;
|
||||||
|
title: string; scheduledAt: string; status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VisitListPage() {
|
||||||
|
const [visits, setVisits] = useState<VisitItem[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<VisitItem[]>('/api/follow-ups?type=followup')
|
||||||
|
.then((r) => setVisits(r.data)).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const statusLabel = (s: string) => {
|
||||||
|
switch (s) {
|
||||||
|
case 'upcoming': return { text: '待随访', color: '#F59E0B', bg: '#FFF8E6' };
|
||||||
|
case 'completed': return { text: '已完成', color: '#20C997', bg: '#E6F9F2' };
|
||||||
|
case 'cancelled': return { text: '已取消', color: '#EF4444', bg: '#FEE9E9' };
|
||||||
|
default: return { text: s, color: '#9BA0B4', bg: '#F5F6F9' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 28 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||||
|
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1A1D28', margin: 0 }}>随访管理</h2>
|
||||||
|
<Link to="/visits/new/edit" style={{
|
||||||
|
padding: '10px 20px', background: 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)', color: '#fff',
|
||||||
|
borderRadius: 10, textDecoration: 'none', fontSize: 13, fontWeight: 600,
|
||||||
|
boxShadow: '0 4px 16px rgba(79,110,247,0.25)',
|
||||||
|
}}>
|
||||||
|
+ 新建随访
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<p style={{ marginBottom: 18, fontSize: 13, color: '#9BA0B4' }}>共 {visits.length} 条随访计划</p>
|
||||||
|
|
||||||
|
<div style={{ background: '#fff', borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)', overflow: 'hidden' }}>
|
||||||
|
{visits.map((v) => {
|
||||||
|
const s = statusLabel(v.status);
|
||||||
|
return (
|
||||||
|
<div key={v.id} style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
padding: '16px 22px', borderBottom: '1px solid #F5F6F9',
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, color: '#1A1D28' }}>{v.title}</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#9BA0B4', marginTop: 3 }}>
|
||||||
|
{v.patientName || '未知'} · {v.scheduledAt?.split('T')[0]}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
|
<span style={{ padding: '4px 12px', borderRadius: 10, fontSize: 11, fontWeight: 500, background: s.bg, color: s.color }}>
|
||||||
|
{s.text}
|
||||||
|
</span>
|
||||||
|
<Link to={`/visits/${v.id}/edit`} style={{
|
||||||
|
color: '#4F6EF7', fontSize: 12, fontWeight: 600,
|
||||||
|
padding: '4px 12px', background: '#EDF0FD', borderRadius: 6,
|
||||||
|
}}>详情</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{visits.length === 0 && (
|
||||||
|
<div style={{ padding: 40, textAlign: 'center', color: '#9BA0B4', fontSize: 13 }}>暂无随访计划</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { api } from '../../services/api-client';
|
import { api } from '../../services/api-client';
|
||||||
|
import { MultiLineChart, type SeriesData } from '../../components/charts/MultiLineChart';
|
||||||
|
|
||||||
interface PatientDetail {
|
interface PatientDetail {
|
||||||
id: string; name: string; phone: string; gender: string; birthday: string;
|
id: string; name: string; phone: string; gender: string; birthday: string;
|
||||||
@@ -12,21 +13,76 @@ interface HealthRecord {
|
|||||||
id: string; type: string; value: string; unit: string; recordedAt: string;
|
id: string; type: string; value: string; unit: string; recordedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ExerciseEntry {
|
||||||
|
type: string; duration: number; intensity: string; caloriesBurned: number; date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DietEntry {
|
||||||
|
foods: { name: string; amount?: string; calories?: number }[];
|
||||||
|
mealType: string; totalCalories: number; date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeLabels: Record<string, string> = {
|
||||||
|
blood_pressure: '血压', heart_rate: '心率', blood_sugar: '血糖', spo2: '血氧',
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeColors: Record<string, string> = {
|
||||||
|
blood_pressure: '#EF4444', heart_rate: '#F59E0B', blood_sugar: '#4F6EF7', spo2: '#20C997',
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeBgs: Record<string, string> = {
|
||||||
|
blood_pressure: '#FEE9E9', heart_rate: '#FFF8E6', blood_sugar: '#EDF0FD', spo2: '#E6F9F2',
|
||||||
|
};
|
||||||
|
|
||||||
|
const exerciseIcons: Record<string, string> = {
|
||||||
|
'散步': '🚶', '慢跑': '🏃', '太极拳': '🤸', '游泳': '🏊', '骑自行车': '🚴', '八段锦': '🧘',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mealLabels: Record<string, string> = {
|
||||||
|
breakfast: '早餐', lunch: '午餐', dinner: '晚餐', snack: '加餐',
|
||||||
|
};
|
||||||
|
const mealIcons: Record<string, string> = {
|
||||||
|
breakfast: '🌅', lunch: '☀️', dinner: '🌙', snack: '🍎',
|
||||||
|
};
|
||||||
|
|
||||||
export function PatientDetailPage() {
|
export function PatientDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const [patient, setPatient] = useState<PatientDetail | null>(null);
|
const [patient, setPatient] = useState<PatientDetail | null>(null);
|
||||||
const [records, setRecords] = useState<HealthRecord[]>([]);
|
const [records, setRecords] = useState<HealthRecord[]>([]);
|
||||||
|
const [exercises, setExercises] = useState<ExerciseEntry[]>([]);
|
||||||
|
const [diets, setDiets] = useState<DietEntry[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
// Fetch patient detail directly by ID + health records
|
|
||||||
api.get<PatientDetail>(`/api/patients/${id}`).then((r) => {
|
api.get<PatientDetail>(`/api/patients/${id}`).then((r) => {
|
||||||
if (r.data) setPatient(r.data);
|
if (r.data) setPatient(r.data);
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
api.get<HealthRecord[]>(`/api/health-records?patientId=${id}&days=30`).then((r) => setRecords(r.data));
|
api.get<HealthRecord[]>(`/api/health-records?patientId=${id}&days=30`).then((r) => {
|
||||||
|
const all = r.data || [];
|
||||||
|
setRecords(all.filter((x) => ['blood_pressure', 'heart_rate', 'blood_sugar', 'spo2'].includes(x.type)));
|
||||||
|
|
||||||
|
// Parse exercise records
|
||||||
|
const exList: ExerciseEntry[] = [];
|
||||||
|
const dietList: DietEntry[] = [];
|
||||||
|
all.filter((x) => x.type === 'exercise' || x.type === 'diet').forEach((r) => {
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(r.value);
|
||||||
|
const date = r.recordedAt?.split('T')[0] || '';
|
||||||
|
if (r.type === 'exercise') {
|
||||||
|
exList.push({ type: v.type, duration: v.duration, intensity: v.intensity, caloriesBurned: v.caloriesBurned || v.calories, date });
|
||||||
|
} else {
|
||||||
|
dietList.push({ mealType: v.mealType || v.meal, foods: v.foods || [], totalCalories: v.totalCalories, date });
|
||||||
|
}
|
||||||
|
} catch { /* skip */ }
|
||||||
|
});
|
||||||
|
exList.sort((a, b) => b.date.localeCompare(a.date));
|
||||||
|
dietList.sort((a, b) => b.date.localeCompare(a.date));
|
||||||
|
setExercises(exList.slice(0, 10));
|
||||||
|
setDiets(dietList.slice(0, 10));
|
||||||
|
}).catch(() => {});
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
if (!patient) return <div style={{ padding: 24 }}>加载中...</div>;
|
if (!patient) return <div style={{ padding: 28, color: '#9BA0B4' }}>加载中...</div>;
|
||||||
|
|
||||||
const latestByType: Record<string, HealthRecord> = {};
|
const latestByType: Record<string, HealthRecord> = {};
|
||||||
records.forEach((r) => {
|
records.forEach((r) => {
|
||||||
@@ -44,38 +100,209 @@ export function PatientDetailPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24 }}>
|
<div style={{ padding: 28, maxWidth: 1100 }}>
|
||||||
<Link to="/patients" style={{ fontSize: 13, color: '#1976d2' }}>← 返回患者列表</Link>
|
<Link to="/patients" style={{ fontSize: 13, color: '#4F6EF7', fontWeight: 500 }}>← 返回患者列表</Link>
|
||||||
|
|
||||||
<div style={{ background: '#fff', marginTop: 16, padding: 24, borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}>
|
{/* Patient info card */}
|
||||||
<h2>{patient.name}</h2>
|
<div style={{ background: '#fff', marginTop: 16, padding: 28, borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px 24px', marginTop: 12, fontSize: 14 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 20 }}>
|
||||||
<div>手机号:{patient.phone}</div>
|
<div style={{
|
||||||
<div>性别:{patient.gender || '-'}</div>
|
width: 52, height: 52, borderRadius: 16,
|
||||||
<div>出生日期:{patient.birthday || '-'}</div>
|
background: 'linear-gradient(135deg, #4F6EF7, #6C8AFF)',
|
||||||
<div>身高:{patient.heightCm}cm / 体重:{patient.weightKg}kg</div>
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
<div>病史:{(patient.medicalHistory || []).join('、') || '-'}</div>
|
fontSize: 20, fontWeight: 700, color: '#fff',
|
||||||
<div>支架日期:{patient.stentDate || '-'}</div>
|
}}>
|
||||||
<div>支架类型:{patient.stentType || '-'}</div>
|
{patient.name?.charAt(0) || '?'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>{patient.name}</h2>
|
||||||
|
<p style={{ margin: '4px 0 0', fontSize: 12, color: '#9BA0B4' }}>{patient.phone}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px 32px', fontSize: 13 }}>
|
||||||
|
<InfoRow label="手机号" value={patient.phone} />
|
||||||
|
<InfoRow label="性别" value={patient.gender || '-'} />
|
||||||
|
<InfoRow label="出生日期" value={patient.birthday || '-'} />
|
||||||
|
<InfoRow label="身高/体重" value={`${patient.heightCm}cm / ${patient.weightKg}kg`} />
|
||||||
|
<InfoRow label="病史" value={(patient.medicalHistory || []).join('、') || '-'} />
|
||||||
|
<InfoRow label="支架日期" value={patient.stentDate || '-'} />
|
||||||
|
<InfoRow label="支架类型" value={patient.stentType || '-'} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 style={{ marginTop: 24, marginBottom: 12 }}>最近健康数据</h3>
|
{/* Health vitals */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
|
<h3 style={{ marginTop: 28, marginBottom: 14, fontSize: 17, fontWeight: 700, color: '#1A1D28' }}>生命体征</h3>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 14 }}>
|
||||||
{Object.entries(latestByType).map(([type, record]) => (
|
{Object.entries(latestByType).map(([type, record]) => (
|
||||||
<div key={type} style={{ background: '#fff', padding: 16, borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}>
|
<div key={type} style={{
|
||||||
<div style={{ fontSize: 12, color: '#888' }}>
|
background: '#fff', padding: 20, borderRadius: 16,
|
||||||
{type === 'blood_pressure' ? '血压' : type === 'heart_rate' ? '心率' : type}
|
boxShadow: '0 2px 12px rgba(0,0,0,0.04)', position: 'relative',
|
||||||
|
}}>
|
||||||
|
<div style={{ position: 'absolute', top: 0, left: 0, width: 4, height: '100%', background: typeColors[type] || '#4F6EF7', borderRadius: '4px 0 0 4px' }} />
|
||||||
|
<div style={{ paddingLeft: 8 }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 11, fontWeight: 600, color: typeColors[type] || '#4F6EF7',
|
||||||
|
background: typeBgs[type] || '#EDF0FD', display: 'inline-block',
|
||||||
|
padding: '3px 10px', borderRadius: 6, marginBottom: 10,
|
||||||
|
}}>
|
||||||
|
{typeLabels[type] || type}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 20, fontWeight: 600, marginTop: 4 }}>
|
<div style={{ fontSize: 22, fontWeight: 800, color: '#1A1D28' }}>
|
||||||
{parseValueDisplay(record)} {record.unit}
|
{parseValueDisplay(record)} <span style={{ fontSize: 13, fontWeight: 500, color: '#9BA0B4' }}>{record.unit}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 11, color: '#bbb', marginTop: 4 }}>
|
<div style={{ fontSize: 11, color: '#C0C5D2', marginTop: 6 }}>
|
||||||
{record.recordedAt?.split('T')[0]}
|
{record.recordedAt?.split('T')[0]}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Trend chart */}
|
||||||
|
<ChartSection records={records} />
|
||||||
|
|
||||||
|
{/* Exercise + Diet side by side */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20, marginTop: 28 }}>
|
||||||
|
{/* Exercise */}
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 14px', fontSize: 17, fontWeight: 700, color: '#1A1D28' }}>
|
||||||
|
🏃 运动记录
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 500, color: '#9BA0B4', marginLeft: 8 }}>
|
||||||
|
近7天 · {exercises.reduce((s, e) => s + (e.duration || 0), 0)}分钟
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
{exercises.length === 0 ? (
|
||||||
|
<div style={{ padding: 40, textAlign: 'center', color: '#C0C5D2', background: '#fff', borderRadius: 14, fontSize: 13 }}>暂无运动记录</div>
|
||||||
|
) : (
|
||||||
|
exercises.slice(0, 7).map((e, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px',
|
||||||
|
background: '#fff', borderRadius: 12, marginBottom: 8,
|
||||||
|
boxShadow: '0 1px 6px rgba(0,0,0,0.03)',
|
||||||
|
borderLeft: `4px solid ${e.intensity === '高' || e.intensity === 'high' ? '#EF4444' : e.intensity === '中' || e.intensity === 'moderate' ? '#F59E0B' : '#20C997'}`,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 22 }}>{exerciseIcons[e.type] || '💪'}</span>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, color: '#1A1D28' }}>{e.type}</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#9BA0B4' }}>{e.duration}分钟 · {e.caloriesBurned}kcal</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#C0C5D2' }}>{e.date?.slice(5)}</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Diet */}
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 14px', fontSize: 17, fontWeight: 700, color: '#1A1D28' }}>
|
||||||
|
🥗 饮食记录
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 500, color: '#9BA0B4', marginLeft: 8 }}>
|
||||||
|
近7天 · {diets.reduce((s, d) => s + (d.totalCalories || 0), 0)}kcal
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
{diets.length === 0 ? (
|
||||||
|
<div style={{ padding: 40, textAlign: 'center', color: '#C0C5D2', background: '#fff', borderRadius: 14, fontSize: 13 }}>暂无饮食记录</div>
|
||||||
|
) : (
|
||||||
|
diets.slice(0, 7).map((d, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px',
|
||||||
|
background: '#fff', borderRadius: 12, marginBottom: 8,
|
||||||
|
boxShadow: '0 1px 6px rgba(0,0,0,0.03)',
|
||||||
|
borderLeft: '4px solid #20C997',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 22 }}>{mealIcons[d.mealType] || '🍽️'}</span>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, color: '#1A1D28' }}>
|
||||||
|
{d.foods?.map(f => f.name).join('、') || '-'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#9BA0B4' }}>
|
||||||
|
{mealLabels[d.mealType] || d.mealType} · {d.totalCalories}kcal
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#C0C5D2' }}>{d.date?.slice(5)}</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHART_INDICATORS = [
|
||||||
|
{ type: 'bp_sys', label: '收缩压', color: '#DC4A4A', unit: 'mmHg', source: 'blood_pressure', field: 'systolic' as const },
|
||||||
|
{ type: 'bp_dia', label: '舒张压', color: '#E0558A', unit: 'mmHg', source: 'blood_pressure', field: 'diastolic' as const },
|
||||||
|
{ type: 'heart_rate', label: '心率', color: '#D68B20', unit: 'bpm' },
|
||||||
|
{ type: 'blood_sugar', label: '血糖', color: '#7C5CE7', unit: 'mmol/L' },
|
||||||
|
{ type: 'spo2', label: '血氧', color: '#3B8ED4', unit: '%' },
|
||||||
|
{ type: 'weight', label: '体重', color: '#3DAF86', unit: 'kg' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function ChartSection({ records }: { records: HealthRecord[] }) {
|
||||||
|
const [visible, setVisible] = useState<Set<string>>(new Set(CHART_INDICATORS.map((i) => i.type)));
|
||||||
|
|
||||||
|
const series: SeriesData[] = useMemo(() => {
|
||||||
|
return CHART_INDICATORS
|
||||||
|
.filter((ind) => visible.has(ind.type))
|
||||||
|
.map((ind) => {
|
||||||
|
const source = (ind as Record<string, string>).source || ind.type;
|
||||||
|
const field = (ind as Record<string, string>).field;
|
||||||
|
const raw = records
|
||||||
|
.filter((r) => r.type === source)
|
||||||
|
.sort((a, b) => a.recordedAt.localeCompare(b.recordedAt));
|
||||||
|
|
||||||
|
const data = raw.map((r) => {
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(r.value);
|
||||||
|
const val = field ? (v[field] ?? 0) : (v.value ?? v);
|
||||||
|
return { date: r.recordedAt.split('T')[0], value: Number(val) || 0 };
|
||||||
|
} catch { return { date: r.recordedAt.split('T')[0], value: 0 }; }
|
||||||
|
});
|
||||||
|
return { name: ind.label, color: ind.color, data, unit: ind.unit };
|
||||||
|
});
|
||||||
|
}, [records, visible]);
|
||||||
|
|
||||||
|
const toggle = (type: string) => {
|
||||||
|
const next = new Set(visible);
|
||||||
|
if (next.has(type)) next.delete(type); else next.add(type);
|
||||||
|
setVisible(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasData = series.some((s) => s.data.length > 0);
|
||||||
|
if (!hasData) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 28 }}>
|
||||||
|
<h3 style={{ margin: '0 0 10px', fontSize: 17, fontWeight: 700, color: '#1A1D28' }}>📈 健康趋势</h3>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
|
||||||
|
{CHART_INDICATORS.map((ind) => (
|
||||||
|
<button
|
||||||
|
key={ind.type}
|
||||||
|
onClick={() => toggle(ind.type)}
|
||||||
|
style={{
|
||||||
|
padding: '5px 12px', borderRadius: 16, border: '1.5px solid',
|
||||||
|
borderColor: ind.color, fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
||||||
|
...(visible.has(ind.type)
|
||||||
|
? { background: ind.color, color: '#fff' }
|
||||||
|
: { background: '#fff', color: ind.color }),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ind.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ background: '#fff', padding: 16, borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
|
||||||
|
<MultiLineChart series={series} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}>
|
||||||
|
<span style={{ color: '#9BA0B4' }}>{label}</span>
|
||||||
|
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{value}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { api } from '../../services/api-client';
|
|||||||
|
|
||||||
interface Patient {
|
interface Patient {
|
||||||
id: string; name: string; phone: string; gender: string;
|
id: string; name: string; phone: string; gender: string;
|
||||||
medicalHistory: string[]; stentDate: string;
|
medicalHistory: string[]; stentDate: string; stentType: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PatientListPage() {
|
export function PatientListPage() {
|
||||||
@@ -24,40 +24,52 @@ export function PatientListPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24 }}>
|
<div style={{ padding: 28 }}>
|
||||||
<h2 style={{ marginBottom: 16 }}>患者管理</h2>
|
<h2 style={{ marginBottom: 6, fontSize: 20, fontWeight: 700, color: '#1A1D28' }}>患者管理</h2>
|
||||||
|
<p style={{ marginBottom: 18, fontSize: 13, color: '#9BA0B4' }}>共 {patients.length} 位患者</p>
|
||||||
|
|
||||||
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="搜索姓名或手机号..."
|
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="搜索姓名或手机号..."
|
||||||
style={{ width: 300, padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4, marginBottom: 16 }} />
|
style={{
|
||||||
|
width: 280, padding: '10px 14px', border: '1.5px solid #E1E5ED', borderRadius: 10,
|
||||||
|
fontSize: 13, marginBottom: 18, outline: 'none', boxSizing: 'border-box',
|
||||||
|
transition: 'border-color 0.2s',
|
||||||
|
}}
|
||||||
|
onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
|
||||||
|
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
|
||||||
|
|
||||||
{loading ? <div>加载中...</div> : (
|
{loading ? <div style={{ color: '#9BA0B4' }}>加载中...</div> : (
|
||||||
<div style={{ background: '#fff', borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}>
|
<div style={{ background: '#fff', borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)', overflow: 'hidden' }}>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '2px solid #f0f0f0', textAlign: 'left' }}>
|
<tr style={{ borderBottom: '2px solid #F0F2F5', textAlign: 'left', background: '#F9FAFC' }}>
|
||||||
<th style={{ padding: '12px 16px' }}>姓名</th>
|
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>姓名</th>
|
||||||
<th style={{ padding: '12px 16px' }}>手机号</th>
|
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>手机号</th>
|
||||||
<th style={{ padding: '12px 16px' }}>性别</th>
|
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>性别</th>
|
||||||
<th style={{ padding: '12px 16px' }}>病史</th>
|
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>病史</th>
|
||||||
<th style={{ padding: '12px 16px' }}>支架日期</th>
|
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>支架日期</th>
|
||||||
<th style={{ padding: '12px 16px' }}>操作</th>
|
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>支架类型</th>
|
||||||
|
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filtered.map((p) => (
|
{filtered.map((p) => (
|
||||||
<tr key={p.id} style={{ borderBottom: '1px solid #f5f5f5' }}>
|
<tr key={p.id} style={{ borderBottom: '1px solid #F5F6F9' }}>
|
||||||
<td style={{ padding: '10px 16px' }}>{p.name}</td>
|
<td style={{ padding: '12px 20px', fontWeight: 500 }}>{p.name}</td>
|
||||||
<td style={{ padding: '10px 16px', color: '#888' }}>{p.phone}</td>
|
<td style={{ padding: '12px 20px', color: '#9BA0B4' }}>{p.phone}</td>
|
||||||
<td style={{ padding: '10px 16px' }}>{p.gender || '-'}</td>
|
<td style={{ padding: '12px 20px' }}>{p.gender || '-'}</td>
|
||||||
<td style={{ padding: '10px 16px' }}>{(p.medicalHistory || []).slice(0, 3).join('、') || '-'}</td>
|
<td style={{ padding: '12px 20px', color: '#5A6072' }}>{(p.medicalHistory || []).slice(0, 3).join('、') || '-'}</td>
|
||||||
<td style={{ padding: '10px 16px' }}>{p.stentDate || '-'}</td>
|
<td style={{ padding: '12px 20px', color: '#5A6072' }}>{p.stentDate || '-'}</td>
|
||||||
<td style={{ padding: '10px 16px' }}>
|
<td style={{ padding: '12px 20px', color: '#5A6072' }}>{p.stentType || '-'}</td>
|
||||||
<Link to={`/patients/${p.id}`} style={{ color: '#1976d2', fontSize: 13 }}>查看详情</Link>
|
<td style={{ padding: '12px 20px' }}>
|
||||||
|
<Link to={`/patients/${p.id}`} style={{
|
||||||
|
color: '#4F6EF7', fontSize: 12, fontWeight: 600,
|
||||||
|
padding: '4px 12px', background: '#EDF0FD', borderRadius: 6,
|
||||||
|
}}>查看详情</Link>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<tr><td colSpan={6} style={{ padding: 24, textAlign: 'center', color: '#999' }}>暂无患者数据</td></tr>
|
<tr><td colSpan={7} style={{ padding: 32, textAlign: 'center', color: '#9BA0B4' }}>暂无患者数据</td></tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ interface RawReport {
|
|||||||
imageUrls: string[]; status: string; riskLevel?: string;
|
imageUrls: string[]; status: string; riskLevel?: string;
|
||||||
summary?: string; suggestions?: string;
|
summary?: string; suggestions?: string;
|
||||||
patientName?: string; doctorName?: string;
|
patientName?: string; doctorName?: string;
|
||||||
createdAt: string; completedAt?: string;
|
uploadedAt: string; completedAt?: string;
|
||||||
items?: RawItem[];
|
items?: RawItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ export function ReportDetailPage() {
|
|||||||
<div style={{ marginTop: 8, fontSize: 13, color: '#888' }}>
|
<div style={{ marginTop: 8, fontSize: 13, color: '#888' }}>
|
||||||
患者:{report.patientName || '未知'} |
|
患者:{report.patientName || '未知'} |
|
||||||
分类:{categoryMap[report.category] || report.category} |
|
分类:{categoryMap[report.category] || report.category} |
|
||||||
日期:{report.createdAt?.split('T')[0]}
|
日期:{report.uploadedAt?.split('T')[0]}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span style={{
|
<span style={{
|
||||||
|
|||||||
@@ -2,80 +2,68 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { api } from '../../services/api-client';
|
import { api } from '../../services/api-client';
|
||||||
|
|
||||||
interface ReportItem {
|
|
||||||
id: string; patientId: string; patientName: string;
|
|
||||||
title: string; category: string; status: string; createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RawReportItem {
|
interface RawReportItem {
|
||||||
id: string; patientId: string; patientName?: string;
|
id: string; patientId: string; patientName?: string;
|
||||||
title: string; category: string; status: string; createdAt: string;
|
title: string; category: string; status: string; uploadedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReportListPage() {
|
export function ReportListPage() {
|
||||||
const [reports, setReports] = useState<ReportItem[]>([]);
|
const [reports, setReports] = useState<RawReportItem[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get<RawReportItem[]>('/api/reports').then((r) => {
|
api.get<RawReportItem[]>('/api/reports').then((r) => setReports(r.data)).catch(() => {});
|
||||||
const mapped = r.data.map((rp) => ({
|
|
||||||
id: rp.id,
|
|
||||||
patientId: rp.patientId,
|
|
||||||
patientName: rp.patientName || 'unknown',
|
|
||||||
title: rp.title,
|
|
||||||
category: rp.category,
|
|
||||||
status: rp.status,
|
|
||||||
createdAt: rp.createdAt,
|
|
||||||
}));
|
|
||||||
setReports(mapped);
|
|
||||||
}).catch(() => {});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const statusLabel = (s: string) => {
|
const statusLabel = (s: string) => {
|
||||||
switch (s) {
|
switch (s) {
|
||||||
case 'pending': return { text: '待审核', color: '#f57c00', bg: '#fff3e0' };
|
case 'pending': return { text: '待审核', color: '#F59E0B', bg: '#FFF8E6' };
|
||||||
case 'completed': return { text: '已完成', color: '#2e7d32', bg: '#e8f5e9' };
|
case 'completed': return { text: '已完成', color: '#20C997', bg: '#E6F9F2' };
|
||||||
default: return { text: s, color: '#666', bg: '#f5f5f5' };
|
default: return { text: s, color: '#9BA0B4', bg: '#F5F6F9' };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24 }}>
|
<div style={{ padding: 28 }}>
|
||||||
<h2 style={{ marginBottom: 16 }}>报告审核</h2>
|
<h2 style={{ marginBottom: 6, fontSize: 20, fontWeight: 700, color: '#1A1D28' }}>报告审核</h2>
|
||||||
|
<p style={{ marginBottom: 18, fontSize: 13, color: '#9BA0B4' }}>共 {reports.length} 份报告</p>
|
||||||
|
|
||||||
<div style={{ background: '#fff', borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}>
|
<div style={{ background: '#fff', borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)', overflow: 'hidden' }}>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '2px solid #f0f0f0', textAlign: 'left' }}>
|
<tr style={{ borderBottom: '2px solid #F0F2F5', textAlign: 'left', background: '#F9FAFC' }}>
|
||||||
<th style={{ padding: '12px 16px' }}>患者</th>
|
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>患者</th>
|
||||||
<th style={{ padding: '12px 16px' }}>标题</th>
|
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>标题</th>
|
||||||
<th style={{ padding: '12px 16px' }}>分类</th>
|
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>分类</th>
|
||||||
<th style={{ padding: '12px 16px' }}>状态</th>
|
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>状态</th>
|
||||||
<th style={{ padding: '12px 16px' }}>日期</th>
|
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>日期</th>
|
||||||
<th style={{ padding: '12px 16px' }}>操作</th>
|
<th style={{ padding: '13px 20px', fontWeight: 600, color: '#5A6072' }}>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{reports.map((r) => {
|
{reports.map((r) => {
|
||||||
const s = statusLabel(r.status);
|
const s = statusLabel(r.status);
|
||||||
return (
|
return (
|
||||||
<tr key={r.id} style={{ borderBottom: '1px solid #f5f5f5' }}>
|
<tr key={r.id} style={{ borderBottom: '1px solid #F5F6F9' }}>
|
||||||
<td style={{ padding: '10px 16px' }}>{r.patientName}</td>
|
<td style={{ padding: '12px 20px', fontWeight: 500 }}>{r.patientName || '未知'}</td>
|
||||||
<td style={{ padding: '10px 16px' }}>{r.title}</td>
|
<td style={{ padding: '12px 20px' }}>{r.title}</td>
|
||||||
<td style={{ padding: '10px 16px', color: '#888' }}>{r.category}</td>
|
<td style={{ padding: '12px 20px', color: '#9BA0B4' }}>{r.category}</td>
|
||||||
<td style={{ padding: '10px 16px' }}>
|
<td style={{ padding: '12px 20px' }}>
|
||||||
<span style={{ padding: '2px 8px', borderRadius: 10, fontSize: 11, background: s.bg, color: s.color }}>
|
<span style={{ padding: '4px 12px', borderRadius: 10, fontSize: 11, fontWeight: 500, background: s.bg, color: s.color }}>
|
||||||
{s.text}
|
{s.text}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '10px 16px', color: '#888' }}>{r.createdAt?.split('T')[0]}</td>
|
<td style={{ padding: '12px 20px', color: '#9BA0B4' }}>{r.uploadedAt?.split('T')[0]}</td>
|
||||||
<td style={{ padding: '10px 16px' }}>
|
<td style={{ padding: '12px 20px' }}>
|
||||||
<Link to={`/reports/${r.id}`} style={{ color: '#1976d2', fontSize: 13 }}>查看</Link>
|
<Link to={`/reports/${r.id}`} style={{
|
||||||
|
color: '#4F6EF7', fontSize: 12, fontWeight: 600,
|
||||||
|
padding: '4px 12px', background: '#EDF0FD', borderRadius: 6,
|
||||||
|
}}>查看</Link>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{reports.length === 0 && (
|
{reports.length === 0 && (
|
||||||
<tr><td colSpan={6} style={{ padding: 24, textAlign: 'center', color: '#999' }}>暂无报告</td></tr>
|
<tr><td colSpan={6} style={{ padding: 32, textAlign: 'center', color: '#9BA0B4' }}>暂无报告</td></tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -24,10 +24,8 @@ export function ProfilePage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
await api.put('/api/auth/me', {
|
await api.put('/api/auth/me', {
|
||||||
name: form.name,
|
name: form.name, department: form.department,
|
||||||
department: form.department,
|
title: form.title, introduction: form.introduction,
|
||||||
title: form.title,
|
|
||||||
introduction: form.introduction,
|
|
||||||
});
|
});
|
||||||
updateProfile(form);
|
updateProfile(form);
|
||||||
alert('保存成功');
|
alert('保存成功');
|
||||||
@@ -36,44 +34,71 @@ export function ProfilePage() {
|
|||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: '100%', padding: '10px 14px', border: '1.5px solid #E1E5ED',
|
||||||
|
borderRadius: 10, fontSize: 13, outline: 'none', boxSizing: 'border-box',
|
||||||
|
};
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
display: 'block', fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 5,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24 }}>
|
<div style={{ padding: 28 }}>
|
||||||
<h2 style={{ marginBottom: 16 }}>个人设置</h2>
|
<h2 style={{ marginBottom: 20, fontSize: 20, fontWeight: 700, color: '#1A1D28' }}>个人设置</h2>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} style={{ background: '#fff', padding: 24, borderRadius: 8, maxWidth: 500 }}>
|
<form onSubmit={handleSubmit} style={{ background: '#fff', padding: 28, borderRadius: 16, maxWidth: 520, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
|
||||||
<div style={{ marginBottom: 14 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 24, paddingBottom: 20, borderBottom: '1px solid #F0F2F5' }}>
|
||||||
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}>姓名</label>
|
<div style={{
|
||||||
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })}
|
width: 56, height: 56, borderRadius: 18,
|
||||||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4 }} />
|
background: 'linear-gradient(135deg, #4F6EF7, #6C8AFF)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 22, fontWeight: 700, color: '#fff',
|
||||||
|
}}>
|
||||||
|
{user.name?.charAt(0) || 'D'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 700, color: '#1A1D28' }}>{user.name}</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#9BA0B4', marginTop: 2 }}>{user.department} · {user.title}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: 14 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}>手机号</label>
|
<label style={labelStyle}>姓名</label>
|
||||||
|
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} style={inputStyle}
|
||||||
|
onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
|
||||||
|
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label style={labelStyle}>手机号</label>
|
||||||
<input value={form.phone} disabled
|
<input value={form.phone} disabled
|
||||||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #eee', borderRadius: 4, background: '#f9f9f9' }} />
|
style={{ ...inputStyle, background: '#F5F7FB', color: '#9BA0B4', border: '1.5px solid #EEF0F5' }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: 14 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}>科室</label>
|
<label style={labelStyle}>科室</label>
|
||||||
<input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })}
|
<input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} style={inputStyle}
|
||||||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4 }} />
|
onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
|
||||||
|
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: 14 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}>职称</label>
|
<label style={labelStyle}>职称</label>
|
||||||
<input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })}
|
<input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} style={inputStyle}
|
||||||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4 }} />
|
onFocus={(e) => e.currentTarget.style.borderColor = '#4F6EF7'}
|
||||||
|
onBlur={(e) => e.currentTarget.style.borderColor = '#E1E5ED'} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: 18 }}>
|
<div style={{ marginBottom: 20 }}>
|
||||||
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}>个人简介</label>
|
<label style={labelStyle}>个人简介</label>
|
||||||
<textarea value={form.introduction} onChange={(e) => setForm({ ...form, introduction: e.target.value })} rows={4}
|
<textarea value={form.introduction} onChange={(e) => setForm({ ...form, introduction: e.target.value })} rows={4}
|
||||||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 4, resize: 'vertical' }} />
|
style={{ ...inputStyle, resize: 'vertical', fontFamily: 'inherit' }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" style={{
|
<button type="submit" style={{
|
||||||
padding: '10px 24px', background: '#1976d2', color: '#fff',
|
padding: '11px 28px', background: 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)', color: '#fff',
|
||||||
border: 'none', borderRadius: 4, fontSize: 14,
|
border: 'none', borderRadius: 10, fontSize: 14, fontWeight: 600,
|
||||||
|
boxShadow: '0 4px 16px rgba(79,110,247,0.25)',
|
||||||
}}>
|
}}>
|
||||||
保存
|
保存
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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 /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
180
frontend-patient/package-lock.json
generated
180
frontend-patient/package-lock.json
generated
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "haruite-medical-demo",
|
"name": "health-manager-demo",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "haruite-medical-demo",
|
"name": "health-manager-demo",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@microsoft/signalr": "^10.0.0",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.6",
|
"echarts-for-react": "^3.0.6",
|
||||||
@@ -550,6 +551,19 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@microsoft/signalr": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"abort-controller": "^3.0.0",
|
||||||
|
"eventsource": "^2.0.2",
|
||||||
|
"fetch-cookie": "^2.0.3",
|
||||||
|
"node-fetch": "^2.6.7",
|
||||||
|
"ws": "^7.5.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||||
@@ -1192,6 +1206,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/abort-controller": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"event-target-shim": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
@@ -1648,6 +1674,24 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/event-target-shim": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/eventsource": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -1686,6 +1730,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fetch-cookie": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==",
|
||||||
|
"license": "Unlicense",
|
||||||
|
"dependencies": {
|
||||||
|
"set-cookie-parser": "^2.4.8",
|
||||||
|
"tough-cookie": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
@@ -2323,6 +2377,26 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.44",
|
"version": "2.0.44",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz",
|
||||||
@@ -2459,16 +2533,33 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/psl": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"punycode": "^2.3.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/lupomontero"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/querystringify": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.2.6",
|
"version": "19.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||||
@@ -2528,6 +2619,12 @@
|
|||||||
"react-dom": ">=18"
|
"react-dom": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/requires-port": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
|
||||||
@@ -2640,6 +2737,27 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tough-cookie": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"psl": "^1.1.33",
|
||||||
|
"punycode": "^2.1.1",
|
||||||
|
"universalify": "^0.2.0",
|
||||||
|
"url-parse": "^1.5.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||||
@@ -2717,6 +2835,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/universalify": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
@@ -2758,6 +2885,16 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/url-parse": {
|
||||||
|
"version": "1.5.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
|
||||||
|
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"querystringify": "^2.1.1",
|
||||||
|
"requires-port": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.0.13",
|
"version": "8.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
|
||||||
@@ -2836,6 +2973,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "~0.0.3",
|
||||||
|
"webidl-conversions": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -2862,6 +3015,27 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "7.5.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
|
||||||
|
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": "^5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@microsoft/signalr": "^10.0.0",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.6",
|
"echarts-for-react": "^3.0.6",
|
||||||
|
|||||||
@@ -36,7 +36,51 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Section Title */
|
||||||
|
.section-title {
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title::before {
|
||||||
|
content: '';
|
||||||
|
width: 4px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tag */
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.tag-success { background: var(--color-success-bg); color: #0D8A5E; }
|
||||||
|
.tag-warning { background: var(--color-warning-bg); color: #D67E0B; }
|
||||||
|
.tag-danger { background: var(--color-danger-bg); color: #D53131; }
|
||||||
|
.tag-info { background: var(--color-primary-bg); color: var(--color-primary); }
|
||||||
|
.tag-primary { background: var(--color-primary-bg); color: var(--color-primary); }
|
||||||
|
|
||||||
/* Transitions */
|
/* Transitions */
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.06); }
|
||||||
|
}
|
||||||
|
|
||||||
.page-enter {
|
.page-enter {
|
||||||
animation: slideInRight 0.3s ease-out;
|
animation: slideInRight 0.3s ease-out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,39 @@
|
|||||||
:root {
|
:root {
|
||||||
/* Primary - Medical Blue */
|
/* Primary - Modern Indigo Blue */
|
||||||
--color-primary: #1E6BFF;
|
--color-primary: #4F6EF7;
|
||||||
--color-primary-light: #4D8FFF;
|
--color-primary-light: #6C8CFF;
|
||||||
--color-primary-dark: #1055E0;
|
--color-primary-dark: #3D56D4;
|
||||||
--color-primary-bg: #EBF3FF;
|
--color-primary-bg: #EEF1FE;
|
||||||
--color-primary-gradient: linear-gradient(135deg, #1E6BFF, #4D8FFF);
|
--color-primary-gradient: linear-gradient(135deg, #4F6EF7, #6C8CFF);
|
||||||
|
|
||||||
|
/* Accent */
|
||||||
|
--color-accent-red: #FF6B6B;
|
||||||
|
--color-accent-orange: #FFA94D;
|
||||||
|
--color-accent-green: #20C997;
|
||||||
|
--color-accent-purple: #845EF7;
|
||||||
|
--color-accent-sky: #339AF0;
|
||||||
|
--color-accent-pink: #F06595;
|
||||||
|
|
||||||
/* Status */
|
/* Status */
|
||||||
--color-success: #10B981;
|
--color-success: #20C997;
|
||||||
--color-success-bg: #ECFDF5;
|
--color-success-bg: #E6F9F2;
|
||||||
--color-warning: #F59E0B;
|
--color-warning: #F59E0B;
|
||||||
--color-warning-bg: #FFFBEB;
|
--color-warning-bg: #FFF4E5;
|
||||||
--color-danger: #EF4444;
|
--color-danger: #FF6B6B;
|
||||||
--color-danger-bg: #FEF2F2;
|
--color-danger-bg: #FEE9E9;
|
||||||
|
|
||||||
/* Risk */
|
|
||||||
--color-risk-normal: #10B981;
|
|
||||||
--color-risk-attention: #F59E0B;
|
|
||||||
--color-risk-abnormal: #EF4444;
|
|
||||||
|
|
||||||
/* Neutral */
|
/* Neutral */
|
||||||
--color-white: #FFFFFF;
|
--color-white: #FFFFFF;
|
||||||
--color-bg: #F2F5FA;
|
--color-bg: #F0F4F8;
|
||||||
--color-bg-secondary: #E8ECF2;
|
--color-bg-secondary: #E8ECF2;
|
||||||
--color-border: #E2E8F0;
|
--color-border: #E4E8EE;
|
||||||
--color-border-light: #F0F2F5;
|
--color-border-light: #EEF1F6;
|
||||||
|
--color-divider: #EDF0F5;
|
||||||
|
|
||||||
/* Text */
|
/* Text */
|
||||||
--color-text-primary: #1A1D28;
|
--color-text-primary: #1A1D28;
|
||||||
--color-text-secondary: #6B7280;
|
--color-text-secondary: #5A5F72;
|
||||||
--color-text-tertiary: #9CA3AF;
|
--color-text-tertiary: #9BA0B4;
|
||||||
--color-text-inverse: #FFFFFF;
|
--color-text-inverse: #FFFFFF;
|
||||||
|
|
||||||
/* Spacing */
|
/* Spacing */
|
||||||
@@ -42,20 +46,21 @@
|
|||||||
--spacing-3xl: 32px;
|
--spacing-3xl: 32px;
|
||||||
|
|
||||||
/* Border radius */
|
/* Border radius */
|
||||||
--radius-sm: 8px;
|
--radius-sm: 10px;
|
||||||
--radius-md: 12px;
|
--radius-md: 14px;
|
||||||
--radius-lg: 16px;
|
--radius-lg: 16px;
|
||||||
--radius-xl: 20px;
|
--radius-xl: 20px;
|
||||||
|
--radius-2xl: 24px;
|
||||||
--radius-full: 9999px;
|
--radius-full: 9999px;
|
||||||
|
|
||||||
/* Shadows */
|
/* Shadows */
|
||||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04);
|
--shadow-xs: 0 1px 3px rgba(0,0,0,0.03);
|
||||||
--shadow-md: 0 2px 12px rgba(0, 0, 0, 0.06);
|
--shadow-sm: 0 2px 12px rgba(0,0,0,0.04);
|
||||||
--shadow-lg: 0 4px 24px rgba(0, 0, 0, 0.08);
|
--shadow-md: 0 4px 20px rgba(0,0,0,0.06);
|
||||||
|
--shadow-lg: 0 8px 30px rgba(0,0,0,0.08);
|
||||||
|
|
||||||
/* Font */
|
/* Font */
|
||||||
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
--font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||||
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
|
||||||
--font-size-xs: 11px;
|
--font-size-xs: 11px;
|
||||||
--font-size-sm: 12px;
|
--font-size-sm: 12px;
|
||||||
--font-size-base: 14px;
|
--font-size-base: 14px;
|
||||||
@@ -66,8 +71,8 @@
|
|||||||
--font-size-3xl: 32px;
|
--font-size-3xl: 32px;
|
||||||
|
|
||||||
/* Layout */
|
/* Layout */
|
||||||
--tab-bar-height: 56px;
|
--tab-bar-height: 64px;
|
||||||
--header-height: 48px;
|
--header-height: 50px;
|
||||||
--max-content-width: 414px;
|
--max-content-width: 414px;
|
||||||
|
|
||||||
/* Z-index */
|
/* Z-index */
|
||||||
|
|||||||
63
frontend-patient/src/components/charts/MultiLineChart.tsx
Normal file
63
frontend-patient/src/components/charts/MultiLineChart.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
@@ -2,14 +2,14 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 18px;
|
min-width: 20px;
|
||||||
height: 18px;
|
height: 20px;
|
||||||
padding: 0 5px;
|
padding: 0 6px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: var(--color-danger);
|
background: var(--color-accent-red);
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,5 +18,5 @@
|
|||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--color-danger);
|
background: var(--color-accent-red);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-sm);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
@@ -21,11 +21,13 @@
|
|||||||
.lg { padding: 12px 24px; font-size: var(--font-size-md); }
|
.lg { padding: 12px 24px; font-size: var(--font-size-md); }
|
||||||
|
|
||||||
.primary {
|
.primary {
|
||||||
background: var(--color-primary);
|
background: var(--color-primary-gradient);
|
||||||
color: var(--color-text-inverse);
|
color: var(--color-text-inverse);
|
||||||
|
box-shadow: 0 4px 14px rgba(79,110,247,0.3);
|
||||||
}
|
}
|
||||||
.primary:hover:not(:disabled) {
|
.primary:hover:not(:disabled) {
|
||||||
background: var(--color-primary-dark);
|
box-shadow: 0 6px 20px rgba(79,110,247,0.35);
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary {
|
.secondary {
|
||||||
@@ -48,24 +50,4 @@
|
|||||||
.text {
|
.text {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
padding-left: 4px;
|
|
||||||
padding-right: 4px;
|
|
||||||
}
|
|
||||||
.text:hover:not(:disabled) {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullWidth { width: 100%; }
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border: 2px solid currentColor;
|
|
||||||
border-top-color: transparent;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.6s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
.card {
|
.card {
|
||||||
background: var(--color-white);
|
background: var(--color-white);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-xl);
|
||||||
padding: var(--spacing-lg);
|
padding: 18px;
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clickable {
|
.clickable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
transition: transform 0.15s, box-shadow 0.15s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.clickable:active {
|
.clickable:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.985);
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
font-size: 48px;
|
width: 64px;
|
||||||
margin-bottom: 12px;
|
height: 64px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon svg {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
stroke: var(--color-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
color: var(--color-text-tertiary);
|
color: var(--color-text-tertiary);
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
import styles from './Empty.module.css';
|
import styles from './Empty.module.css';
|
||||||
|
|
||||||
interface EmptyProps {
|
interface EmptyProps {
|
||||||
icon?: string;
|
icon?: React.ReactNode;
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Empty({ icon = '📭', message = '暂无数据' }: EmptyProps) {
|
const DEFAULT_ICON = (
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export function Empty({ icon = DEFAULT_ICON, message = '暂无数据' }: EmptyProps) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.empty}>
|
<div className={styles.empty}>
|
||||||
<span className={styles.icon}>{icon}</span>
|
<div className={styles.icon}>{icon}</div>
|
||||||
<p className={styles.message}>{message}</p>
|
<p className={styles.message}>{message}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,15 +16,16 @@
|
|||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
border: 1.5px solid var(--color-border);
|
border: 1.5px solid var(--color-border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-sm);
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input:focus {
|
.input:focus {
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
background: var(--color-white);
|
background: var(--color-white);
|
||||||
|
box-shadow: 0 0 0 3px rgba(79,110,247,0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input::placeholder {
|
.input::placeholder {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
min-width: 160px;
|
min-width: 160px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.success { background: var(--color-success); }
|
.success { background: var(--color-success); }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
.layout {
|
.layout {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
background: var(--color-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
|
|||||||
@@ -9,13 +9,12 @@ export function AppLayout() {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.layout}>
|
<div className={styles.layout}>
|
||||||
<main className={styles.main}>
|
<main className={styles.main}>
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence>
|
||||||
<motion.div
|
<motion.div
|
||||||
key={location.pathname}
|
key={location.pathname}
|
||||||
initial={{ opacity: 0, y: 8 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0, y: -8 }}
|
transition={{ duration: 0.12 }}
|
||||||
transition={{ duration: 0.15, ease: 'easeOut' }}
|
|
||||||
>
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
max-width: var(--max-content-width);
|
max-width: var(--max-content-width);
|
||||||
height: var(--header-height);
|
height: var(--header-height);
|
||||||
background: var(--color-white);
|
background: var(--color-white);
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-divider);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -33,13 +33,13 @@
|
|||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
margin-left: -8px;
|
margin-left: -8px;
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-sm);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: var(--font-size-md);
|
font-size: 17px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ export function StackLayout() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence>
|
||||||
<motion.div
|
<motion.div
|
||||||
key={location.pathname}
|
key={location.pathname}
|
||||||
initial={{ opacity: 0, x: 40 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0, x: -40 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
transition={{ duration: 0.15 }}
|
||||||
>
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -7,11 +7,12 @@
|
|||||||
max-width: var(--max-content-width);
|
max-width: var(--max-content-width);
|
||||||
height: var(--tab-bar-height);
|
height: var(--tab-bar-height);
|
||||||
background: var(--color-white);
|
background: var(--color-white);
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--color-divider);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
z-index: var(--z-tab-bar);
|
z-index: var(--z-tab-bar);
|
||||||
|
padding: 0 8px;
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,9 +22,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding: var(--spacing-xs) var(--spacing-md);
|
padding: 6px 0;
|
||||||
min-width: 56px;
|
min-width: 56px;
|
||||||
min-height: 44px;
|
|
||||||
color: var(--color-text-tertiary);
|
color: var(--color-text-tertiary);
|
||||||
transition: color 0.2s;
|
transition: color 0.2s;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
@@ -34,33 +34,57 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tabIcon {
|
.tabIcon {
|
||||||
font-size: 22px;
|
width: 44px;
|
||||||
line-height: 1;
|
height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabActive .tabIcon {
|
||||||
|
background: var(--color-primary-bg);
|
||||||
|
transform: translateY(-6px);
|
||||||
|
box-shadow: 0 4px 12px rgba(79,110,247,0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabActive .tabIcon::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -2px;
|
||||||
|
width: 20px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabLabel {
|
.tabLabel {
|
||||||
font-size: var(--font-size-xs);
|
font-size: 10px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
transition: color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabIcon {
|
.tabActive .tabLabel {
|
||||||
position: relative;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -6px;
|
top: -4px;
|
||||||
right: -10px;
|
right: -6px;
|
||||||
min-width: 16px;
|
min-width: 18px;
|
||||||
height: 16px;
|
height: 18px;
|
||||||
padding: 0 4px;
|
padding: 0 5px;
|
||||||
background: #EF4444;
|
background: var(--color-accent-red);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
border: 2px solid #fff;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,51 @@
|
|||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { NAV_ITEMS } from '@/utils/constants';
|
|
||||||
import { useNotificationStore } from '@/stores/notification.store';
|
|
||||||
import styles from './TabBar.module.css';
|
import styles from './TabBar.module.css';
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{
|
||||||
|
path: '/home',
|
||||||
|
label: '首页',
|
||||||
|
svg: (
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||||
|
<polyline points="9 22 9 12 15 12 15 22" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/health',
|
||||||
|
label: '健康',
|
||||||
|
svg: (
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/services',
|
||||||
|
label: '服务',
|
||||||
|
svg: (
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="12" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/profile',
|
||||||
|
label: '我的',
|
||||||
|
svg: (
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="12" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export function TabBar() {
|
export function TabBar() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const unreadCount = useNotificationStore((s) => s.unreadCount);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className={styles.tabBar}>
|
<nav className={styles.tabBar}>
|
||||||
@@ -18,12 +57,7 @@ export function TabBar() {
|
|||||||
className={`${styles.tab} ${isActive ? styles.tabActive : ''}`}
|
className={`${styles.tab} ${isActive ? styles.tabActive : ''}`}
|
||||||
onClick={() => navigate(item.path)}
|
onClick={() => navigate(item.path)}
|
||||||
>
|
>
|
||||||
<span className={styles.tabIcon}>
|
<span className={styles.tabIcon}>{item.svg}</span>
|
||||||
{item.icon}
|
|
||||||
{item.path === '/services' && unreadCount > 0 && (
|
|
||||||
<span className={styles.badge}>{unreadCount > 99 ? '99+' : unreadCount}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className={styles.tabLabel}>{item.label}</span>
|
<span className={styles.tabLabel}>{item.label}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -48,7 +48,11 @@ export function LoginPage() {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<div className={styles.logo}>♥</div>
|
<div className={styles.logo}>
|
||||||
|
<svg width="36" height="36" viewBox="0 0 24 24" fill="var(--color-primary)" stroke="none">
|
||||||
|
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
<h1 className={styles.title}>健康管家</h1>
|
<h1 className={styles.title}>健康管家</h1>
|
||||||
<p className={styles.subtitle}>心脏健康管理平台</p>
|
<p className={styles.subtitle}>心脏健康管理平台</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +1,339 @@
|
|||||||
.tabs { display: flex; gap: 8px; margin-bottom: 16px; }
|
/* Tabs */
|
||||||
.tab { padding: 6px 14px; border-radius: var(--radius-full); font-size: var(--font-size-sm); background: var(--color-bg-secondary); color: var(--color-text-secondary); }
|
.tabs {
|
||||||
.tabActive { background: var(--color-primary); color: var(--color-text-inverse); }
|
display: flex;
|
||||||
.sectionTitle { font-size: var(--font-size-base); font-weight: 600; margin: 16px 0 8px; }
|
gap: 8px;
|
||||||
.recCard { margin-bottom: 8px; }
|
margin-bottom: 16px;
|
||||||
.recHeader { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; font-weight: 600; font-size: var(--font-size-sm); }
|
}
|
||||||
.suitBadge { font-size: var(--font-size-xs); padding: 2px 8px; border-radius: var(--radius-sm); }
|
|
||||||
.suitYes { background: var(--color-success-bg); color: var(--color-success); }
|
.tab {
|
||||||
.suitNo { background: var(--color-danger-bg); color: var(--color-danger); }
|
flex: 1;
|
||||||
.notSuitable { opacity: 0.5; }
|
padding: 12px;
|
||||||
.recMeta { font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
|
border-radius: 14px;
|
||||||
.recDesc { font-size: var(--font-size-xs); color: var(--color-text-secondary); margin: 6px 0; }
|
background: #F5F6F9;
|
||||||
.foodTags { display: flex; gap: 6px; flex-wrap: wrap; }
|
border: none;
|
||||||
.foodTag { padding: 2px 8px; font-size: var(--font-size-xs); background: var(--color-primary-bg); color: var(--color-primary); border-radius: var(--radius-sm); }
|
font-size: 15px;
|
||||||
.addCard { margin-bottom: 12px; display: flex; flex-direction: column; gap: 10px; }
|
font-weight: 600;
|
||||||
.addRow { display: flex; gap: 8px; align-items: center; }
|
color: #6B7280;
|
||||||
.select { padding: 10px 12px; border: 1.5px solid var(--color-border); border-radius: var(--radius-md); font-size: var(--font-size-sm); background: var(--color-bg); outline: none; }
|
cursor: pointer;
|
||||||
.intensityRow { display: flex; gap: 8px; }
|
transition: all 0.25s ease;
|
||||||
.intensityBtn { flex: 1; padding: 6px; font-size: var(--font-size-xs); background: var(--color-bg); border-radius: var(--radius-md); }
|
display: flex;
|
||||||
.intensityActive { background: var(--color-primary-bg); color: var(--color-primary); }
|
align-items: center;
|
||||||
.logCard { margin-bottom: 6px; }
|
justify-content: center;
|
||||||
.logDate { font-size: var(--font-size-xs); color: var(--color-text-tertiary); margin-top: 2px; }
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabIcon { font-size: 18px; }
|
||||||
|
|
||||||
|
.tabActive {
|
||||||
|
background: linear-gradient(135deg, #4F6EF7, #6C8AFF);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 16px rgba(79,110,247,0.3);
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Summary cards */
|
||||||
|
.summaryCard,
|
||||||
|
.summaryCardDiet {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(135deg, #f0f4ff 0%, #fff 60%);
|
||||||
|
box-shadow: 0 2px 16px rgba(0,0,0,0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryCardDiet {
|
||||||
|
background: linear-gradient(135deg, #f0faf6 0%, #fff 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryBg {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryContent {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryLabel { font-size: 13px; color: #6B7280; margin-bottom: 4px; }
|
||||||
|
.summaryValue { font-size: 32px; font-weight: 800; color: #1A1D28; }
|
||||||
|
.summaryUnit { font-size: 14px; font-weight: 500; color: #9BA0B4; }
|
||||||
|
.summaryHint { font-size: 12px; color: #9BA0B4; margin-top: 4px; }
|
||||||
|
|
||||||
|
.summaryRight {
|
||||||
|
animation: pulse 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.05); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add card */
|
||||||
|
.addCard {
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 100px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addGrid3 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 80px 100px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1.5px solid #E1E5ED;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: #fff;
|
||||||
|
color: #1A1D28;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select:focus { border-color: #4F6EF7; }
|
||||||
|
|
||||||
|
.intensityRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intensityBtn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 0;
|
||||||
|
border: 1.5px solid #E1E5ED;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6B7280;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intensityActive {
|
||||||
|
border-color: #4F6EF7;
|
||||||
|
background: #EEF2FF;
|
||||||
|
color: #4F6EF7;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mealTabRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mealTab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 4px;
|
||||||
|
border: 1.5px solid #E1E5ED;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6B7280;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mealTabActive {
|
||||||
|
border-color: #20C997;
|
||||||
|
background: #E6F9F2;
|
||||||
|
color: #20C997;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Day groups */
|
||||||
|
.dayGroup {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dayLabel {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #9BA0B4;
|
||||||
|
padding: 0 4px 8px;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Log cards */
|
||||||
|
.logCard {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
box-shadow: 0 1px 8px rgba(0,0,0,0.03);
|
||||||
|
border-left: 4px solid #E1E5ED;
|
||||||
|
transition: transform 0.15s, box-shadow 0.15s;
|
||||||
|
animation: fadeInUp 0.3s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logCard:active { transform: scale(0.98); }
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.intensityLow { border-left-color: #20C997; }
|
||||||
|
.intensityModerate { border-left-color: #F59E0B; }
|
||||||
|
.intensityHigh { border-left-color: #EF4444; }
|
||||||
|
.mealBreakfast { border-left-color: #F59E0B; }
|
||||||
|
.mealLunch { border-left-color: #4F6EF7; }
|
||||||
|
.mealDinner { border-left-color: #845EF7; }
|
||||||
|
.mealSnack { border-left-color: #20C997; }
|
||||||
|
|
||||||
|
.logIcon { font-size: 28px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.logInfo { flex: 1; min-width: 0; }
|
||||||
|
.logTitle { font-size: 15px; font-weight: 600; color: #1A1D28; }
|
||||||
|
.logMeta { font-size: 12px; color: #9BA0B4; margin-top: 2px; }
|
||||||
|
|
||||||
|
.intensityTag {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: #F5F6F9;
|
||||||
|
color: #6B7280;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delBtn {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #C0C5D2;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.15s;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delBtn:hover {
|
||||||
|
color: #EF4444;
|
||||||
|
background: #FEE9E9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delBtn:active {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section titles */
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1A1D28;
|
||||||
|
margin: 24px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recommendation cards grid */
|
||||||
|
.recGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recCard {
|
||||||
|
position: relative;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0,0,0,0.04);
|
||||||
|
text-align: center;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recCard:hover { transform: translateY(-2px); }
|
||||||
|
.recNo { opacity: 0.5; }
|
||||||
|
|
||||||
|
.recEmoji { font-size: 32px; margin-bottom: 6px; }
|
||||||
|
.recName { font-size: 14px; font-weight: 600; color: #1A1D28; margin-bottom: 2px; }
|
||||||
|
.recMeta { font-size: 11px; color: #9BA0B4; margin-bottom: 8px; }
|
||||||
|
|
||||||
|
.recBadge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recGood { background: #E6F9F2; color: #20C997; }
|
||||||
|
.recBad { background: #FEE9E9; color: #EF4444; }
|
||||||
|
|
||||||
|
/* Diet recommendation cards */
|
||||||
|
.dietRecCard {
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0,0,0,0.04);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dietRecCard:hover { transform: translateY(-2px); }
|
||||||
|
|
||||||
|
.dietRecHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dietRecIcon { font-size: 16px; }
|
||||||
|
.dietRecTitle { font-size: 14px; font-weight: 600; color: #1A1D28; }
|
||||||
|
|
||||||
|
.dietRecDesc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6B7280;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dietRecFoods { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
|
||||||
|
.dietFoodTag {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #E6F9F2;
|
||||||
|
color: #20C997;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 360px) {
|
||||||
|
.recGrid { grid-template-columns: 1fr; }
|
||||||
|
.addGrid3 { grid-template-columns: 1fr 1fr; }
|
||||||
|
.addGrid { grid-template-columns: 1fr 80px; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
import { PageHeader } from '@/components/layout/PageHeader';
|
||||||
import { Card } from '@/components/common/Card';
|
import { Card } from '@/components/common/Card';
|
||||||
import { Button } from '@/components/common/Button';
|
import { Button } from '@/components/common/Button';
|
||||||
@@ -10,144 +10,280 @@ import type { ExerciseRecord, DietRecord } from '@/types';
|
|||||||
import { formatDate } from '@/utils/format';
|
import { formatDate } from '@/utils/format';
|
||||||
import styles from './ExerciseDietPage.module.css';
|
import styles from './ExerciseDietPage.module.css';
|
||||||
|
|
||||||
|
const EXERCISE_TYPES = ['散步', '慢跑', '太极拳', '游泳', '骑自行车', '八段锦', '瑜伽', '广场舞'];
|
||||||
|
const INTENSITIES = [
|
||||||
|
{ key: 'low' as const, label: '低强度', emoji: '🟢' },
|
||||||
|
{ key: 'moderate' as const, label: '中强度', emoji: '🟡' },
|
||||||
|
{ key: 'high' as const, label: '高强度', emoji: '🔴' },
|
||||||
|
];
|
||||||
|
const MEAL_TYPES = [
|
||||||
|
{ key: 'breakfast' as const, label: '早餐', icon: '🌅' },
|
||||||
|
{ key: 'lunch' as const, label: '午餐', icon: '☀️' },
|
||||||
|
{ key: 'dinner' as const, label: '晚餐', icon: '🌙' },
|
||||||
|
{ key: 'snack' as const, label: '加餐', icon: '🍎' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function groupByDate<T extends { date: string }>(items: T[]): [string, T[]][] {
|
||||||
|
const map = new Map<string, T[]>();
|
||||||
|
items.forEach((item) => {
|
||||||
|
const list = map.get(item.date) || [];
|
||||||
|
list.push(item);
|
||||||
|
map.set(item.date, list);
|
||||||
|
});
|
||||||
|
return [...map.entries()].sort((a, b) => b[0].localeCompare(a[0]));
|
||||||
|
}
|
||||||
|
|
||||||
export function ExerciseDietPage() {
|
export function ExerciseDietPage() {
|
||||||
const [subTab, setSubTab] = useState<'recommend' | 'exercise' | 'diet'>('recommend');
|
const [tab, setTab] = useState<'exercise' | 'diet'>('exercise');
|
||||||
const [exercises, setExercises] = useState<ExerciseRecord[]>([]);
|
const [exercises, setExercises] = useState<ExerciseRecord[]>([]);
|
||||||
const [diets, setDiets] = useState<DietRecord[]>([]);
|
const [diets, setDiets] = useState<DietRecord[]>([]);
|
||||||
|
|
||||||
|
// exercise form
|
||||||
const [exType, setExType] = useState('散步');
|
const [exType, setExType] = useState('散步');
|
||||||
const [exDuration, setExDuration] = useState('30');
|
const [exDuration, setExDuration] = useState('30');
|
||||||
const [exIntensity, setExIntensity] = useState<'low' | 'moderate' | 'high'>('low');
|
const [exIntensity, setExIntensity] = useState<'low' | 'moderate' | 'high'>('low');
|
||||||
|
|
||||||
|
// diet form
|
||||||
const [mealType, setMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast');
|
const [mealType, setMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast');
|
||||||
const [foodName, setFoodName] = useState('');
|
const [foodName, setFoodName] = useState('');
|
||||||
|
const [foodAmount, setFoodAmount] = useState('1份');
|
||||||
const [foodKcal, setFoodKcal] = useState('');
|
const [foodKcal, setFoodKcal] = useState('');
|
||||||
|
|
||||||
const recommendations = exerciseDietService.getExerciseRecommendations();
|
const exRecommendations = exerciseDietService.getExerciseRecommendations();
|
||||||
const dietRecommendations = exerciseDietService.getDietRecommendations();
|
const dietRecommendations = exerciseDietService.getDietRecommendations();
|
||||||
|
|
||||||
|
const groupedExercises = useMemo(() => groupByDate(exercises).slice(0, 7), [exercises]);
|
||||||
|
const groupedDiets = useMemo(() => groupByDate(diets).slice(0, 7), [diets]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
exerciseDietService.getExerciseLogs().then(setExercises);
|
exerciseDietService.getExerciseLogs().then(setExercises);
|
||||||
exerciseDietService.getDietLogs().then(setDiets);
|
exerciseDietService.getDietLogs().then(setDiets);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const deleteExercise = async (id: string) => {
|
||||||
|
await exerciseDietService.deleteExerciseLog(id);
|
||||||
|
toast('已删除');
|
||||||
|
exerciseDietService.getExerciseLogs().then(setExercises);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteDiet = async (id: string) => {
|
||||||
|
await exerciseDietService.deleteDietLog(id);
|
||||||
|
toast('已删除');
|
||||||
|
exerciseDietService.getDietLogs().then(setDiets);
|
||||||
|
};
|
||||||
|
|
||||||
const addExercise = async () => {
|
const addExercise = async () => {
|
||||||
if (!exDuration) return;
|
if (!exDuration) return;
|
||||||
await exerciseDietService.addExerciseLog({
|
await exerciseDietService.addExerciseLog({
|
||||||
type: exType, duration: parseInt(exDuration), intensity: exIntensity,
|
type: exType, duration: parseInt(exDuration), intensity: exIntensity,
|
||||||
caloriesBurned: parseInt(exDuration) * 4, date: new Date().toISOString().slice(0, 10),
|
caloriesBurned: parseInt(exDuration) * ({ low: 4, moderate: 7, high: 11 }[exIntensity] || 4),
|
||||||
|
date: new Date().toISOString().slice(0, 10),
|
||||||
});
|
});
|
||||||
toast('记录成功');
|
toast('运动记录成功');
|
||||||
exerciseDietService.getExerciseLogs().then(setExercises);
|
exerciseDietService.getExerciseLogs().then(setExercises);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addDiet = async () => {
|
const addDiet = async () => {
|
||||||
if (!foodName || !foodKcal) { toast('请填写食物信息', 'error'); return; }
|
if (!foodName || !foodKcal) { toast('请填写食物名称和热量', 'error'); return; }
|
||||||
await exerciseDietService.addDietLog({
|
await exerciseDietService.addDietLog({
|
||||||
mealType, foods: [{ name: foodName, amount: '1份', calories: parseInt(foodKcal) }],
|
mealType, foods: [{ name: foodName, amount: foodAmount, calories: parseInt(foodKcal) }],
|
||||||
totalCalories: parseInt(foodKcal), date: new Date().toISOString().slice(0, 10),
|
totalCalories: parseInt(foodKcal), date: new Date().toISOString().slice(0, 10),
|
||||||
});
|
});
|
||||||
toast('记录成功');
|
setFoodName(''); setFoodAmount('1份'); setFoodKcal('');
|
||||||
|
toast('饮食记录成功');
|
||||||
exerciseDietService.getDietLogs().then(setDiets);
|
exerciseDietService.getDietLogs().then(setDiets);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const todayExKcal = exercises.filter(e => e.date === new Date().toISOString().slice(0, 10)).reduce((s, e) => s + (e.caloriesBurned || 0), 0);
|
||||||
|
const todayDietKcal = diets.filter(d => d.date === new Date().toISOString().slice(0, 10)).reduce((s, d) => s + (d.totalCalories || 0), 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page--no-tab">
|
<div className="page--no-tab">
|
||||||
<PageHeader title="运动饮食" />
|
<PageHeader title="运动饮食" />
|
||||||
|
|
||||||
|
{/* Tab switch */}
|
||||||
<div className={styles.tabs}>
|
<div className={styles.tabs}>
|
||||||
{[
|
<button className={`${styles.tab} ${tab === 'exercise' ? styles.tabActive : ''}`} onClick={() => setTab('exercise')}>
|
||||||
{ key: 'recommend', label: '推荐' },
|
<span className={styles.tabIcon}>🏃</span> 运动
|
||||||
{ key: 'exercise', label: '运动' },
|
</button>
|
||||||
{ key: 'diet', label: '饮食' },
|
<button className={`${styles.tab} ${tab === 'diet' ? styles.tabActive : ''}`} onClick={() => setTab('diet')}>
|
||||||
].map((t) => (
|
<span className={styles.tabIcon}>🥗</span> 饮食
|
||||||
<button key={t.key} className={`${styles.tab} ${subTab === t.key ? styles.tabActive : ''}`} onClick={() => setSubTab(t.key as typeof subTab)}>
|
|
||||||
{t.label}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{subTab === 'recommend' && (
|
{/* ============ EXERCISE TAB ============ */}
|
||||||
<div>
|
{tab === 'exercise' && (
|
||||||
<h3 className={styles.sectionTitle}>运动推荐</h3>
|
<>
|
||||||
{recommendations.map((r, i) => (
|
{/* Today summary card */}
|
||||||
<Card key={i} className={`${styles.recCard} ${!r.suitable ? styles.notSuitable : ''}`}>
|
<div className={styles.summaryCard}>
|
||||||
<div className={styles.recHeader}>
|
<svg className={styles.summaryBg} viewBox="0 0 400 120" preserveAspectRatio="none">
|
||||||
<span>{r.icon} {r.name}</span>
|
<ellipse cx="350" cy="10" rx="180" ry="140" fill="rgba(79,110,247,0.06)" />
|
||||||
<span className={`${styles.suitBadge} ${r.suitable ? styles.suitYes : styles.suitNo}`}>
|
<ellipse cx="50" cy="100" rx="120" ry="60" fill="rgba(79,110,247,0.04)" />
|
||||||
{r.suitable ? '适合' : '不适合'}
|
</svg>
|
||||||
</span>
|
<div className={styles.summaryContent}>
|
||||||
|
<div className={styles.summaryLeft}>
|
||||||
|
<div className={styles.summaryLabel}>今日运动消耗</div>
|
||||||
|
<div className={styles.summaryValue}>{todayExKcal} <span className={styles.summaryUnit}>kcal</span></div>
|
||||||
|
<div className={styles.summaryHint}>相当于慢跑 {Math.round(todayExKcal / 8)} 分钟</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.summaryRight}>
|
||||||
|
<svg width="64" height="64" viewBox="0 0 64 64" fill="none">
|
||||||
|
<circle cx="32" cy="32" r="28" stroke="#E8ECF4" strokeWidth="6" />
|
||||||
|
<circle cx="32" cy="32" r="28" stroke="url(#exGrad)" strokeWidth="6" strokeLinecap="round"
|
||||||
|
strokeDasharray={`${Math.min(todayExKcal / 3, 175)} 176`} transform="rotate(-90 32 32)" />
|
||||||
|
<defs><linearGradient id="exGrad"><stop stopColor="#4F6EF7"/><stop offset="1" stopColor="#6C8AFF"/></linearGradient></defs>
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.recMeta}>{r.duration} · {r.frequency} · {r.intensity}强度</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
<h3 className={styles.sectionTitle}>饮食推荐</h3>
|
|
||||||
{dietRecommendations.slice(0, 3).map((d, i) => (
|
|
||||||
<Card key={i} className={styles.recCard}>
|
|
||||||
<div className={styles.recHeader}><span>🍽️ {d.title}</span></div>
|
|
||||||
<p className={styles.recDesc}>{d.description}</p>
|
|
||||||
<div className={styles.foodTags}>
|
|
||||||
{d.recommendedFoods.slice(0, 3).map((f, j) => (
|
|
||||||
<span key={j} className={styles.foodTag}>{f}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{subTab === 'exercise' && (
|
{/* Add record */}
|
||||||
<div>
|
|
||||||
<Card className={styles.addCard}>
|
<Card className={styles.addCard}>
|
||||||
<div className={styles.addRow}>
|
<div className={styles.addGrid}>
|
||||||
<select className={styles.select} value={exType} onChange={(e) => setExType(e.target.value)}>
|
<select className={styles.select} value={exType} onChange={(e) => setExType(e.target.value)}>
|
||||||
{['散步', '慢跑', '太极拳', '游泳', '骑自行车', '八段锦'].map((t) => (
|
{EXERCISE_TYPES.map((t) => (<option key={t}>{t}</option>))}
|
||||||
<option key={t}>{t}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
<Input value={exDuration} onChange={(e) => setExDuration(e.target.value)} type="number" placeholder="分钟" />
|
<Input value={exDuration} onChange={(e) => setExDuration(e.target.value)} type="number" placeholder="分钟" />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.intensityRow}>
|
<div className={styles.intensityRow}>
|
||||||
{['low', 'moderate', 'high'].map((i) => (
|
{INTENSITIES.map((i) => (
|
||||||
<button key={i} className={`${styles.intensityBtn} ${exIntensity === i ? styles.intensityActive : ''}`} onClick={() => setExIntensity(i as typeof exIntensity)}>
|
<button key={i.key} className={`${styles.intensityBtn} ${exIntensity === i.key ? styles.intensityActive : ''}`}
|
||||||
{{ low: '低强度', moderate: '中强度', high: '高强度' }[i]}
|
onClick={() => setExIntensity(i.key)}>
|
||||||
|
{i.emoji} {i.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={addExercise}>记录运动</Button>
|
<Button size="sm" onClick={addExercise}>记录运动</Button>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{exercises.length === 0 ? <Empty message="暂无运动记录" /> : exercises.slice(0, 10).map((e) => (
|
{/* Recent records */}
|
||||||
<Card key={e.id} className={styles.logCard}>
|
{groupedExercises.length === 0 ? (
|
||||||
<div>{e.type} · {e.duration}分钟 · {e.caloriesBurned}kcal</div>
|
<Empty message="暂无运动记录,开始记录吧" />
|
||||||
<div className={styles.logDate}>{formatDate(e.date, 'MM-DD')}</div>
|
) : (
|
||||||
</Card>
|
groupedExercises.map(([date, items]) => (
|
||||||
|
<div key={date} className={styles.dayGroup}>
|
||||||
|
<div className={styles.dayLabel}>{formatDate(date, 'MM月DD日')} · {items.reduce((s, e) => s + (e.duration || 0), 0)}分钟</div>
|
||||||
|
{items.map((e, i) => (
|
||||||
|
<div key={i} className={`${styles.logCard} ${styles[`intensity${e.intensity?.[0]?.toUpperCase()}${e.intensity?.slice(1)}`] || ''}`}>
|
||||||
|
<div className={styles.logIcon}>{e.type === '散步' ? '🚶' : e.type === '慢跑' ? '🏃' : e.type === '太极拳' ? '🤸' : e.type === '游泳' ? '🏊' : e.type === '骑自行车' ? '🚴' : e.type === '八段锦' ? '🧘' : '💪'}</div>
|
||||||
|
<div className={styles.logInfo}>
|
||||||
|
<div className={styles.logTitle}>{e.type}</div>
|
||||||
|
<div className={styles.logMeta}>{e.duration}分钟 · {e.caloriesBurned}kcal</div>
|
||||||
|
</div>
|
||||||
|
<span className={styles.intensityTag}>
|
||||||
|
{{ low: '低', moderate: '中', high: '高' }[e.intensity || 'low']}
|
||||||
|
</span>
|
||||||
|
<button className={styles.delBtn} onClick={() => deleteExercise(e.id)} title="删除">×</button>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{subTab === 'diet' && (
|
{/* Recommendations */}
|
||||||
<div>
|
<h3 className={styles.sectionTitle}>适合您的运动</h3>
|
||||||
|
<div className={styles.recGrid}>
|
||||||
|
{exRecommendations.slice(0, 4).map((r, i) => (
|
||||||
|
<div key={i} className={`${styles.recCard} ${!r.suitable ? styles.recNo : ''}`}>
|
||||||
|
<div className={styles.recEmoji}>{r.name === '散步' ? '🚶' : r.name === '太极拳' ? '🤸' : r.name === '慢跑' ? '🏃' : r.name === '游泳' ? '🏊' : r.name === '骑自行车' ? '🚴' : r.name === '八段锦' ? '🧘' : '🏋️'}</div>
|
||||||
|
<div className={styles.recName}>{r.name}</div>
|
||||||
|
<div className={styles.recMeta}>{r.duration} · {r.frequency}</div>
|
||||||
|
<span className={`${styles.recBadge} ${r.suitable ? styles.recGood : styles.recBad}`}>
|
||||||
|
{r.suitable ? '适合' : '避免'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ============ DIET TAB ============ */}
|
||||||
|
{tab === 'diet' && (
|
||||||
|
<>
|
||||||
|
{/* Today summary card */}
|
||||||
|
<div className={styles.summaryCardDiet}>
|
||||||
|
<svg className={styles.summaryBg} viewBox="0 0 400 120" preserveAspectRatio="none">
|
||||||
|
<ellipse cx="350" cy="10" rx="180" ry="140" fill="rgba(32,201,151,0.06)" />
|
||||||
|
<ellipse cx="50" cy="100" rx="120" ry="60" fill="rgba(32,201,151,0.04)" />
|
||||||
|
</svg>
|
||||||
|
<div className={styles.summaryContent}>
|
||||||
|
<div className={styles.summaryLeft}>
|
||||||
|
<div className={styles.summaryLabel}>今日摄入热量</div>
|
||||||
|
<div className={styles.summaryValue}>{todayDietKcal} <span className={styles.summaryUnit}>kcal</span></div>
|
||||||
|
<div className={styles.summaryHint}>
|
||||||
|
{todayDietKcal < 1200 ? '摄入偏低,注意营养' : todayDietKcal < 2200 ? '摄入适中,继续保持' : '摄入偏高,注意控制'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.summaryRight}>
|
||||||
|
<svg width="64" height="64" viewBox="0 0 64 64">
|
||||||
|
<path d="M16 24c0-4 3-8 8-8h16c5 0 8 4 8 8v8c0 8-3 16-16 16s-16-8-16-16V24z" fill="#20C997" opacity="0.15" />
|
||||||
|
<circle cx="40" cy="44" r="14" fill="#20C997" opacity="0.1" />
|
||||||
|
<text x="32" y="40" textAnchor="middle" fontSize="18">🍽️</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add record */}
|
||||||
<Card className={styles.addCard}>
|
<Card className={styles.addCard}>
|
||||||
<div className={styles.addRow}>
|
<div className={styles.mealTabRow}>
|
||||||
<select className={styles.select} value={mealType} onChange={(e) => setMealType(e.target.value as typeof mealType)}>
|
{MEAL_TYPES.map((m) => (
|
||||||
<option value="breakfast">早餐</option>
|
<button key={m.key} className={`${styles.mealTab} ${mealType === m.key ? styles.mealTabActive : ''}`}
|
||||||
<option value="lunch">午餐</option>
|
onClick={() => setMealType(m.key)}>
|
||||||
<option value="dinner">晚餐</option>
|
{m.icon} {m.label}
|
||||||
<option value="snack">加餐</option>
|
</button>
|
||||||
</select>
|
))}
|
||||||
<Input value={foodName} onChange={(e) => setFoodName(e.target.value)} placeholder="食物名" />
|
</div>
|
||||||
<Input value={foodKcal} onChange={(e) => setFoodKcal(e.target.value)} type="number" placeholder="kcal" />
|
<div className={styles.addGrid3}>
|
||||||
|
<Input value={foodName} onChange={(e) => setFoodName(e.target.value)} placeholder="食物名称" />
|
||||||
|
<Input value={foodAmount} onChange={(e) => setFoodAmount(e.target.value)} placeholder="份量" />
|
||||||
|
<Input value={foodKcal} onChange={(e) => setFoodKcal(e.target.value)} type="number" placeholder="热量(kcal)" />
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={addDiet}>记录饮食</Button>
|
<Button size="sm" onClick={addDiet}>记录饮食</Button>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{diets.length === 0 ? <Empty message="暂无饮食记录" /> : diets.slice(0, 10).map((d) => (
|
{/* Recent records */}
|
||||||
<Card key={d.id} className={styles.logCard}>
|
{groupedDiets.length === 0 ? (
|
||||||
<div>{d.mealType === 'breakfast' ? '🌅' : d.mealType === 'lunch' ? '🌞' : d.mealType === 'dinner' ? '🌙' : '🍪'} {d.foods.map((f) => f.name).join(', ')}</div>
|
<Empty message="暂无饮食记录,开始记录吧" />
|
||||||
<div className={styles.logDate}>{d.totalCalories}kcal · {formatDate(d.date, 'MM-DD')}</div>
|
) : (
|
||||||
</Card>
|
groupedDiets.map(([date, items]) => (
|
||||||
|
<div key={date} className={styles.dayGroup}>
|
||||||
|
<div className={styles.dayLabel}>{formatDate(date, 'MM月DD日')} · {items.reduce((s, d) => s + (d.totalCalories || 0), 0)}kcal</div>
|
||||||
|
{items.map((d, i) => (
|
||||||
|
<div key={i} className={`${styles.logCard} ${styles[`meal${d.mealType?.[0]?.toUpperCase()}${d.mealType?.slice(1)}`] || ''}`}>
|
||||||
|
<div className={styles.logIcon}>{MEAL_TYPES.find(m => m.key === d.mealType)?.icon || '🍽️'}</div>
|
||||||
|
<div className={styles.logInfo}>
|
||||||
|
<div className={styles.logTitle}>{d.foods?.map(f => f.name).join('、')}</div>
|
||||||
|
<div className={styles.logMeta}>
|
||||||
|
{MEAL_TYPES.find(m => m.key === d.mealType)?.label} · {d.totalCalories}kcal · {d.foods?.map(f => f.amount).join('、')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className={styles.delBtn} onClick={() => deleteDiet(d.id)} title="删除">×</button>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recommendations */}
|
||||||
|
<h3 className={styles.sectionTitle}>饮食建议</h3>
|
||||||
|
<div className={styles.recGrid}>
|
||||||
|
{dietRecommendations.slice(0, 4).map((d, i) => (
|
||||||
|
<div key={i} className={styles.dietRecCard}>
|
||||||
|
<div className={styles.dietRecHeader}>
|
||||||
|
<span className={styles.dietRecIcon}>💡</span>
|
||||||
|
<span className={styles.dietRecTitle}>{d.title}</span>
|
||||||
|
</div>
|
||||||
|
<p className={styles.dietRecDesc}>{d.description.slice(0, 40)}...</p>
|
||||||
|
<div className={styles.dietRecFoods}>
|
||||||
|
{d.recommendedFoods.slice(0, 3).map((f, j) => (
|
||||||
|
<span key={j} className={styles.dietFoodTag}>{f}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
|
|||||||
@@ -1,60 +1,87 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
import { PageHeader } from '@/components/layout/PageHeader';
|
||||||
import { Card } from '@/components/common/Card';
|
import { Card } from '@/components/common/Card';
|
||||||
import type { CalendarDay } from '@/types';
|
import type { CalendarDay } from '@/types';
|
||||||
|
import { api } from '@/services/api-client';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import styles from './HealthCalendarPage.module.css';
|
import styles from './HealthCalendarPage.module.css';
|
||||||
|
|
||||||
const MARKER_COLORS: Record<string, string> = {
|
interface MedRecord { medicationId: string; timeSlot: string; takenAt?: string | null; isTaken: boolean; }
|
||||||
medication_taken: '#10B981',
|
interface HealthRecord { id: string; type: string; recordedAt: string; }
|
||||||
medication_missed: '#EF4444',
|
interface Medication { id: string; drugName: string; timeSlots: string[]; status: string; startDate: string; endDate?: string | null; }
|
||||||
follow_up: '#F59E0B',
|
|
||||||
measurement: '#2563EB',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function HealthCalendarPage() {
|
export function HealthCalendarPage() {
|
||||||
const [currentDate, setCurrentDate] = useState(dayjs());
|
const [currentDate, setCurrentDate] = useState(dayjs());
|
||||||
|
const [medRecords, setMedRecords] = useState<MedRecord[]>([]);
|
||||||
|
const [medications, setMedications] = useState<Medication[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Fetch all active medications and their records
|
||||||
|
api.get<Medication[]>('/api/medications').then((res) => {
|
||||||
|
const active = res.data.filter((m) => m.status === 'active');
|
||||||
|
setMedications(active);
|
||||||
|
// Fetch records for each active medication
|
||||||
|
Promise.all(active.map((m) =>
|
||||||
|
api.get<MedRecord[]>(`/api/medications/${m.id}/records`)
|
||||||
|
.then((r) => r.data)
|
||||||
|
.catch(() => [] as MedRecord[])
|
||||||
|
)).then((all) => setMedRecords(all.flat()));
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const calendarDays = useMemo(() => {
|
const calendarDays = useMemo(() => {
|
||||||
const startOfMonth = currentDate.startOf('month');
|
const startOfMonth = currentDate.startOf('month');
|
||||||
const endOfMonth = currentDate.endOf('month');
|
const endOfMonth = currentDate.endOf('month');
|
||||||
const startDay = startOfMonth.day();
|
const startDay = startOfMonth.day();
|
||||||
const days: CalendarDay[] = [];
|
const days: CalendarDay[] = [];
|
||||||
|
|
||||||
const today = dayjs().format('YYYY-MM-DD');
|
const today = dayjs().format('YYYY-MM-DD');
|
||||||
|
|
||||||
|
// Build a map: date -> { taken, missed }
|
||||||
|
const dateMap: Record<string, { taken: number; missed: number }> = {};
|
||||||
|
medRecords.forEach((r) => {
|
||||||
|
const d = r.takenAt?.split('T')[0];
|
||||||
|
if (!d) return;
|
||||||
|
if (!dateMap[d]) dateMap[d] = { taken: 0, missed: 0 };
|
||||||
|
if (r.isTaken) dateMap[d].taken++;
|
||||||
|
else dateMap[d].missed++;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pad previous month
|
||||||
for (let i = startDay - 1; i >= 0; i--) {
|
for (let i = startDay - 1; i >= 0; i--) {
|
||||||
const d = startOfMonth.subtract(i + 1, 'day');
|
const d = startOfMonth.subtract(i + 1, 'day');
|
||||||
days.push({
|
days.push({ date: d.format('YYYY-MM-DD'), year: d.year(), month: d.month() + 1, day: d.date(), isCurrentMonth: false, isToday: d.format('YYYY-MM-DD') === today, markers: [] });
|
||||||
date: d.format('YYYY-MM-DD'),
|
|
||||||
year: d.year(),
|
|
||||||
month: d.month() + 1,
|
|
||||||
day: d.date(),
|
|
||||||
isCurrentMonth: false,
|
|
||||||
isToday: d.format('YYYY-MM-DD') === today,
|
|
||||||
markers: [],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let d = startOfMonth; d.isBefore(endOfMonth) || d.isSame(endOfMonth, 'day'); d = d.add(1, 'day')) {
|
for (let d = startOfMonth; d.isBefore(endOfMonth) || d.isSame(endOfMonth, 'day'); d = d.add(1, 'day')) {
|
||||||
const dateStr = d.format('YYYY-MM-DD');
|
const dateStr = d.format('YYYY-MM-DD');
|
||||||
const markers: CalendarDay['markers'] = [];
|
const markers: CalendarDay['markers'] = [];
|
||||||
|
const dm = dateMap[dateStr];
|
||||||
|
|
||||||
// Calendar markers would be populated from real API data
|
if (dm) {
|
||||||
|
if (dm.taken > 0) {
|
||||||
days.push({
|
markers.push({ type: 'medication_taken', color: '#10B981', count: dm.taken });
|
||||||
date: dateStr,
|
}
|
||||||
year: d.year(),
|
if (dm.missed > 0) {
|
||||||
month: d.month() + 1,
|
markers.push({ type: 'medication_missed', color: '#EF4444', count: dm.missed });
|
||||||
day: d.date(),
|
}
|
||||||
isCurrentMonth: true,
|
} else {
|
||||||
isToday: dateStr === today,
|
// Check if any medication should have been taken on this date
|
||||||
markers,
|
const dateInRange = medications.some((m) => {
|
||||||
|
if (m.status !== 'active') return false;
|
||||||
|
const sd = m.startDate;
|
||||||
|
const ed = m.endDate || '9999-12-31';
|
||||||
|
return dateStr >= sd && dateStr <= ed;
|
||||||
});
|
});
|
||||||
|
if (dateInRange && medications.length > 0) {
|
||||||
|
markers.push({ type: 'medication_missed', color: '#FFA500', count: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
days.push({ date: dateStr, year: d.year(), month: d.month() + 1, day: d.date(), isCurrentMonth: true, isToday: dateStr === today, markers });
|
||||||
}
|
}
|
||||||
|
|
||||||
return days;
|
return days;
|
||||||
}, [currentDate]);
|
}, [currentDate, medRecords, medications]);
|
||||||
|
|
||||||
const weeks: CalendarDay[][] = [];
|
const weeks: CalendarDay[][] = [];
|
||||||
for (let i = 0; i < calendarDays.length; i += 7) {
|
for (let i = 0; i < calendarDays.length; i += 7) {
|
||||||
@@ -79,18 +106,11 @@ export function HealthCalendarPage() {
|
|||||||
{weeks.map((week, wi) => (
|
{weeks.map((week, wi) => (
|
||||||
<div key={wi} className={styles.week}>
|
<div key={wi} className={styles.week}>
|
||||||
{week.map((day) => (
|
{week.map((day) => (
|
||||||
<div
|
<div key={day.date} className={`${styles.day} ${!day.isCurrentMonth ? styles.outside : ''} ${day.isToday ? styles.today : ''}`}>
|
||||||
key={day.date}
|
|
||||||
className={`${styles.day} ${!day.isCurrentMonth ? styles.outside : ''} ${day.isToday ? styles.today : ''}`}
|
|
||||||
>
|
|
||||||
<span className={styles.dayNum}>{day.day}</span>
|
<span className={styles.dayNum}>{day.day}</span>
|
||||||
<div className={styles.markers}>
|
<div className={styles.markers}>
|
||||||
{day.markers.slice(0, 3).map((m, i) => (
|
{day.markers.slice(0, 3).map((m, i) => (
|
||||||
<span
|
<span key={i} className={styles.dot} style={{ background: m.color }} />
|
||||||
key={i}
|
|
||||||
className={styles.dot}
|
|
||||||
style={{ background: m.color }}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,10 +121,9 @@ export function HealthCalendarPage() {
|
|||||||
<Card className={styles.legend}>
|
<Card className={styles.legend}>
|
||||||
<div className={styles.legendTitle}>图例</div>
|
<div className={styles.legendTitle}>图例</div>
|
||||||
<div className={styles.legendItems}>
|
<div className={styles.legendItems}>
|
||||||
<span className={styles.legendItem}><span className={styles.dot} style={{ background: '#2563EB' }} /> 测量日</span>
|
|
||||||
<span className={styles.legendItem}><span className={styles.dot} style={{ background: '#10B981' }} /> 已服药</span>
|
<span className={styles.legendItem}><span className={styles.dot} style={{ background: '#10B981' }} /> 已服药</span>
|
||||||
<span className={styles.legendItem}><span className={styles.dot} style={{ background: '#EF4444' }} /> 漏服药</span>
|
<span className={styles.legendItem}><span className={styles.dot} style={{ background: '#EF4444' }} /> 漏服药</span>
|
||||||
<span className={styles.legendItem}><span className={styles.dot} style={{ background: '#F59E0B' }} /> 复查日</span>
|
<span className={styles.legendItem}><span className={styles.dot} style={{ background: '#FFA500' }} /> 未记录</span>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,45 +1,251 @@
|
|||||||
.grid {
|
/* Combined card */
|
||||||
display: grid;
|
.combinedCard {
|
||||||
grid-template-columns: repeat(3, 1fr);
|
display: flex;
|
||||||
gap: 10px;
|
flex-direction: column;
|
||||||
margin-bottom: 16px;
|
gap: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
margin: 0 -4px;
|
||||||
|
width: calc(100% + 8px);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 2px 16px rgba(0,0,0,0.06);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.combinedCard:active { transform: scale(0.985); }
|
||||||
|
|
||||||
|
.combinedRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combinedIcon {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combinedInfo { flex: 1; text-align: left; }
|
||||||
|
.combinedTitle { font-size: 18px; font-weight: 700; color: #1A1D28; display: block; }
|
||||||
|
.combinedDesc { font-size: 12px; color: #9BA0B4; margin-top: 2px; display: block; }
|
||||||
|
.combinedArrow { font-size: 24px; color: #C0C5D2; }
|
||||||
|
|
||||||
|
.indicatorTags {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quick links — horizontal row */
|
||||||
|
.quickRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 20px -4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quickCard {
|
||||||
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
padding: 20px 12px;
|
padding: 14px 8px;
|
||||||
background: var(--color-white);
|
background: #fff;
|
||||||
border-radius: var(--radius-lg);
|
border: none;
|
||||||
box-shadow: var(--shadow-sm);
|
border-radius: 14px;
|
||||||
transition: transform 0.15s;
|
box-shadow: 0 1px 8px rgba(0,0,0,0.04);
|
||||||
-webkit-tap-highlight-color: transparent;
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s, box-shadow 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card:active { transform: scale(0.96); }
|
.quickCard:active { transform: scale(0.95); }
|
||||||
|
|
||||||
.cardIcon { font-size: 32px; line-height: 1; }
|
.quickIcon {
|
||||||
.cardTitle { font-size: var(--font-size-base); font-weight: 600; }
|
width: 42px;
|
||||||
.cardDesc { font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
|
height: 42px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.extraLinks {
|
.quickLabel {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1A1D28;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* AI Assistant */
|
||||||
|
.aiCard {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 18px;
|
||||||
|
padding: 22px 18px;
|
||||||
|
margin: 16px -4px 0;
|
||||||
|
width: calc(100% + 8px);
|
||||||
|
background: linear-gradient(135deg, #EFF2FF 0%, #F5F7FF 40%, #FDF0F5 100%);
|
||||||
|
border-radius: 22px;
|
||||||
|
border: 1px solid rgba(79,110,247,0.08);
|
||||||
|
box-shadow: 0 2px 16px rgba(79,110,247,0.06);
|
||||||
|
transition: transform 0.15s, box-shadow 0.15s;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.linkCard {
|
.aiCard::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 140px;
|
||||||
|
height: 140px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(79,110,247,0.04);
|
||||||
|
top: -40px;
|
||||||
|
right: -40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiCard::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(240,101,149,0.04);
|
||||||
|
bottom: -20px;
|
||||||
|
left: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiCard:active { transform: scale(0.985); }
|
||||||
|
|
||||||
|
.aiHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 14px 16px;
|
position: relative;
|
||||||
background: var(--color-white);
|
z-index: 1;
|
||||||
border-radius: var(--radius-md);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.linkCard:active { background: var(--color-bg); }
|
.aiAvatar {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--color-primary-gradient, linear-gradient(135deg, #4F6EF7 0%, #845EF7 100%));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 4px 14px rgba(79,110,247,0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiTitleBlock { flex: 1; }
|
||||||
|
|
||||||
|
.aiTitle {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary, #1A1D28);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiSubtitle {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-tertiary, #9BA0B4);
|
||||||
|
display: block;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiBadge {
|
||||||
|
background: var(--color-primary-bg, #EDF0FD);
|
||||||
|
color: var(--color-primary, #4F6EF7);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiDivider {
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(79,110,247,0.08);
|
||||||
|
margin: 0 4px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiQuestionList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiQuestion {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgba(79,110,247,0.06);
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #3D4A6B;
|
||||||
|
text-align: left;
|
||||||
|
transition: all 0.2s;
|
||||||
|
line-height: 1.45;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiQuestion:hover {
|
||||||
|
background: #F9FAFF;
|
||||||
|
border-color: rgba(79,110,247,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiDot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-primary, #4F6EF7);
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.5;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputHint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1.5px dashed rgba(79,110,247,0.15);
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-tertiary, #9BA0B4);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputHint:hover {
|
||||||
|
border-color: rgba(79,110,247,0.3);
|
||||||
|
background: #F9FAFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputHintIcon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
opacity: 0.5;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,75 +1,119 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
import { PageHeader } from '@/components/layout/PageHeader';
|
||||||
import { MEASUREMENT_TYPES } from '@/utils/constants';
|
|
||||||
import styles from './HealthHubPage.module.css';
|
import styles from './HealthHubPage.module.css';
|
||||||
|
|
||||||
|
const COMBINED = {
|
||||||
|
path: '/health/records/add',
|
||||||
|
label: '健康指标',
|
||||||
|
desc: '血压 · 心率 · 血糖 · 血氧 · 体重',
|
||||||
|
indicators: [
|
||||||
|
{ label: '血压', color: '#EF4444', bg: '#FEE9E9' },
|
||||||
|
{ label: '心率', color: '#F59E0B', bg: '#FFF4E5' },
|
||||||
|
{ label: '血糖', color: '#845EF7', bg: '#F3E8FF' },
|
||||||
|
{ label: '血氧', color: '#339AF0', bg: '#E6F0FF' },
|
||||||
|
{ label: '体重', color: '#20C997', bg: '#E6F9F2' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const QUICK_LINKS = [
|
||||||
|
{
|
||||||
|
label: '健康日历', path: '/health/calendar',
|
||||||
|
color: '#F59E0B', bg: '#FFF8E6',
|
||||||
|
svg: (<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2" /><line x1="16" y1="2" x2="16" y2="6" /><line x1="8" y1="2" x2="8" y2="6" /><line x1="3" y1="10" x2="21" y2="10" /></svg>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '服药管理', path: '/health/medications',
|
||||||
|
color: '#D67E0B', bg: '#FFF4E5',
|
||||||
|
svg: (<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="4" y="5" width="16" height="14" rx="4" /><path d="M10 9v6M14 9v6M8 12h8" /></svg>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '运动饮食', path: '/health/exercise-diet',
|
||||||
|
color: '#20C997', bg: '#E6F9F2',
|
||||||
|
svg: (<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10" /><path d="M12 6v6l4 2" /></svg>),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export function HealthHubPage() {
|
export function HealthHubPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader title="健康中心" showBack={false} />
|
<PageHeader title="健康中心" showBack={false} />
|
||||||
<div className={styles.grid}>
|
|
||||||
<button
|
{/* Combined indicators card */}
|
||||||
className={styles.card}
|
<button className={styles.combinedCard} onClick={() => navigate(COMBINED.path)}>
|
||||||
onClick={() => navigate('/health/records?type=blood_pressure')}
|
<div className={styles.combinedRow}>
|
||||||
>
|
<div className={styles.combinedIcon}>
|
||||||
<span className={styles.cardIcon}>💓</span>
|
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
|
||||||
<span className={styles.cardTitle}>血压</span>
|
<rect x="2" y="2" width="12" height="12" rx="3" fill="#EEF2FF" stroke="#4F6EF7" strokeWidth="1.5" />
|
||||||
<span className={styles.cardDesc}>记录和趋势</span>
|
<rect x="18" y="2" width="12" height="12" rx="3" fill="#FEE9E9" stroke="#EF4444" strokeWidth="1.5" />
|
||||||
|
<rect x="2" y="18" width="12" height="12" rx="3" fill="#FFF4E5" stroke="#F59E0B" strokeWidth="1.5" />
|
||||||
|
<rect x="18" y="18" width="12" height="12" rx="3" fill="#E6F9F2" stroke="#20C997" strokeWidth="1.5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className={styles.combinedInfo}>
|
||||||
|
<span className={styles.combinedTitle}>{COMBINED.label}</span>
|
||||||
|
<span className={styles.combinedDesc}>{COMBINED.desc}</span>
|
||||||
|
</div>
|
||||||
|
<span className={styles.combinedArrow}>›</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.indicatorTags}>
|
||||||
|
{COMBINED.indicators.map((ind) => (
|
||||||
|
<span key={ind.label} className={styles.tag} style={{ background: ind.bg, color: ind.color }}>
|
||||||
|
{ind.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
className={styles.card}
|
{/* Quick links — horizontal row */}
|
||||||
onClick={() => navigate('/health/records?type=heart_rate')}
|
<div className={styles.quickRow}>
|
||||||
>
|
{QUICK_LINKS.map((link) => (
|
||||||
<span className={styles.cardIcon}>❤️</span>
|
<button key={link.path} className={styles.quickCard} onClick={() => navigate(link.path)}>
|
||||||
<span className={styles.cardTitle}>心率</span>
|
<span className={styles.quickIcon} style={{ background: link.bg, color: link.color }}>
|
||||||
<span className={styles.cardDesc}>记录和趋势</span>
|
{link.svg}
|
||||||
|
</span>
|
||||||
|
<span className={styles.quickLabel}>{link.label}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
))}
|
||||||
className={styles.card}
|
</div>
|
||||||
onClick={() => navigate('/health/records?type=blood_sugar')}
|
|
||||||
>
|
{/* AI 健康助手 */}
|
||||||
<span className={styles.cardIcon}>🩸</span>
|
<div className={styles.aiCard}>
|
||||||
<span className={styles.cardTitle}>血糖</span>
|
<div className={styles.aiHeader}>
|
||||||
<span className={styles.cardDesc}>记录和趋势</span>
|
<div className={styles.aiAvatar}>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className={styles.aiTitleBlock}>
|
||||||
|
<span className={styles.aiTitle}>AI 健康助手</span>
|
||||||
|
<span className={styles.aiSubtitle}>智能分析 · 随时提问</span>
|
||||||
|
</div>
|
||||||
|
<span className={styles.aiBadge}>Beta</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.aiDivider} />
|
||||||
|
|
||||||
|
<div className={styles.aiQuestionList}>
|
||||||
|
<button className={styles.aiQuestion}>
|
||||||
|
<span className={styles.aiDot} />
|
||||||
|
分析我的血压变化趋势,有什么需要注意的?
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button className={styles.aiQuestion}>
|
||||||
className={styles.card}
|
<span className={styles.aiDot} />
|
||||||
onClick={() => navigate('/health/records?type=spo2')}
|
根据我最近的指标,运动饮食有什么建议?
|
||||||
>
|
|
||||||
<span className={styles.cardIcon}>🫁</span>
|
|
||||||
<span className={styles.cardTitle}>血氧</span>
|
|
||||||
<span className={styles.cardDesc}>记录和趋势</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={styles.card}
|
|
||||||
onClick={() => navigate('/health/records?type=weight')}
|
|
||||||
>
|
|
||||||
<span className={styles.cardIcon}>⚖️</span>
|
|
||||||
<span className={styles.cardTitle}>体重</span>
|
|
||||||
<span className={styles.cardDesc}>记录和趋势</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={styles.card}
|
|
||||||
onClick={() => navigate('/health/records?type=steps')}
|
|
||||||
>
|
|
||||||
<span className={styles.cardIcon}>🚶</span>
|
|
||||||
<span className={styles.cardTitle}>步数</span>
|
|
||||||
<span className={styles.cardDesc}>记录和趋势</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.extraLinks}>
|
<div className={styles.inputHint}>
|
||||||
<button className={styles.linkCard} onClick={() => navigate('/health/calendar')}>
|
<svg className={styles.inputHintIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
📅 健康日历
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
</button>
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
<button className={styles.linkCard} onClick={() => navigate('/health/medications')}>
|
</svg>
|
||||||
💊 服药管理
|
输入你的健康问题,AI 为你分析...
|
||||||
</button>
|
</div>
|
||||||
<button className={styles.linkCard} onClick={() => navigate('/health/exercise-diet')}>
|
|
||||||
🏃 运动饮食
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,11 +3,12 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
background: var(--color-primary);
|
background: var(--color-primary-gradient);
|
||||||
color: var(--color-text-inverse);
|
color: var(--color-text-inverse);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
box-shadow: 0 4px 14px rgba(79,110,247,0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chartBtn {
|
.chartBtn {
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
border: 1.5px solid var(--color-primary);
|
border: 1.5px solid var(--color-primary);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recordCard {
|
.recordCard {
|
||||||
@@ -28,7 +30,7 @@
|
|||||||
|
|
||||||
.recordValue {
|
.recordValue {
|
||||||
font-size: var(--font-size-xl);
|
font-size: var(--font-size-xl);
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function HealthRecordListPage() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{records.length === 0 ? (
|
{records.length === 0 ? (
|
||||||
<Empty icon={config.icon} message={`暂无${config.label}记录`} />
|
<Empty message={`暂无${config.label}记录`} />
|
||||||
) : (
|
) : (
|
||||||
records.map((r) => (
|
records.map((r) => (
|
||||||
<Card key={r.id} className={styles.recordCard}>
|
<Card key={r.id} className={styles.recordCard}>
|
||||||
@@ -51,7 +51,7 @@ export function HealthRecordListPage() {
|
|||||||
<div className={styles.recordMeta}>
|
<div className={styles.recordMeta}>
|
||||||
<span>{formatDate(r.recordedAt, 'MM-DD HH:mm')}</span>
|
<span>{formatDate(r.recordedAt, 'MM-DD HH:mm')}</span>
|
||||||
<span className={styles.source}>
|
<span className={styles.source}>
|
||||||
{r.source === 'device' ? '📡 设备' : '✋ 手动'}
|
{r.source === 'device' ? '设备' : '手动'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -1,87 +1,135 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Button } from '@/components/common/Button';
|
|
||||||
import { Input } from '@/components/common/Input';
|
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
import { PageHeader } from '@/components/layout/PageHeader';
|
||||||
import { ToastContainer, toast } from '@/components/common/Toast';
|
import { ToastContainer, toast } from '@/components/common/Toast';
|
||||||
import * as healthService from '@/services/health.service';
|
import * as healthService from '@/services/health.service';
|
||||||
import { MEASUREMENT_TYPES } from '@/utils/constants';
|
|
||||||
import type { MeasurementType } from '@/types';
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import styles from './ManualEntryPage.module.css';
|
import styles from './ManualEntryPage.module.css';
|
||||||
|
|
||||||
|
const INDICATORS = [
|
||||||
|
{
|
||||||
|
type: 'blood_pressure' as const, label: '血压', color: '#DC4A4A',
|
||||||
|
icon: '🩺', multi: true, fields: [
|
||||||
|
{ key: 'systolic', placeholder: '收缩压', hint: '120' },
|
||||||
|
{ key: 'diastolic', placeholder: '舒张压', hint: '80' },
|
||||||
|
], unit: 'mmHg', chartLabel: '血压',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'heart_rate' as const, label: '心率', color: '#D68B20',
|
||||||
|
icon: '💓', fields: [{ key: 'heart_rate', placeholder: '心率', hint: '72' }], unit: 'bpm', chartLabel: '心率',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'blood_sugar' as const, label: '血糖', color: '#7C5CE7',
|
||||||
|
icon: '🩸', fields: [{ key: 'blood_sugar', placeholder: '血糖', hint: '5.6' }], unit: 'mmol/L', chartLabel: '血糖',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'spo2' as const, label: '血氧', color: '#3B8ED4',
|
||||||
|
icon: '🫁', fields: [{ key: 'spo2', placeholder: '血氧', hint: '98' }], unit: '%', chartLabel: '血氧',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'weight' as const, label: '体重', color: '#3DAF86',
|
||||||
|
icon: '⚖️', fields: [{ key: 'weight', placeholder: '体重', hint: '70.5' }], unit: 'kg', chartLabel: '体重',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export function ManualEntryPage() {
|
export function ManualEntryPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const type = (searchParams.get('type') || 'blood_pressure') as MeasurementType;
|
|
||||||
const config = MEASUREMENT_TYPES[type];
|
|
||||||
|
|
||||||
const [systolic, setSystolic] = useState('');
|
const [values, setValues] = useState<Record<string, string>>({
|
||||||
const [diastolic, setDiastolic] = useState('');
|
systolic: '', diastolic: '', heart_rate: '', blood_sugar: '', spo2: '', weight: '',
|
||||||
const [value, setValue] = useState('');
|
});
|
||||||
const [date, setDate] = useState(dayjs().format('YYYY-MM-DD'));
|
const [dates, setDates] = useState<Record<string, string>>(
|
||||||
const [time, setTime] = useState(dayjs().format('HH:mm'));
|
Object.fromEntries(INDICATORS.map((i) => [i.type, dayjs().format('YYYY-MM-DD')])),
|
||||||
const [loading, setLoading] = useState(false);
|
);
|
||||||
|
const [loading, setLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const setVal = (key: string, v: string) => setValues((prev) => ({ ...prev, [key]: v }));
|
||||||
|
|
||||||
|
const saveOne = async (type: string) => {
|
||||||
|
setLoading(type);
|
||||||
|
try {
|
||||||
|
const d = dates[type] || dayjs().format('YYYY-MM-DD');
|
||||||
|
const recordedAt = `${d}T${dayjs().format('HH:mm:ss')}`;
|
||||||
|
const ind = INDICATORS.find((i) => i.type === type)!;
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
const numVal = parseFloat(value);
|
|
||||||
if (type === 'blood_pressure') {
|
if (type === 'blood_pressure') {
|
||||||
const sys = parseFloat(systolic);
|
const sys = parseFloat(values.systolic);
|
||||||
const dia = parseFloat(diastolic);
|
const dia = parseFloat(values.diastolic);
|
||||||
if (!sys || !dia) { toast('请填写完整', 'error'); return; }
|
if (!sys || !dia) { toast('请填写收缩压和舒张压', 'error'); return; }
|
||||||
await healthService.addRecord({
|
await healthService.addRecord({ type, value: { systolic: sys, diastolic: dia }, unit: 'mmHg', recordedAt, recordedDate: d, source: 'manual' });
|
||||||
type,
|
|
||||||
value: { systolic: sys, diastolic: dia },
|
|
||||||
unit: 'mmHg',
|
|
||||||
recordedAt: `${date}T${time}:00`,
|
|
||||||
recordedDate: date,
|
|
||||||
source: 'manual',
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
if (!numVal) { toast('请填写数值', 'error'); return; }
|
const v = parseFloat(values[ind.fields[0].key]);
|
||||||
await healthService.addRecord({
|
if (!v) { toast('请填写数值', 'error'); return; }
|
||||||
type,
|
await healthService.addRecord({ type, value: v, unit: ind.unit, recordedAt, recordedDate: d, source: 'manual' });
|
||||||
value: numVal,
|
}
|
||||||
unit: config.unit,
|
toast(`${ind.label} 已保存`);
|
||||||
recordedAt: `${date}T${time}:00`,
|
} catch {
|
||||||
recordedDate: date,
|
toast('保存失败', 'error');
|
||||||
source: 'manual',
|
} finally {
|
||||||
});
|
setLoading(null);
|
||||||
}
|
}
|
||||||
toast('记录成功');
|
|
||||||
setTimeout(() => navigate(-1), 500);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page--no-tab">
|
<div className="page--no-tab">
|
||||||
<PageHeader title={`新增${config.label}记录`} />
|
<PageHeader title="健康指标" />
|
||||||
<div className={styles.form}>
|
|
||||||
{type === 'blood_pressure' ? (
|
<div className={styles.cards}>
|
||||||
<>
|
{INDICATORS.map((ind) => (
|
||||||
<div className={styles.bpRow}>
|
<div key={ind.type} className={styles.card}>
|
||||||
<Input label="收缩压 (mmHg)" value={systolic} onChange={(e) => setSystolic(e.target.value)} type="number" />
|
<div className={styles.cardHeader}>
|
||||||
<Input label="舒张压 (mmHg)" value={diastolic} onChange={(e) => setDiastolic(e.target.value)} type="number" />
|
<span className={styles.cardIcon}>{ind.icon}</span>
|
||||||
|
<span className={styles.cardTitle} style={{ color: ind.color }}>{ind.label}</span>
|
||||||
|
<span className={styles.cardUnit}>{ind.unit}</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
) : (
|
<div className={styles.cardInputs}>
|
||||||
<Input
|
{ind.fields.map((f) => (
|
||||||
label={`${config.label} (${config.unit})`}
|
<input
|
||||||
value={value}
|
key={f.key}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
className={styles.cardInput}
|
||||||
type="number"
|
type="number"
|
||||||
step="0.1"
|
step={ind.type === 'blood_sugar' || ind.type === 'weight' ? '0.1' : '1'}
|
||||||
|
placeholder={`${f.placeholder} ${f.hint}`}
|
||||||
|
value={values[f.key] || ''}
|
||||||
|
onChange={(e) => setVal(f.key, e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
|
<div className={styles.dateChip}>
|
||||||
|
<input
|
||||||
|
className={styles.dateInput}
|
||||||
|
type="date"
|
||||||
|
value={dates[ind.type] || ''}
|
||||||
|
onChange={(e) => setDates((prev) => ({ ...prev, [ind.type]: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={styles.checkBtn}
|
||||||
|
style={{ background: ind.color }}
|
||||||
|
onClick={() => saveOne(ind.type)}
|
||||||
|
disabled={loading === ind.type}
|
||||||
|
>
|
||||||
|
{loading === ind.type ? '…' : (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
)}
|
)}
|
||||||
|
</button>
|
||||||
<div className={styles.row}>
|
</div>
|
||||||
<Input label="日期" value={date} onChange={(e) => setDate(e.target.value)} type="date" />
|
</div>
|
||||||
<Input label="时间" value={time} onChange={(e) => setTime(e.target.value)} type="time" />
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button variant="primary" size="lg" fullWidth loading={loading} onClick={handleSubmit}>
|
{/* Trend entry */}
|
||||||
保存记录
|
<button className={styles.trendCard} onClick={() => navigate('/health/trends')}>
|
||||||
</Button>
|
<span className={styles.trendIcon}>📈</span>
|
||||||
|
<div className={styles.trendInfo}>
|
||||||
|
<span className={styles.trendTitle}>健康趋势</span>
|
||||||
|
<span className={styles.trendSub}>血压 · 心率 · 血糖 · 血氧 · 体重</span>
|
||||||
</div>
|
</div>
|
||||||
|
<span className={styles.trendArrow}>›</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
import { PageHeader } from '@/components/layout/PageHeader';
|
||||||
import { LineChart } from '@/components/charts/LineChart';
|
import { MultiLineChart, type SeriesData } from '@/components/charts/MultiLineChart';
|
||||||
import { Empty } from '@/components/common/Empty';
|
import { Empty } from '@/components/common/Empty';
|
||||||
import { MEASUREMENT_TYPES } from '@/utils/constants';
|
|
||||||
import * as healthService from '@/services/health.service';
|
import * as healthService from '@/services/health.service';
|
||||||
import type { HealthRecord, MeasurementType } from '@/types';
|
import type { HealthRecord } from '@/types';
|
||||||
import { Button } from '@/components/common/Button';
|
|
||||||
import styles from './TrendChartPage.module.css';
|
import styles from './TrendChartPage.module.css';
|
||||||
|
|
||||||
|
const INDICATORS = [
|
||||||
|
{ type: 'bp_systolic' as const, label: '收缩压', color: '#DC4A4A', unit: 'mmHg', source: 'blood_pressure' as const, field: 'systolic' as const },
|
||||||
|
{ type: 'bp_diastolic' as const, label: '舒张压', color: '#E0558A', unit: 'mmHg', source: 'blood_pressure' as const, field: 'diastolic' as const },
|
||||||
|
{ type: 'heart_rate' as const, label: '心率', color: '#D68B20', unit: 'bpm' },
|
||||||
|
{ type: 'blood_sugar' as const, label: '血糖', color: '#7C5CE7', unit: 'mmol/L' },
|
||||||
|
{ type: 'spo2' as const, label: '血氧', color: '#3B8ED4', unit: '%' },
|
||||||
|
{ type: 'weight' as const, label: '体重', color: '#3DAF86', unit: 'kg' },
|
||||||
|
];
|
||||||
|
|
||||||
const PERIODS = [
|
const PERIODS = [
|
||||||
{ label: '7天', days: 7 },
|
{ label: '7天', days: 7 },
|
||||||
{ label: '14天', days: 14 },
|
{ label: '14天', days: 14 },
|
||||||
@@ -17,25 +23,60 @@ const PERIODS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function TrendChartPage() {
|
export function TrendChartPage() {
|
||||||
const { type } = useParams<{ type: MeasurementType }>();
|
const [visible, setVisible] = useState<Set<string>>(new Set(INDICATORS.map((i) => i.type)));
|
||||||
const config = MEASUREMENT_TYPES[type || 'blood_pressure'];
|
|
||||||
const [records, setRecords] = useState<HealthRecord[]>([]);
|
|
||||||
const [period, setPeriod] = useState(30);
|
const [period, setPeriod] = useState(30);
|
||||||
|
const [allRecords, setAllRecords] = useState<HealthRecord[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (type) healthService.getTrendData(type, period).then(setRecords);
|
const sources = [...new Set(INDICATORS.map((i) => (i as Record<string, string>).source || i.type))];
|
||||||
}, [type, period]);
|
Promise.all(sources.map((s) => healthService.getTrendData(s as Parameters<typeof healthService.getTrendData>[0], period)))
|
||||||
|
.then((results) => setAllRecords(results.flat()));
|
||||||
|
}, [period]);
|
||||||
|
|
||||||
const isBP = type === 'blood_pressure';
|
const series: SeriesData[] = useMemo(() => {
|
||||||
const chartData = records.map((r) => ({
|
const result: SeriesData[] = [];
|
||||||
|
for (const ind of INDICATORS) {
|
||||||
|
if (!visible.has(ind.type)) continue;
|
||||||
|
const sourceType = (ind as Record<string, string>).source || ind.type;
|
||||||
|
const raw = allRecords
|
||||||
|
.filter((r) => r.type === sourceType)
|
||||||
|
.sort((a, b) => a.recordedDate.localeCompare(b.recordedDate));
|
||||||
|
|
||||||
|
if ('field' in ind && ind.field) {
|
||||||
|
result.push({
|
||||||
|
name: ind.label,
|
||||||
|
color: ind.color,
|
||||||
|
data: raw.map((r) => ({
|
||||||
date: r.recordedDate,
|
date: r.recordedDate,
|
||||||
value: isBP ? (typeof r.value === 'object' ? r.value.systolic : 0) : (r.value as number),
|
value: typeof r.value === 'object' ? (r.value as Record<string, number>)[ind.field!] : 0,
|
||||||
value2: isBP ? (typeof r.value === 'object' ? r.value.diastolic : 0) : undefined,
|
})),
|
||||||
}));
|
unit: ind.unit,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
result.push({
|
||||||
|
name: ind.label,
|
||||||
|
color: ind.color,
|
||||||
|
data: raw.map((r) => ({ date: r.recordedDate, value: r.value as number })),
|
||||||
|
unit: ind.unit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [allRecords, visible]);
|
||||||
|
|
||||||
|
const toggle = (type: string) => {
|
||||||
|
const next = new Set(visible);
|
||||||
|
if (next.has(type)) next.delete(type); else next.add(type);
|
||||||
|
setVisible(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasData = series.some((s) => s.data.length > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page--no-tab">
|
<div className="page--no-tab">
|
||||||
<PageHeader title={`${config.label}趋势`} />
|
<PageHeader title="健康趋势" />
|
||||||
|
|
||||||
|
{/* Period selector */}
|
||||||
<div className={styles.periodBar}>
|
<div className={styles.periodBar}>
|
||||||
{PERIODS.map((p) => (
|
{PERIODS.map((p) => (
|
||||||
<button
|
<button
|
||||||
@@ -47,18 +88,31 @@ export function TrendChartPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{chartData.length > 0 ? (
|
|
||||||
<LineChart
|
{/* Toggle indicators */}
|
||||||
data={chartData}
|
<div className={styles.toggleBar}>
|
||||||
seriesName={isBP ? '收缩压' : config.label}
|
{INDICATORS.map((ind) => (
|
||||||
seriesName2={isBP ? '舒张压' : undefined}
|
<button
|
||||||
unit={config.unit}
|
key={ind.type}
|
||||||
markLine={isBP ? 140 : undefined}
|
className={`${styles.toggleBtn} ${visible.has(ind.type) ? styles.toggleOn : styles.toggleOff}`}
|
||||||
markLineLabel={isBP ? '140警戒线' : undefined}
|
style={{
|
||||||
/>
|
borderColor: ind.color,
|
||||||
|
...(visible.has(ind.type) ? { background: ind.color, color: '#fff' } : { color: ind.color }),
|
||||||
|
}}
|
||||||
|
onClick={() => toggle(ind.type)}
|
||||||
|
>
|
||||||
|
<span className={styles.toggleDot} style={{ background: visible.has(ind.type) ? '#fff' : ind.color }} />
|
||||||
|
{ind.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasData ? (
|
||||||
|
<MultiLineChart series={series} />
|
||||||
) : (
|
) : (
|
||||||
<Empty message="暂无数据" />
|
<Empty message="暂无数据" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,11 +36,11 @@ export function DeviceBindingPage() {
|
|||||||
<div className="page--no-tab">
|
<div className="page--no-tab">
|
||||||
<PageHeader title="设备管理" />
|
<PageHeader title="设备管理" />
|
||||||
<Button fullWidth loading={scanning} onClick={handleScan} style={{ marginBottom: 16 }}>
|
<Button fullWidth loading={scanning} onClick={handleScan} style={{ marginBottom: 16 }}>
|
||||||
{scanning ? '搜索中...' : '🔍 扫描附近设备'}
|
{scanning ? '搜索中...' : '扫描附近设备'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{devices.length === 0 ? (
|
{devices.length === 0 ? (
|
||||||
<Empty icon="📡" message="暂无已绑定设备" />
|
<Empty message="暂无已绑定设备" />
|
||||||
) : (
|
) : (
|
||||||
devices.map((d) => (
|
devices.map((d) => (
|
||||||
<Card key={d.id} className={styles.deviceCard}>
|
<Card key={d.id} className={styles.deviceCard}>
|
||||||
@@ -51,7 +51,7 @@ export function DeviceBindingPage() {
|
|||||||
<span className={`${styles.status} ${d.status === 'connected' ? styles.connected : styles.disconnected}`}>
|
<span className={`${styles.status} ${d.status === 'connected' ? styles.connected : styles.disconnected}`}>
|
||||||
{d.status === 'connected' ? '已连接' : '未连接'}
|
{d.status === 'connected' ? '已连接' : '未连接'}
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.battery}>🔋 {d.batteryLevel}%</span>
|
<span className={styles.battery}>电量 {d.batteryLevel}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,138 +1,358 @@
|
|||||||
.notifyBtn {
|
.greetingBar {
|
||||||
|
padding: 12px 0 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
font-size: 20px;
|
}
|
||||||
padding: 4px;
|
|
||||||
min-width: 44px;
|
.dateText {
|
||||||
min-height: 44px;
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notifyBtn {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
background: var(--color-white);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
border: 1px solid var(--color-divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notifyBadge {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-accent-red);
|
||||||
|
border: 2px solid #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overviewCard {
|
.overviewCard {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
background: linear-gradient(135deg, #1E6BFF, #4D8FFF);
|
background: linear-gradient(145deg, #3A54E8 0%, #5B74F7 30%, #7D9AFF 100%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: 20px 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 8px 25px rgba(58,84,232,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overviewCard::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 160px;
|
||||||
|
height: 160px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
top: -40px;
|
||||||
|
right: -40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overviewCard::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
bottom: -30px;
|
||||||
|
left: -30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overviewHeader {
|
.overviewHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 18px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overviewTitle {
|
.overviewTitle {
|
||||||
font-size: var(--font-size-md);
|
font-size: var(--font-size-md);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #fff;
|
opacity: 0.95;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overviewTime {
|
.overviewTime {
|
||||||
font-size: var(--font-size-xs);
|
font-size: 11px;
|
||||||
color: var(--color-text-tertiary);
|
opacity: 0.65;
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overviewData {
|
.overviewData {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: stretch;
|
||||||
gap: 16px;
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bpSection,
|
.dataCol {
|
||||||
.hrSection {
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataCol:first-child {
|
||||||
|
flex: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dataLabel {
|
.dataLabel {
|
||||||
font-size: var(--font-size-xs);
|
font-size: 10px;
|
||||||
color: var(--color-text-tertiary);
|
opacity: 0.65;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bpValues {
|
.bpValues {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 4px;
|
gap: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bpNum {
|
.bpNum {
|
||||||
font-size: var(--font-size-3xl);
|
font-size: 22px;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
line-height: 1.1;
|
line-height: 1.15;
|
||||||
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.risk_normal { color: var(--color-success); }
|
|
||||||
.risk_borderline { color: var(--color-warning); }
|
|
||||||
.risk_abnormal { color: var(--color-danger); }
|
|
||||||
|
|
||||||
.bpSep {
|
.bpSep {
|
||||||
font-size: var(--font-size-xl);
|
font-size: 14px;
|
||||||
color: var(--color-text-tertiary);
|
opacity: 0.35;
|
||||||
|
margin: 0 -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hrNum {
|
.hrNum {
|
||||||
font-size: var(--font-size-3xl);
|
font-size: 22px;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
color: var(--color-text-primary);
|
line-height: 1.15;
|
||||||
line-height: 1.1;
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.unit {
|
.unit {
|
||||||
font-size: var(--font-size-xs);
|
font-size: 10px;
|
||||||
color: var(--color-text-tertiary);
|
opacity: 0.5;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.divider {
|
.divider {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 60px;
|
background: rgba(255,255,255,0.18);
|
||||||
background: var(--color-border);
|
flex-shrink: 0;
|
||||||
|
align-self: stretch;
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.riskAbnormal {
|
||||||
|
color: #FF7171 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Quick Actions */
|
/* Quick Actions */
|
||||||
.quickActions {
|
.quickActions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
background: var(--color-white);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 16px;
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.quickAction {
|
.quickAction {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
padding: 12px 8px;
|
padding: 14px 4px;
|
||||||
border-radius: var(--radius-md);
|
background: var(--color-white);
|
||||||
transition: background 0.15s;
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: all 0.2s;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quickAction:active {
|
.quickAction:active {
|
||||||
background: var(--color-bg);
|
transform: scale(0.94);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.quickIcon {
|
.quickIcon {
|
||||||
font-size: 28px;
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
border-radius: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 24px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quickLabel {
|
.quickLabel {
|
||||||
font-size: var(--font-size-xs);
|
font-size: 12px;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Today's Medications */
|
||||||
|
/* Today's Medications */
|
||||||
|
.medSection {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medSectionTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.medTitleIcon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--color-primary-gradient);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4px 12px rgba(79,110,247,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.medTitleCount {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: #EDF0FD;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medCard {
|
||||||
|
background: var(--color-white);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: 18px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medCard:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.medHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medNameGroup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medName {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.medDosage {
|
||||||
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.medStatus {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medStatusDone { background: var(--color-success-bg); color: #0D8A5E; }
|
||||||
|
.medStatusPending { background: var(--color-primary-bg); color: var(--color-primary); }
|
||||||
|
.medStatusMissed { background: var(--color-danger-bg); color: #D53131; }
|
||||||
|
|
||||||
|
.medSlots {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medSlot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medSlotTaken { background: var(--color-success-bg); color: #0D8A5E; }
|
||||||
|
.medSlotTodo { background: var(--color-bg); color: var(--color-text-secondary); }
|
||||||
|
.medSlotMissed { background: var(--color-danger-bg); color: #D53131; }
|
||||||
|
|
||||||
|
.medSlotIcon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medSlotDot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medSlotDotTaken { background: var(--color-success); }
|
||||||
|
.medSlotDotTodo { background: var(--color-text-tertiary); }
|
||||||
|
.medSlotDotMissed { background: var(--color-danger); }
|
||||||
|
|
||||||
|
.medEmpty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px 24px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--color-white);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Health Tip */
|
/* Health Tip */
|
||||||
.tipCard {
|
.tipCard {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
background: linear-gradient(135deg, #FFFDF5, #FFF8EC);
|
||||||
|
border: 1px solid #FDE8B3;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tipHeader {
|
.tipHeader {
|
||||||
@@ -144,18 +364,19 @@
|
|||||||
|
|
||||||
.tipTitle {
|
.tipTitle {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: var(--color-text-secondary);
|
color: #B7791F;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tipHint {
|
.tipHint {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
color: var(--color-text-tertiary);
|
color: #D69E2E;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tipContent {
|
.tipContent {
|
||||||
font-size: var(--font-size-sm);
|
font-size: 13px;
|
||||||
color: var(--color-text-secondary);
|
color: #7B3F00;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,118 +1,273 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { Card } from '@/components/common/Card';
|
import { Card } from '@/components/common/Card';
|
||||||
import { Empty } from '@/components/common/Empty';
|
|
||||||
import { Badge } from '@/components/common/Badge';
|
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useNotificationStore } from '@/stores/notification.store';
|
import { useNotificationStore } from '@/stores/notification.store';
|
||||||
|
import { api } from '@/services/api-client';
|
||||||
import * as healthService from '@/services/health.service';
|
import * as healthService from '@/services/health.service';
|
||||||
import { MEASUREMENT_TYPES, HEALTH_TIPS } from '@/utils/constants';
|
|
||||||
import { getBPRiskLevel } from '@/utils/format';
|
|
||||||
import type { HealthStats } from '@/types';
|
import type { HealthStats } from '@/types';
|
||||||
import styles from './HomePage.module.css';
|
import styles from './HomePage.module.css';
|
||||||
|
|
||||||
|
interface MedSlot { time: string; taken: boolean; missed: boolean; takenAt?: string }
|
||||||
|
interface MedSummary { id: string; drugName: string; dosage: string; frequency: string; slots: MedSlot[]; allTaken: boolean }
|
||||||
|
|
||||||
|
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'];
|
||||||
|
|
||||||
const QUICK_ACTIONS = [
|
const QUICK_ACTIONS = [
|
||||||
{ label: '测血压', icon: '💓', path: '/health/records?type=blood_pressure' },
|
{
|
||||||
{ label: '记用药', icon: '💊', path: '/health/medications' },
|
key: 'bp',
|
||||||
{ label: '在线问诊', icon: '👨⚕️', path: '/services/consultation' },
|
label: '血压',
|
||||||
{ label: '报告解读', icon: '📋', path: '/services/reports' },
|
path: '/health/records?type=blood_pressure',
|
||||||
{ label: '健康日历', icon: '📅', path: '/health/calendar' },
|
svg: (
|
||||||
{ label: '运动饮食', icon: '🏃', path: '/health/exercise-diet' },
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#EF4444" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
||||||
|
<polyline points="12 7 12 13 15 15" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
iconBg: '#FEE9E9',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'med',
|
||||||
|
label: '用药',
|
||||||
|
path: '/health/medications',
|
||||||
|
svg: (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#D67E0B" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="4" y="5" width="16" height="14" rx="4" />
|
||||||
|
<path d="M10 9v6M14 9v6M8 12h8" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
iconBg: '#FFF4E5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'chat',
|
||||||
|
label: '问诊',
|
||||||
|
path: '/services/consultation',
|
||||||
|
svg: (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4F6EF7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||||
|
<line x1="9" y1="10" x2="15" y2="10" />
|
||||||
|
<line x1="12" y1="7" x2="12" y2="13" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
iconBg: '#E6F0FF',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'report',
|
||||||
|
label: '报告',
|
||||||
|
path: '/services/reports',
|
||||||
|
svg: (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#845EF7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
iconBg: '#F3E8FF',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'calendar',
|
||||||
|
label: '日历',
|
||||||
|
path: '/health/calendar',
|
||||||
|
svg: (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#20C997" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||||
|
<line x1="16" y1="2" x2="16" y2="6" />
|
||||||
|
<line x1="8" y1="2" x2="8" y2="6" />
|
||||||
|
<line x1="3" y1="10" x2="21" y2="10" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
iconBg: '#E6F9F2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'followup',
|
||||||
|
label: '复查',
|
||||||
|
path: '/services/follow-ups',
|
||||||
|
svg: (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#F06595" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M18 10h-2.5L14 13l-2-6-3 6.5L7 10H5" />
|
||||||
|
<rect x="2" y="2" width="20" height="20" rx="3" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
iconBg: '#FFF0F5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'diet',
|
||||||
|
label: '饮食',
|
||||||
|
path: '/health/exercise-diet',
|
||||||
|
svg: (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#10B981" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 6v6l4 2" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
iconBg: '#F0FDF4',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'device',
|
||||||
|
label: '设备',
|
||||||
|
path: '/home/device-binding',
|
||||||
|
svg: (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#339AF0" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
|
||||||
|
<line x1="12" y1="18" x2="12.01" y2="18" />
|
||||||
|
<path d="M9 6h6" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
iconBg: '#EFF6FF',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { unreadCount, fetchNotifications } = useNotificationStore();
|
const { unreadCount, fetchNotifications } = useNotificationStore();
|
||||||
const [stats, setStats] = useState<HealthStats[]>([]);
|
const [stats, setStats] = useState<HealthStats[]>([]);
|
||||||
const [tipIndex, setTipIndex] = useState(0);
|
const [meds, setMeds] = useState<MedSummary[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
healthService.getLatestStats().then(setStats);
|
healthService.getLatestStats().then(setStats);
|
||||||
fetchNotifications();
|
fetchNotifications();
|
||||||
}, [fetchNotifications]);
|
api.get<MedSummary[]>('/api/medications/today-summary')
|
||||||
|
.then((r) => setMeds(r.data))
|
||||||
|
.catch(() => {});
|
||||||
|
}, [fetchNotifications, location.pathname]);
|
||||||
|
|
||||||
const bpStats = stats.find((s) => s.type === 'blood_pressure');
|
const bpStats = stats.find((s) => s.type === 'blood_pressure');
|
||||||
const hrStats = stats.find((s) => s.type === 'heart_rate');
|
const hrStats = stats.find((s) => s.type === 'heart_rate');
|
||||||
|
const sugarStats = stats.find((s) => s.type === 'blood_sugar');
|
||||||
|
const spo2Stats = stats.find((s) => s.type === 'spo2');
|
||||||
|
|
||||||
const bpValue = bpStats?.latest?.value;
|
const bpValue = bpStats?.latest?.value;
|
||||||
const systolic = typeof bpValue === 'object' ? bpValue.systolic : null;
|
const systolic = typeof bpValue === 'object' ? bpValue.systolic : null;
|
||||||
const diastolic = typeof bpValue === 'object' ? bpValue.diastolic : null;
|
const diastolic = typeof bpValue === 'object' ? bpValue.diastolic : null;
|
||||||
const riskLevel = systolic && diastolic ? getBPRiskLevel(systolic, diastolic) : null;
|
|
||||||
|
const todayDate = useMemo(() => {
|
||||||
|
const d = new Date();
|
||||||
|
return `${d.getMonth() + 1}月${d.getDate()}日 星期${WEEKDAYS[d.getDay()]}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const bpAbnormal = systolic !== null && diastolic !== null
|
||||||
|
&& (systolic >= 120 || diastolic >= 80);
|
||||||
|
|
||||||
|
const hrValue = hrStats?.latest ? Number(hrStats.latest.value) : null;
|
||||||
|
const hrAbnormal = hrValue !== null && (hrValue < 60 || hrValue > 100);
|
||||||
|
|
||||||
|
const sugarValue = sugarStats?.latest ? Number(sugarStats.latest.value) : null;
|
||||||
|
const sugarAbnormal = sugarValue !== null && (sugarValue < 3.9 || sugarValue > 6.1);
|
||||||
|
|
||||||
|
const spo2Value = spo2Stats?.latest ? Number(spo2Stats.latest.value) : null;
|
||||||
|
const spo2Abnormal = spo2Value !== null && spo2Value < 95;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page" style={{ paddingTop: 0 }}>
|
||||||
<PageHeader
|
<div className={styles.greetingBar}>
|
||||||
title={`你好,${user?.nickname || '用户'}`}
|
<div className={styles.dateText}>{todayDate}</div>
|
||||||
showBack={false}
|
<button onClick={() => navigate('/notifications')} className={styles.notifyBtn}>
|
||||||
rightAction={
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-secondary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<button className={styles.notifyBtn} onClick={() => navigate('/notifications')}>
|
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||||
🔔
|
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||||
{unreadCount > 0 && <Badge count={unreadCount} />}
|
</svg>
|
||||||
|
{unreadCount > 0 && <span className={styles.notifyBadge} />}
|
||||||
</button>
|
</button>
|
||||||
}
|
</div>
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Health Overview */}
|
|
||||||
{bpStats?.latest && hrStats?.latest ? (
|
|
||||||
<Card className={styles.overviewCard}>
|
<Card className={styles.overviewCard}>
|
||||||
<div className={styles.overviewHeader}>
|
<div className={styles.overviewHeader}>
|
||||||
<span className={styles.overviewTitle}>健康概览</span>
|
<span className={styles.overviewTitle}>健康概览</span>
|
||||||
<span className={styles.overviewTime}>最新记录</span>
|
<span className={styles.overviewTime}>最新记录</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.overviewData}>
|
<div className={styles.overviewData}>
|
||||||
<div className={styles.bpSection}>
|
<div className={styles.dataCol}>
|
||||||
<span className={styles.dataLabel}>血压</span>
|
<span className={styles.dataLabel}>血压</span>
|
||||||
|
{systolic ? (
|
||||||
<div className={styles.bpValues}>
|
<div className={styles.bpValues}>
|
||||||
<span className={`${styles.bpNum} ${styles[`risk_${riskLevel}`] || ''}`}>
|
<span className={`${styles.bpNum} ${bpAbnormal ? styles.riskAbnormal : ''}`}>
|
||||||
{systolic}
|
{systolic}
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.bpSep}>/</span>
|
<span className={styles.bpSep}>/</span>
|
||||||
<span className={`${styles.bpNum} ${styles[`risk_${riskLevel}`] || ''}`}>
|
<span className={`${styles.bpNum} ${bpAbnormal ? styles.riskAbnormal : ''}`}>
|
||||||
{diastolic}
|
{diastolic}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
) : <span className={styles.bpNum} style={{ fontSize: 22, opacity: 0.4 }}>--/--</span>}
|
||||||
<span className={styles.unit}>mmHg</span>
|
<span className={styles.unit}>mmHg</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.divider} />
|
<div className={styles.divider} />
|
||||||
<div className={styles.hrSection}>
|
<div className={styles.dataCol}>
|
||||||
<span className={styles.dataLabel}>心率</span>
|
<span className={styles.dataLabel}>心率</span>
|
||||||
<span className={styles.hrNum}>{Number(hrStats.latest.value)}</span>
|
<span className={`${styles.hrNum} ${hrAbnormal ? styles.riskAbnormal : ''}`}>{hrValue ?? '--'}</span>
|
||||||
<span className={styles.unit}>bpm</span>
|
<span className={styles.unit}>bpm</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.divider} />
|
||||||
|
<div className={styles.dataCol}>
|
||||||
|
<span className={styles.dataLabel}>血糖</span>
|
||||||
|
<span className={`${styles.hrNum} ${sugarAbnormal ? styles.riskAbnormal : ''}`}>{sugarValue ?? '--'}</span>
|
||||||
|
<span className={styles.unit}>mmol/L</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.divider} />
|
||||||
|
<div className={styles.dataCol}>
|
||||||
|
<span className={styles.dataLabel}>血氧</span>
|
||||||
|
<span className={`${styles.hrNum} ${spo2Abnormal ? styles.riskAbnormal : ''}`}>{spo2Value ?? '--'}</span>
|
||||||
|
<span className={styles.unit}>%</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
|
||||||
<Empty icon="💓" message="暂无健康数据" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Quick Actions */}
|
|
||||||
<div className={styles.quickActions}>
|
<div className={styles.quickActions}>
|
||||||
{QUICK_ACTIONS.map((action) => (
|
{QUICK_ACTIONS.map((action) => (
|
||||||
<button
|
<button key={action.key} className={styles.quickAction} onClick={() => navigate(action.path)}>
|
||||||
key={action.label}
|
<span className={styles.quickIcon} style={{ background: action.iconBg }}>
|
||||||
className={styles.quickAction}
|
{action.svg}
|
||||||
onClick={() => navigate(action.path)}
|
</span>
|
||||||
>
|
|
||||||
<span className={styles.quickIcon}>{action.icon}</span>
|
|
||||||
<span className={styles.quickLabel}>{action.label}</span>
|
<span className={styles.quickLabel}>{action.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Health Tip */}
|
<div className={styles.medSection}>
|
||||||
<Card
|
<div className={styles.medSectionTitle}>
|
||||||
className={styles.tipCard}
|
<span className={styles.medTitleIcon}>
|
||||||
onClick={() => setTipIndex((prev) => (prev + 1) % HEALTH_TIPS.length)}
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
>
|
<rect x="4" y="5" width="16" height="14" rx="4" />
|
||||||
<div className={styles.tipHeader}>
|
<path d="M10 9v6M14 9v6" />
|
||||||
<span>💡</span>
|
</svg>
|
||||||
<span className={styles.tipTitle}>健康小贴士</span>
|
</span>
|
||||||
<span className={styles.tipHint}>点击换一条</span>
|
今日用药
|
||||||
|
{meds.length > 0 && <span className={styles.medTitleCount}>{meds.length}种</span>}
|
||||||
|
</div>
|
||||||
|
{meds.length === 0 ? (
|
||||||
|
<div className={styles.medEmpty}>暂无用药安排</div>
|
||||||
|
) : (
|
||||||
|
meds.map((med) => (
|
||||||
|
<Card key={med.id} className={styles.medCard} onClick={() => navigate(`/health/medications/${med.id}`)}>
|
||||||
|
<div className={styles.medHeader}>
|
||||||
|
<div className={styles.medNameGroup}>
|
||||||
|
<span className={styles.medName}>{med.drugName}</span>
|
||||||
|
<span className={styles.medDosage}>{med.dosage} · {med.frequency}</span>
|
||||||
|
</div>
|
||||||
|
<span className={`${styles.medStatus} ${med.allTaken ? styles.medStatusDone : med.slots.some(s => s.missed) ? styles.medStatusMissed : styles.medStatusPending}`}>
|
||||||
|
{med.allTaken ? '已完成' : med.slots.some(s => s.missed) ? '有漏服' : '待服用'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.medSlots}>
|
||||||
|
{med.slots.map((slot) => (
|
||||||
|
<div key={slot.time} className={`${styles.medSlot} ${slot.taken ? styles.medSlotTaken : slot.missed ? styles.medSlotMissed : styles.medSlotTodo}`}>
|
||||||
|
<span className={`${styles.medSlotDot} ${slot.taken ? styles.medSlotDotTaken : slot.missed ? styles.medSlotDotMissed : styles.medSlotDotTodo}`} />
|
||||||
|
<svg className={styles.medSlotIcon} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<polyline points="12 6 12 12 16 14" />
|
||||||
|
</svg>
|
||||||
|
{slot.time}
|
||||||
|
{slot.missed && !slot.taken && <span style={{ fontSize: 10, opacity: 0.7, marginLeft: 2 }}>漏服</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className={styles.tipContent}>{HEALTH_TIPS[tipIndex]}</p>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,234 @@
|
|||||||
.infoCard { margin-bottom: 12px; }
|
.infoCard { margin-bottom: 14px; padding: 20px; }
|
||||||
|
|
||||||
.infoTitle { font-size: var(--font-size-lg); font-weight: 700; margin-bottom: 12px; }
|
.heroHeader {
|
||||||
|
|
||||||
.infoRow {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
align-items: flex-start;
|
||||||
padding: 8px 0;
|
gap: 16px;
|
||||||
font-size: var(--font-size-sm);
|
margin-bottom: 20px;
|
||||||
border-bottom: 1px solid var(--color-border-light);
|
}
|
||||||
|
|
||||||
|
.heroIcon {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--color-primary-gradient);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 4px 16px rgba(79,110,247,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroInfo { flex: 1; }
|
||||||
|
|
||||||
|
.heroName {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroMeta {
|
||||||
|
font-size: 13px;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.activeBadge { color: var(--color-success); font-weight: 500; }
|
.infoGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.adherenceCard { text-align: center; }
|
.infoItem {
|
||||||
|
background: var(--color-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.adherenceTitle { font-size: var(--font-size-sm); color: var(--color-text-secondary); margin-bottom: 4px; }
|
.infoLabel {
|
||||||
|
font-size: 11px;
|
||||||
.adherenceRate {
|
color: var(--color-text-tertiary);
|
||||||
font-size: var(--font-size-3xl);
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-success);
|
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.infoValue {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Today tracking */
|
||||||
|
.todayTitle {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todayDate {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slotRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slotRowTaken {
|
||||||
|
background: linear-gradient(135deg, #ECFDF5, #F0FFF4);
|
||||||
|
border: 1px solid #A7F3D0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slotRowPending {
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1.5px dashed var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slotLeft {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slotCircle {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slotCircleTaken {
|
||||||
|
background: var(--color-success);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slotCirclePending {
|
||||||
|
background: var(--color-white);
|
||||||
|
color: var(--color-primary);
|
||||||
|
border: 2px solid var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slotTime {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slotLabel {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todaySummary {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todayProgress {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todayProgressBar {
|
||||||
|
flex: 1;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--color-border);
|
||||||
|
margin: 0 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todayProgressFill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--color-primary-gradient);
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 7-day chart */
|
||||||
|
.chartTitle {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartBars {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartBarWrap {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartBar {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
min-height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartBarFull {
|
||||||
|
background: var(--color-primary-gradient);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartBarPartial {
|
||||||
|
background: linear-gradient(180deg, #4F6EF7 0%, #B8C4FD 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartBarEmpty {
|
||||||
|
background: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartDate {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLegend {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLegendItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLegendDot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLegendDotFull { background: var(--color-primary); }
|
||||||
|
.chartLegendDotPartial { background: #B8C4FD; }
|
||||||
|
.chartLegendDotEmpty { background: var(--color-border); }
|
||||||
|
|||||||
@@ -1,59 +1,174 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
import { PageHeader } from '@/components/layout/PageHeader';
|
||||||
import { Card } from '@/components/common/Card';
|
import { Card } from '@/components/common/Card';
|
||||||
import { Button } from '@/components/common/Button';
|
import { Button } from '@/components/common/Button';
|
||||||
import { PieChart } from '@/components/charts/PieChart';
|
import { ToastContainer, toast } from '@/components/common/Toast';
|
||||||
import * as medicationService from '@/services/medication.service';
|
import * as medicationService from '@/services/medication.service';
|
||||||
import type { Medication, MedicationAdherence } from '@/types';
|
import type { Medication, MedicationRecord } from '@/types';
|
||||||
import styles from './MedicationDetailPage.module.css';
|
import styles from './MedicationDetailPage.module.css';
|
||||||
|
|
||||||
export function MedicationDetailPage() {
|
export function MedicationDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const [med, setMed] = useState<Medication | null>(null);
|
||||||
const [medications, setMedications] = useState<Medication[]>([]);
|
const [records, setRecords] = useState<MedicationRecord[]>([]);
|
||||||
const [adherence, setAdherence] = useState<MedicationAdherence | null>(null);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const load = () => {
|
||||||
medicationService.getMedications().then(setMedications);
|
if (!id) return;
|
||||||
if (id) medicationService.getAdherence(id).then(setAdherence).catch(() => {});
|
medicationService.getMedications().then((meds) => {
|
||||||
}, [id]);
|
const m = meds.find((x) => x.id === id);
|
||||||
|
if (m) setMed(m);
|
||||||
|
});
|
||||||
|
medicationService.getMedicationRecords(id).then(setRecords);
|
||||||
|
};
|
||||||
|
|
||||||
const med = medications.find((m) => m.id === id);
|
useEffect(() => { load(); }, [id]);
|
||||||
|
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const todayRecords = records.filter((r) => r.takenAt?.startsWith(today) || !r.takenAt);
|
||||||
|
const todaySlots = med?.timeSlots || [];
|
||||||
|
|
||||||
|
const handleMarkTaken = async (slot: string) => {
|
||||||
|
if (!id) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await medicationService.markTaken(id, slot);
|
||||||
|
toast('已记录');
|
||||||
|
load();
|
||||||
|
} catch { toast('失败', 'error'); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
if (!med) {
|
if (!med) {
|
||||||
return (
|
return <div className="page--no-tab"><PageHeader title="药品详情" /><div style={{ padding: 40, textAlign: 'center', color: '#9CA3AF' }}>药品不存在</div></div>;
|
||||||
<div className="page--no-tab">
|
}
|
||||||
<PageHeader title="药品详情" />
|
|
||||||
<div style={{ padding: 40, textAlign: 'center', color: '#9CA3AF' }}>药品不存在</div>
|
const slotTaken = (slot: string) => todayRecords.some((r) => r.timeSlot === slot && r.isTaken);
|
||||||
</div>
|
|
||||||
);
|
const last7Days: { date: string; taken: number; total: number }[] = [];
|
||||||
|
for (let i = 6; i >= 0; i--) {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - i);
|
||||||
|
const ds = d.toISOString().split('T')[0];
|
||||||
|
const dayRecords = records.filter((r) => r.takenAt?.startsWith(ds) && r.isTaken);
|
||||||
|
last7Days.push({ date: ds, taken: dayRecords.length, total: todaySlots.length });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page--no-tab">
|
<div className="page--no-tab">
|
||||||
<PageHeader title={med.drugName} />
|
<PageHeader title={med.drugName} />
|
||||||
|
|
||||||
<Card className={styles.infoCard}>
|
<Card className={styles.infoCard}>
|
||||||
<div className={styles.infoTitle}>{med.drugName}</div>
|
<div className={styles.heroHeader}>
|
||||||
<div className={styles.infoRow}><span>剂量</span><span>{med.dosage}</span></div>
|
<div className={styles.heroIcon}>
|
||||||
<div className={styles.infoRow}><span>服用时间</span><span>{med.timeSlots.join(', ')}</span></div>
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<div className={styles.infoRow}><span>日期</span><span>{med.startDate} ~ {med.endDate || '长期'}</span></div>
|
<rect x="4" y="5" width="16" height="14" rx="4" />
|
||||||
<div className={styles.infoRow}><span>状态</span><span className={med.status === 'active' ? styles.activeBadge : ''}>{med.status === 'active' ? '进行中' : '已结束'}</span></div>
|
<path d="M10 9v6M14 9v6" />
|
||||||
{med.notes && <div className={styles.infoRow}><span>备注</span><span>{med.notes}</span></div>}
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className={styles.heroInfo}>
|
||||||
|
<div className={styles.heroName}>{med.drugName}</div>
|
||||||
|
<div className={styles.heroMeta}>{med.dosage} · {med.frequency}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.infoGrid}>
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<div className={styles.infoLabel}>剂量</div>
|
||||||
|
<div className={styles.infoValue}>{med.dosage}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<div className={styles.infoLabel}>频次</div>
|
||||||
|
<div className={styles.infoValue}>{med.frequency}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<div className={styles.infoLabel}>用药时间</div>
|
||||||
|
<div className={styles.infoValue}>{med.timeSlots.join(', ')}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<div className={styles.infoLabel}>有效期</div>
|
||||||
|
<div className={styles.infoValue}>{med.startDate} ~ {med.endDate || '长期'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{adherence && (
|
{med.notes && (
|
||||||
<Card className={styles.adherenceCard}>
|
<Card className={styles.infoCard}>
|
||||||
<div className={styles.adherenceTitle}>近30天依从性</div>
|
<div className={styles.infoLabel} style={{ marginBottom: 4 }}>备注</div>
|
||||||
<div className={styles.adherenceRate}>{adherence.rate}%</div>
|
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.6 }}>{med.notes}</div>
|
||||||
<PieChart
|
|
||||||
data={[
|
|
||||||
{ name: '已服用', value: adherence.rate, color: '#10B981' },
|
|
||||||
{ name: '未服用', value: 100 - adherence.rate, color: '#EF4444' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Card className={styles.infoCard}>
|
||||||
|
<div className={styles.todayTitle}>今日服药</div>
|
||||||
|
<div className={styles.todayDate}>{today}</div>
|
||||||
|
|
||||||
|
{todaySlots.map((slot) => {
|
||||||
|
const taken = slotTaken(slot);
|
||||||
|
return (
|
||||||
|
<div key={slot} className={`${styles.slotRow} ${taken ? styles.slotRowTaken : styles.slotRowPending}`}>
|
||||||
|
<div className={styles.slotLeft}>
|
||||||
|
<div className={`${styles.slotCircle} ${taken ? styles.slotCircleTaken : styles.slotCirclePending}`}>
|
||||||
|
{taken ? '✓' : slot}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className={styles.slotTime}>{slot}</div>
|
||||||
|
<div className={styles.slotLabel}>{taken ? '已服用' : '待服用'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!taken && med.status === 'active' && (
|
||||||
|
<Button size="sm" variant="primary" loading={loading} onClick={() => handleMarkTaken(slot)}>
|
||||||
|
打卡
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className={styles.todaySummary}>
|
||||||
|
<span className={styles.todayProgress}>
|
||||||
|
{todaySlots.filter((s) => slotTaken(s)).length}/{todaySlots.length} 次
|
||||||
|
</span>
|
||||||
|
<div className={styles.todayProgressBar}>
|
||||||
|
<div className={styles.todayProgressFill} style={{
|
||||||
|
width: `${todaySlots.length > 0 ? (todaySlots.filter((s) => slotTaken(s)).length / todaySlots.length) * 100 : 0}%`,
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className={styles.infoCard}>
|
||||||
|
<div className={styles.chartTitle}>近7天记录</div>
|
||||||
|
<div className={styles.chartBars}>
|
||||||
|
{last7Days.map((d) => {
|
||||||
|
const pct = d.total > 0 ? (d.taken / d.total) * 100 : 0;
|
||||||
|
const height = d.total > 0 ? Math.max(8, (d.taken / d.total) * 60) : 8;
|
||||||
|
return (
|
||||||
|
<div key={d.date} className={styles.chartBarWrap}>
|
||||||
|
<div
|
||||||
|
className={`${styles.chartBar} ${pct === 100 ? styles.chartBarFull : pct > 0 ? styles.chartBarPartial : styles.chartBarEmpty}`}
|
||||||
|
style={{ height }}
|
||||||
|
/>
|
||||||
|
<div className={styles.chartDate}>{d.date.slice(5)}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className={styles.chartLegend}>
|
||||||
|
<div className={styles.chartLegendItem}>
|
||||||
|
<span className={`${styles.chartLegendDot} ${styles.chartLegendDotFull}`} /> 全勤
|
||||||
|
</div>
|
||||||
|
<div className={styles.chartLegendItem}>
|
||||||
|
<span className={`${styles.chartLegendDot} ${styles.chartLegendDotPartial}`} /> 漏服
|
||||||
|
</div>
|
||||||
|
<div className={styles.chartLegendItem}>
|
||||||
|
<span className={`${styles.chartLegendDot} ${styles.chartLegendDotEmpty}`} /> 未开始
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ToastContainer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
.sectionLabel {
|
.sectionLabel {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
|||||||
@@ -10,33 +10,33 @@ import styles from './MedicationEditPage.module.css';
|
|||||||
|
|
||||||
export function MedicationEditPage() {
|
export function MedicationEditPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [drugName, setDrugName] = useState('');
|
const [drugName, setDrugName] = useState('');
|
||||||
const [dosage, setDosage] = useState('');
|
const [dosage, setDosage] = useState('');
|
||||||
const [frequency, setFrequency] = useState('每日1次');
|
|
||||||
const [timeSlots, setTimeSlots] = useState(['08:00']);
|
const [timeSlots, setTimeSlots] = useState(['08:00']);
|
||||||
const [startDate, setStartDate] = useState('');
|
const [startDate, setStartDate] = useState('');
|
||||||
const [endDate, setEndDate] = useState('');
|
const [endDate, setEndDate] = useState('');
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const freqLabels: Record<string, string> = {
|
const addTimeSlot = () => setTimeSlots([...timeSlots, '12:00']);
|
||||||
'每日1次': '每日一次', '每日2次': '每日两次', '每日3次': '每日三次',
|
const removeTimeSlot = (i: number) => {
|
||||||
|
if (timeSlots.length <= 1) return;
|
||||||
|
setTimeSlots(timeSlots.filter((_, idx) => idx !== i));
|
||||||
};
|
};
|
||||||
|
const updateTimeSlot = (i: number, val: string) => {
|
||||||
const handleFreqChange = (f: string) => {
|
setTimeSlots(timeSlots.map((s, idx) => idx === i ? val : s));
|
||||||
setFrequency(f);
|
|
||||||
if (f === '每日1次' || f === 'once_daily') setTimeSlots(['08:00']);
|
|
||||||
else if (f === '每日2次' || f === 'twice_daily') setTimeSlots(['08:00', '20:00']);
|
|
||||||
else setTimeSlots(['08:00', '14:00', '20:00']);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!drugName || !dosage) { toast('请填写药品名和剂量', 'error'); return; }
|
if (!drugName || !dosage) { toast('请填写药品名和剂量', 'error'); return; }
|
||||||
|
if (timeSlots.length === 0) { toast('请设置至少一个服药时间', 'error'); return; }
|
||||||
|
const sorted = [...timeSlots].sort();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await medicationService.addMedication({
|
await medicationService.addMedication({
|
||||||
drugName, dosage, frequency, timeSlots,
|
drugName, dosage,
|
||||||
|
frequency: `每日${sorted.length}次`,
|
||||||
|
timeSlots: sorted,
|
||||||
startDate: startDate || new Date().toISOString().slice(0, 10),
|
startDate: startDate || new Date().toISOString().slice(0, 10),
|
||||||
endDate: endDate || undefined,
|
endDate: endDate || undefined,
|
||||||
notes,
|
notes,
|
||||||
@@ -44,9 +44,7 @@ export function MedicationEditPage() {
|
|||||||
});
|
});
|
||||||
toast('添加成功');
|
toast('添加成功');
|
||||||
navigate(-1);
|
navigate(-1);
|
||||||
} finally {
|
} finally { setLoading(false); }
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -57,45 +55,37 @@ export function MedicationEditPage() {
|
|||||||
<label className={styles.sectionLabel}>药品名称</label>
|
<label className={styles.sectionLabel}>药品名称</label>
|
||||||
<div className={styles.drugGrid}>
|
<div className={styles.drugGrid}>
|
||||||
{COMMON_DRUGS.slice(0, 6).map((d) => (
|
{COMMON_DRUGS.slice(0, 6).map((d) => (
|
||||||
<button
|
<button key={d} className={`${styles.drugChip} ${drugName === d ? styles.drugChipActive : ''}`} onClick={() => setDrugName(d)}>{d}</button>
|
||||||
key={d}
|
|
||||||
className={`${styles.drugChip} ${drugName === d ? styles.drugChipActive : ''}`}
|
|
||||||
onClick={() => setDrugName(d)}
|
|
||||||
>
|
|
||||||
{d}
|
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Input placeholder="或手动输入药品名" value={drugName} onChange={(e) => setDrugName(e.target.value)} />
|
<Input placeholder="或手动输入" value={drugName} onChange={(e) => setDrugName(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Input label="剂量 (如 100mg)" value={dosage} onChange={(e) => setDosage(e.target.value)} placeholder="100mg" />
|
<Input label="剂量" value={dosage} onChange={(e) => setDosage(e.target.value)} placeholder="如 100mg" />
|
||||||
|
|
||||||
<div className={styles.section}>
|
<div className={styles.section}>
|
||||||
<label className={styles.sectionLabel}>服用频次</label>
|
<label className={styles.sectionLabel}>服药时间</label>
|
||||||
<div className={styles.freqRow}>
|
{timeSlots.map((slot, i) => (
|
||||||
{(['每日1次', '每日2次', '每日3次'] as const).map((f) => (
|
<div key={i} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
|
||||||
<button
|
<input type="time" value={slot} onChange={(e) => updateTimeSlot(i, e.target.value)}
|
||||||
key={f}
|
style={{ flex: 1, padding: '8px 12px', border: '1px solid #ddd', borderRadius: 8, fontSize: 14, fontFamily: 'inherit' }} />
|
||||||
className={`${styles.freqBtn} ${frequency === f ? styles.freqActive : ''}`}
|
<button onClick={() => removeTimeSlot(i)} disabled={timeSlots.length <= 1}
|
||||||
onClick={() => handleFreqChange(f)}
|
style={{ background: 'none', border: 'none', color: '#EF4444', fontSize: 18, cursor: 'pointer', padding: 4, fontWeight: 700 }}>×</button>
|
||||||
>
|
|
||||||
{freqLabels[f]}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
<button onClick={addTimeSlot} style={{ padding: '6px 14px', border: '1px dashed #2563EB', borderRadius: 8, background: 'none', color: '#2563EB', fontSize: 13, cursor: 'pointer' }}>
|
||||||
|
+ 添加时间点
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.row}>
|
<div className={styles.row}>
|
||||||
<Input label="开始日期" value={startDate} onChange={(e) => setStartDate(e.target.value)} type="date" />
|
<Input label="开始日期" value={startDate} onChange={(e) => setStartDate(e.target.value)} type="date" />
|
||||||
<Input label="结束日期" value={endDate} onChange={(e) => setEndDate(e.target.value)} type="date" />
|
<Input label="结束日期(可选)" value={endDate} onChange={(e) => setEndDate(e.target.value)} type="date" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Input label="备注 (如饭后服用)" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
<Input label="备注" value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="如:饭后服用" />
|
||||||
|
|
||||||
<Button variant="primary" size="lg" fullWidth loading={loading} onClick={handleSubmit}>
|
<Button variant="primary" size="lg" fullWidth loading={loading} onClick={handleSubmit}>保存</Button>
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,44 +1,182 @@
|
|||||||
.tabs {
|
.tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 16px;
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
padding: 6px 16px;
|
flex: 1;
|
||||||
border-radius: var(--radius-full);
|
padding: 10px 0;
|
||||||
font-size: var(--font-size-sm);
|
border-radius: 10px;
|
||||||
background: var(--color-bg-secondary);
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabActive {
|
.tabActive {
|
||||||
background: var(--color-primary);
|
background: var(--color-white);
|
||||||
color: var(--color-text-inverse);
|
color: var(--color-primary);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.medCard { margin-bottom: 8px; }
|
.medCard {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 18px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medCard::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 4px;
|
||||||
|
border-radius: 4px 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medCardActive::before {
|
||||||
|
background: var(--color-primary-gradient);
|
||||||
|
}
|
||||||
|
|
||||||
|
.medCardEnded::before {
|
||||||
|
background: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
.medHeader {
|
.medHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
justify-content: space-between;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.medName { font-size: var(--font-size-base); font-weight: 600; }
|
.medName {
|
||||||
.medDosage { font-size: var(--font-size-sm); color: var(--color-text-secondary); }
|
font-size: 16px;
|
||||||
.medNote { font-size: var(--font-size-xs); color: var(--color-text-tertiary); margin-top: 4px; }
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.medStatus {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medStatusActive {
|
||||||
|
background: var(--color-primary-bg);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.medStatusEnded {
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.medMeta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medDosage {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.medFrequency {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
background: var(--color-bg);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medSlots {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medSlot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.medSlotTaken {
|
||||||
|
background: var(--color-success-bg);
|
||||||
|
color: #0D8A5E;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medSlotDot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.medSlotDotTaken {
|
||||||
|
background: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.medNote {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--color-divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteBtn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteBtn:active {
|
||||||
|
background: var(--color-danger-bg);
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
.fab {
|
.fab {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 80px;
|
bottom: 80px;
|
||||||
right: max(16px, calc((100vw - var(--max-content-width)) / 2 + 16px));
|
right: max(16px, calc((100vw - var(--max-content-width)) / 2 + 16px));
|
||||||
padding: 12px 20px;
|
width: 52px;
|
||||||
background: var(--color-primary);
|
height: 52px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--color-primary-gradient);
|
||||||
color: var(--color-text-inverse);
|
color: var(--color-text-inverse);
|
||||||
border-radius: var(--radius-full);
|
font-size: 22px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: 0 4px 16px rgba(79,110,247,0.35);
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:active {
|
||||||
|
transform: scale(0.92);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,93 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
import { PageHeader } from '@/components/layout/PageHeader';
|
||||||
import { Card } from '@/components/common/Card';
|
import { Card } from '@/components/common/Card';
|
||||||
import { Empty } from '@/components/common/Empty';
|
import { Empty } from '@/components/common/Empty';
|
||||||
import { Badge } from '@/components/common/Badge';
|
import { ToastContainer, toast } from '@/components/common/Toast';
|
||||||
import * as medicationService from '@/services/medication.service';
|
import * as medicationService from '@/services/medication.service';
|
||||||
import type { Medication } from '@/types';
|
import type { Medication } from '@/types';
|
||||||
import styles from './MedicationListPage.module.css';
|
import styles from './MedicationListPage.module.css';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
export function MedicationListPage() {
|
export function MedicationListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [medications, setMedications] = useState<Medication[]>([]);
|
const [medications, setMedications] = useState<Medication[]>([]);
|
||||||
const [tab, setTab] = useState<'active' | 'completed'>('active');
|
const [tab, setTab] = useState<'active' | 'ended'>('active');
|
||||||
|
|
||||||
useEffect(() => {
|
const load = () => { medicationService.getMedications().then(setMedications); };
|
||||||
medicationService.getMedications().then(setMedications);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const filtered = medications.filter((m) =>
|
useEffect(() => { load(); }, []);
|
||||||
tab === 'active' ? m.status === 'active' : m.status === 'completed',
|
|
||||||
);
|
const handleDelete = async (e: React.MouseEvent, medId: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
await medicationService.deleteMedication(medId);
|
||||||
|
toast('已删除');
|
||||||
|
load();
|
||||||
|
} catch { toast('删除失败', 'error'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = medications.filter((m) => tab === 'active' ? m.status === 'active' : m.status !== 'active');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page--no-tab">
|
<div className="page--no-tab">
|
||||||
<PageHeader title="服药管理" />
|
<PageHeader title="我的用药" />
|
||||||
<div className={styles.tabs}>
|
<div className={styles.tabs}>
|
||||||
<button className={`${styles.tab} ${tab === 'active' ? styles.tabActive : ''}`} onClick={() => setTab('active')}>进行中</button>
|
<button className={`${styles.tab} ${tab === 'active' ? styles.tabActive : ''}`} onClick={() => setTab('active')}>进行中</button>
|
||||||
<button className={`${styles.tab} ${tab === 'completed' ? styles.tabActive : ''}`} onClick={() => setTab('completed')}>已结束</button>
|
<button className={`${styles.tab} ${tab === 'ended' ? styles.tabActive : ''}`} onClick={() => setTab('ended')}>已结束</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<Empty icon="💊" message={tab === 'active' ? '暂无进行中的用药' : '暂无已结束的用药'} />
|
<Empty message={tab === 'active' ? '暂无进行中的用药' : '暂无已结束的用药'} />
|
||||||
) : (
|
) : (
|
||||||
filtered.map((med) => (
|
filtered.map((med) => (
|
||||||
<Card key={med.id} className={styles.medCard} onClick={() => navigate(`/health/medications/${med.id}`)}>
|
<Card
|
||||||
|
key={med.id}
|
||||||
|
className={`${styles.medCard} ${med.status === 'active' ? styles.medCardActive : styles.medCardEnded}`}
|
||||||
|
onClick={() => navigate(`/health/medications/${med.id}`)}
|
||||||
|
>
|
||||||
<div className={styles.medHeader}>
|
<div className={styles.medHeader}>
|
||||||
<span className={styles.medName}>{med.drugName}</span>
|
<span className={styles.medName}>{med.drugName}</span>
|
||||||
{med.status === 'active' && <Badge dot />}
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span className={`${styles.medStatus} ${med.status === 'active' ? styles.medStatusActive : styles.medStatusEnded}`}>
|
||||||
|
{med.status === 'active' ? '进行中' : '已结束'}
|
||||||
|
</span>
|
||||||
|
<button className={styles.deleteBtn} onClick={(e) => handleDelete(e, med.id)}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.medDosage}>{med.dosage} · {med.timeSlots.join(', ')}</div>
|
</div>
|
||||||
<div className={styles.medNote}>{med.notes}</div>
|
<div className={styles.medMeta}>
|
||||||
|
<span className={styles.medDosage}>{med.dosage}</span>
|
||||||
|
<span className={styles.medFrequency}>{med.frequency}</span>
|
||||||
|
</div>
|
||||||
|
{med.timeSlots && med.timeSlots.length > 0 && (
|
||||||
|
<div className={styles.medSlots}>
|
||||||
|
{med.timeSlots.map((slot) => {
|
||||||
|
const record = med.records?.find((r) => r.timeSlot === slot);
|
||||||
|
const taken = record?.isTaken;
|
||||||
|
return (
|
||||||
|
<div key={slot} className={`${styles.medSlot} ${taken ? styles.medSlotTaken : ''}`}>
|
||||||
|
<span className={`${styles.medSlotDot} ${taken ? styles.medSlotDotTaken : ''}`} />
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<polyline points="12 6 12 12 16 14" />
|
||||||
|
</svg>
|
||||||
|
{slot}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{med.notes && <div className={styles.medNote}>{med.notes}</div>}
|
||||||
</Card>
|
</Card>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button className={styles.fab} onClick={() => navigate('/health/medications/add')}>
|
<button className={styles.fab} onClick={() => navigate('/health/medications/add')}>+</button>
|
||||||
+ 添加药品
|
<ToastContainer />
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
import { PageHeader } from '@/components/layout/PageHeader';
|
||||||
import { Card } from '@/components/common/Card';
|
import { Card } from '@/components/common/Card';
|
||||||
import { Empty } from '@/components/common/Empty';
|
import { Empty } from '@/components/common/Empty';
|
||||||
import { useNotificationStore } from '@/stores/notification.store';
|
import { useNotificationStore } from '@/stores/notification.store';
|
||||||
import { formatRelative } from '@/utils/format';
|
import { formatRelative } from '@/utils/format';
|
||||||
import type { NotificationType } from '@/types';
|
import type { NotificationType, Notification } from '@/types';
|
||||||
import styles from './NotificationListPage.module.css';
|
import styles from './NotificationListPage.module.css';
|
||||||
|
|
||||||
const TYPE_TABS: { key: NotificationType | 'all'; label: string }[] = [
|
const TYPE_TABS: { key: NotificationType | 'all'; label: string }[] = [
|
||||||
@@ -15,7 +16,23 @@ const TYPE_TABS: { key: NotificationType | 'all'; label: string }[] = [
|
|||||||
{ key: 'system', label: '系统' },
|
{ key: 'system', label: '系统' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function getNavPath(n: Notification): string | null {
|
||||||
|
switch (n.type) {
|
||||||
|
case 'consultation':
|
||||||
|
return '/services/consultation';
|
||||||
|
case 'medication':
|
||||||
|
return '/health/medications';
|
||||||
|
case 'followup':
|
||||||
|
return '/services/follow-ups';
|
||||||
|
case 'report':
|
||||||
|
return n.relatedId ? `/services/reports/${n.relatedId}` : '/services/reports';
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function NotificationListPage() {
|
export function NotificationListPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const { notifications, unreadCount, fetchNotifications, markRead, markAllRead } = useNotificationStore();
|
const { notifications, unreadCount, fetchNotifications, markRead, markAllRead } = useNotificationStore();
|
||||||
const [tab, setTab] = useState<NotificationType | 'all'>('all');
|
const [tab, setTab] = useState<NotificationType | 'all'>('all');
|
||||||
|
|
||||||
@@ -27,6 +44,12 @@ export function NotificationListPage() {
|
|||||||
? notifications
|
? notifications
|
||||||
: notifications.filter((n) => n.type === tab);
|
: notifications.filter((n) => n.type === tab);
|
||||||
|
|
||||||
|
const handleClick = (n: Notification) => {
|
||||||
|
if (!n.isRead) markRead(n.id);
|
||||||
|
const path = getNavPath(n);
|
||||||
|
if (path) navigate(path);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page--no-tab">
|
<div className="page--no-tab">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -53,13 +76,13 @@ export function NotificationListPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<Empty icon="🔔" message="暂无通知" />
|
<Empty message="暂无通知" />
|
||||||
) : (
|
) : (
|
||||||
filtered.map((n) => (
|
filtered.map((n) => (
|
||||||
<Card
|
<Card
|
||||||
key={n.id}
|
key={n.id}
|
||||||
className={`${styles.notifCard} ${!n.isRead ? styles.unread : ''}`}
|
className={`${styles.notifCard} ${!n.isRead ? styles.unread : ''}`}
|
||||||
onClick={() => { if (!n.isRead) markRead(n.id); }}
|
onClick={() => handleClick(n)}
|
||||||
>
|
>
|
||||||
<div className={styles.notifHeader}>
|
<div className={styles.notifHeader}>
|
||||||
<span className={styles.notifTitle}>{n.title}</span>
|
<span className={styles.notifTitle}>{n.title}</span>
|
||||||
|
|||||||
@@ -15,18 +15,18 @@ export function EditProfilePage() {
|
|||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [gender, setGender] = useState('');
|
const [gender, setGender] = useState('');
|
||||||
const [birthday, setBirthday] = useState('');
|
const [birthday, setBirthday] = useState('');
|
||||||
const [height, setHeight] = useState('');
|
|
||||||
const [weight, setWeight] = useState('');
|
|
||||||
const [history, setHistory] = useState('');
|
const [history, setHistory] = useState('');
|
||||||
|
const [stentDate, setStentDate] = useState('');
|
||||||
|
const [stentType, setStentType] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
setName(user.nickname || '');
|
setName(user.nickname || '');
|
||||||
setGender(user.gender || '');
|
setGender(user.gender || '');
|
||||||
setBirthday(user.birthday || '');
|
setBirthday(user.birthday || '');
|
||||||
setHeight(user.height ? String(user.height) : '');
|
|
||||||
setWeight(user.weight ? String(user.weight) : '');
|
|
||||||
setHistory((user.medicalHistory || []).join('、'));
|
setHistory((user.medicalHistory || []).join('、'));
|
||||||
|
setStentDate(user.stentImplantDate || '');
|
||||||
|
setStentType(user.stentType || '');
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
@@ -37,18 +37,18 @@ export function EditProfilePage() {
|
|||||||
name: name || undefined,
|
name: name || undefined,
|
||||||
gender: gender || undefined,
|
gender: gender || undefined,
|
||||||
birthday: birthday || undefined,
|
birthday: birthday || undefined,
|
||||||
heightCm: height ? Number(height) : undefined,
|
|
||||||
weightKg: weight ? Number(weight) : undefined,
|
|
||||||
medicalHistory: history ? history.split(/[、,,]/).filter(Boolean) : undefined,
|
medicalHistory: history ? history.split(/[、,,]/).filter(Boolean) : undefined,
|
||||||
|
stentDate: stentDate || undefined,
|
||||||
|
stentType: stentType || undefined,
|
||||||
};
|
};
|
||||||
await authService.updateProfile(data);
|
await authService.updateProfile(data);
|
||||||
updateProfile({
|
updateProfile({
|
||||||
nickname: name,
|
nickname: name,
|
||||||
gender: gender as 'male' | 'female' | 'unknown',
|
gender: gender as 'male' | 'female' | 'unknown',
|
||||||
birthday,
|
birthday,
|
||||||
height: height ? Number(height) : 0,
|
|
||||||
weight: weight ? Number(weight) : 0,
|
|
||||||
medicalHistory: history ? history.split(/[、,,]/).filter(Boolean) : [],
|
medicalHistory: history ? history.split(/[、,,]/).filter(Boolean) : [],
|
||||||
|
stentImplantDate: stentDate,
|
||||||
|
stentType,
|
||||||
});
|
});
|
||||||
toast('保存成功');
|
toast('保存成功');
|
||||||
setTimeout(() => navigate(-1), 800);
|
setTimeout(() => navigate(-1), 800);
|
||||||
@@ -92,17 +92,6 @@ export function EditProfilePage() {
|
|||||||
<input className={styles.input} type="date" value={birthday} onChange={(e) => setBirthday(e.target.value)} />
|
<input className={styles.input} type="date" value={birthday} onChange={(e) => setBirthday(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.row}>
|
|
||||||
<div className={styles.field}>
|
|
||||||
<label className={styles.label}>身高 (cm)</label>
|
|
||||||
<input className={styles.input} type="number" value={height} onChange={(e) => setHeight(e.target.value)} placeholder="170" />
|
|
||||||
</div>
|
|
||||||
<div className={styles.field}>
|
|
||||||
<label className={styles.label}>体重 (kg)</label>
|
|
||||||
<input className={styles.input} type="number" value={weight} onChange={(e) => setWeight(e.target.value)} placeholder="70" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.field}>
|
<div className={styles.field}>
|
||||||
<label className={styles.label}>既往病史(用顿号分隔)</label>
|
<label className={styles.label}>既往病史(用顿号分隔)</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -114,6 +103,16 @@ export function EditProfilePage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label className={styles.label}>支架植入日期</label>
|
||||||
|
<input className={styles.input} type="date" value={stentDate} onChange={(e) => setStentDate(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label className={styles.label}>支架类型</label>
|
||||||
|
<input className={styles.input} value={stentType} onChange={(e) => setStentType(e.target.value)} placeholder="如:药物洗脱支架(DES)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button variant="primary" size="lg" fullWidth loading={loading} onClick={handleSave}>
|
<Button variant="primary" size="lg" fullWidth loading={loading} onClick={handleSave}>
|
||||||
保存
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
191
frontend-patient/src/pages/profile/HealthRecordPage.module.css
Normal file
191
frontend-patient/src/pages/profile/HealthRecordPage.module.css
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
.profileCard {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--color-primary-gradient);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profileInfo {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
font-size: 24px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailCard {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardTitle {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin-bottom: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardTitle::before {
|
||||||
|
content: '';
|
||||||
|
width: 4px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardCount {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
background: var(--color-bg);
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyTags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyTag {
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: var(--color-primary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stentInfo {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicatorsGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicatorItem {
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px 8px;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicatorValue {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicatorLabel {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicatorUnit {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medItem {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid var(--color-divider);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.medItem:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medName {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.medMeta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reportItem {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid var(--color-divider);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.reportItem:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reportName {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reportStatus {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reportDone {
|
||||||
|
background: var(--color-success-bg);
|
||||||
|
color: #0D8A5E;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reportPending {
|
||||||
|
background: var(--color-warning-bg);
|
||||||
|
color: #D67E0B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyText {
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
125
frontend-patient/src/pages/profile/HealthRecordPage.tsx
Normal file
125
frontend-patient/src/pages/profile/HealthRecordPage.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { PageHeader } from '@/components/layout/PageHeader';
|
||||||
|
import { Card } from '@/components/common/Card';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { api } from '@/services/api-client';
|
||||||
|
import * as healthService from '@/services/health.service';
|
||||||
|
import type { HealthStats, Report, Medication } from '@/types';
|
||||||
|
import styles from './HealthRecordPage.module.css';
|
||||||
|
|
||||||
|
export function HealthRecordPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [stats, setStats] = useState<HealthStats[]>([]);
|
||||||
|
const [reports, setReports] = useState<Report[]>([]);
|
||||||
|
const [meds, setMeds] = useState<Medication[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
healthService.getLatestStats().then(setStats).catch(() => {});
|
||||||
|
api.get<Report[]>('/api/reports').then((r) => setReports(r.data.slice(0, 5))).catch(() => {});
|
||||||
|
api.get<Medication[]>('/api/medications').then((r) => {
|
||||||
|
setMeds(r.data.filter((m: Medication) => m.status === 'active'));
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const bp = stats.find((s) => s.type === 'blood_pressure');
|
||||||
|
const hr = stats.find((s) => s.type === 'heart_rate');
|
||||||
|
const sugar = stats.find((s) => s.type === 'blood_sugar');
|
||||||
|
const spo2 = stats.find((s) => s.type === 'spo2');
|
||||||
|
const weight = stats.find((s) => s.type === 'weight');
|
||||||
|
|
||||||
|
const bpVal = bp?.latest?.value;
|
||||||
|
const systolic = typeof bpVal === 'object' ? (bpVal as Record<string,number>).systolic : null;
|
||||||
|
const diastolic = typeof bpVal === 'object' ? (bpVal as Record<string,number>).diastolic : null;
|
||||||
|
|
||||||
|
const indicators = [
|
||||||
|
{ label: '血压', value: systolic ? `${systolic}/${diastolic}` : '--', unit: 'mmHg', color: '#EF4444' },
|
||||||
|
{ label: '心率', value: hr?.latest?.value ?? '--', unit: 'bpm', color: '#F59E0B' },
|
||||||
|
{ label: '血糖', value: sugar?.latest?.value ?? '--', unit: 'mmol/L', color: '#4F6EF7' },
|
||||||
|
{ label: '血氧', value: spo2?.latest?.value ?? '--', unit: '%', color: '#20C997' },
|
||||||
|
{ label: '体重', value: weight?.latest?.value ?? '--', unit: 'kg', color: '#845EF7' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page--no-tab">
|
||||||
|
<PageHeader title="健康档案" />
|
||||||
|
|
||||||
|
<Card className={styles.profileCard} onClick={() => navigate('/profile/edit')}>
|
||||||
|
<div className={styles.avatar}>{user?.nickname?.[0] || '用'}</div>
|
||||||
|
<div className={styles.profileInfo}>
|
||||||
|
<div className={styles.name}>{user?.nickname || '用户'}</div>
|
||||||
|
<div className={styles.phone}>{user?.phone}</div>
|
||||||
|
</div>
|
||||||
|
<span className={styles.arrow}>›</span>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{user?.medicalHistory && user.medicalHistory.length > 0 && (
|
||||||
|
<Card className={styles.detailCard}>
|
||||||
|
<div className={styles.cardTitle}>既往病史</div>
|
||||||
|
<div className={styles.historyTags}>
|
||||||
|
{user.medicalHistory.map((h, i) => (
|
||||||
|
<span key={i} className={styles.historyTag}>{h}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{user.stentImplantDate && (
|
||||||
|
<div className={styles.stentInfo}>
|
||||||
|
支架植入:{user.stentImplantDate}{user.stentType ? ` · ${user.stentType}` : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card className={styles.detailCard}>
|
||||||
|
<div className={styles.cardTitle}>健康指标</div>
|
||||||
|
<div className={styles.indicatorsGrid}>
|
||||||
|
{indicators.map((item) => (
|
||||||
|
<div key={item.label} className={styles.indicatorItem}>
|
||||||
|
<div className={styles.indicatorValue} style={{ color: item.color }}>
|
||||||
|
{item.value}
|
||||||
|
</div>
|
||||||
|
<div className={styles.indicatorLabel}>
|
||||||
|
{item.label}<span className={styles.indicatorUnit}>{item.unit}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className={styles.detailCard}>
|
||||||
|
<div className={styles.cardTitle}>
|
||||||
|
用药记录
|
||||||
|
<span className={styles.cardCount}>{meds.length}种</span>
|
||||||
|
</div>
|
||||||
|
{meds.length === 0 ? (
|
||||||
|
<div className={styles.emptyText}>暂无用药</div>
|
||||||
|
) : (
|
||||||
|
meds.map((m) => (
|
||||||
|
<div key={m.id} className={styles.medItem} onClick={() => navigate(`/health/medications/${m.id}`)}>
|
||||||
|
<span className={styles.medName}>{m.drugName}</span>
|
||||||
|
<span className={styles.medMeta}>{m.dosage} · {m.timeSlots?.join(', ')}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className={styles.detailCard}>
|
||||||
|
<div className={styles.cardTitle}>
|
||||||
|
检查报告
|
||||||
|
<span className={styles.cardCount}>{reports.length}份</span>
|
||||||
|
</div>
|
||||||
|
{reports.length === 0 ? (
|
||||||
|
<div className={styles.emptyText}>暂无报告</div>
|
||||||
|
) : (
|
||||||
|
reports.map((r: Record<string, unknown>, i: number) => (
|
||||||
|
<div key={i} className={styles.reportItem} onClick={() => navigate(`/services/reports/${r.id}`)}>
|
||||||
|
<span className={styles.reportName}>{r.title as string}</span>
|
||||||
|
<span className={`${styles.reportStatus} ${r.status === 'completed' ? styles.reportDone : styles.reportPending}`}>
|
||||||
|
{r.status === 'completed' ? '已解读' : '待审核'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,66 +2,131 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 20px;
|
||||||
|
background: var(--color-primary-gradient);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: 24px 20px;
|
||||||
|
box-shadow: 0 8px 30px rgba(79,110,247,0.3);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profileCard::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
right: -30px;
|
||||||
|
top: -30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 56px; height: 56px;
|
width: 60px;
|
||||||
border-radius: 50%;
|
height: 60px;
|
||||||
background: var(--color-primary-bg);
|
border-radius: 20px;
|
||||||
color: var(--color-primary);
|
background: rgba(255,255,255,0.2);
|
||||||
|
color: #fff;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: var(--font-size-xl);
|
font-size: 24px;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nickname { font-size: var(--font-size-lg); font-weight: 600; }
|
.profileInfo { flex: 1; position: relative; z-index: 1; }
|
||||||
.phone { font-size: var(--font-size-xs); color: var(--color-text-tertiary); margin-top: 2px; }
|
|
||||||
|
|
||||||
.statsCard {
|
.nickname { font-size: 20px; font-weight: 800; }
|
||||||
display: flex;
|
.phone { font-size: 13px; opacity: 0.7; margin-top: 3px; }
|
||||||
|
|
||||||
|
.editBadge {
|
||||||
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-around;
|
gap: 2px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuSection {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat { text-align: center; }
|
.menuSectionTitle {
|
||||||
.statValue { font-size: var(--font-size-sm); font-weight: 600; display: block; }
|
font-size: 12px;
|
||||||
.statLabel { font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
|
font-weight: 600;
|
||||||
.statDivider { width: 1px; height: 32px; background: var(--color-border); }
|
color: var(--color-text-tertiary);
|
||||||
|
padding: 0 4px 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
.menuList {
|
.menuList {
|
||||||
background: var(--color-white);
|
background: var(--color-white);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-xl);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.menuItem {
|
.menuItem {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 14px 16px;
|
padding: 16px 18px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: var(--font-size-base);
|
font-size: 15px;
|
||||||
border-bottom: 1px solid var(--color-border-light);
|
font-weight: 500;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
border-bottom: 1px solid var(--color-divider);
|
||||||
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menuItem:last-child { border-bottom: none; }
|
.menuItem:last-child { border-bottom: none; }
|
||||||
.menuItem:active { background: var(--color-bg); }
|
.menuItem:active { background: var(--color-bg); }
|
||||||
|
|
||||||
.menuRight { display: flex; align-items: center; gap: 8px; }
|
.menuItemLeft {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuIcon {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuArrow {
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuBadge {
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.logoutBtn {
|
.logoutBtn {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 14px;
|
padding: 15px;
|
||||||
background: var(--color-white);
|
background: var(--color-white);
|
||||||
color: var(--color-danger);
|
color: var(--color-danger);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-xl);
|
||||||
font-size: var(--font-size-base);
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
|
margin-top: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logoutBtn:active { background: var(--color-danger-bg); }
|
||||||
|
|||||||
@@ -30,46 +30,67 @@ export function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className={styles.statsCard}>
|
|
||||||
<div className={styles.stat}>
|
|
||||||
<span className={styles.statValue}>{user?.height || '-'}cm</span>
|
|
||||||
<span className={styles.statLabel}>身高</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.statDivider} />
|
|
||||||
<div className={styles.stat}>
|
|
||||||
<span className={styles.statValue}>{user?.weight || '-'}kg</span>
|
|
||||||
<span className={styles.statLabel}>体重</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.statDivider} />
|
|
||||||
<div className={styles.stat}>
|
|
||||||
<span className={styles.statValue}>{user?.medicalHistory?.join('、') || '-'}</span>
|
|
||||||
<span className={styles.statLabel}>病史</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className={styles.menuList}>
|
<div className={styles.menuList}>
|
||||||
<button className={styles.menuItem} onClick={() => navigate('/health/medications')}>
|
<button className={styles.menuItem} onClick={() => navigate('/profile/health-record')}>
|
||||||
<span>💊 我的用药</span>
|
<span className={styles.menuItemLeft}>
|
||||||
<span>→</span>
|
<span className={styles.menuIcon} style={{ background: 'var(--color-primary-bg)' }}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
健康档案
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button className={styles.menuItem} onClick={() => navigate('/notifications')}>
|
<button className={styles.menuItem} onClick={() => navigate('/notifications')}>
|
||||||
<span>🔔 消息通知</span>
|
<span className={styles.menuItemLeft}>
|
||||||
<div className={styles.menuRight}>
|
<span className={styles.menuIcon} style={{ background: '#EFF6FF' }}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#339AF0" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||||
|
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
消息通知
|
||||||
|
</span>
|
||||||
|
<span className={styles.menuArrow}>
|
||||||
{unreadCount > 0 && <Badge count={unreadCount} />}
|
{unreadCount > 0 && <Badge count={unreadCount} />}
|
||||||
<span>→</span>
|
</span>
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
<button className={styles.menuItem} onClick={() => navigate('/home/device-binding')}>
|
<button className={styles.menuItem} onClick={() => navigate('/home/device-binding')}>
|
||||||
<span>📡 设备管理</span>
|
<span className={styles.menuItemLeft}>
|
||||||
<span>→</span>
|
<span className={styles.menuIcon} style={{ background: '#F3E8FF' }}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#845EF7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
|
||||||
|
<line x1="12" y1="18" x2="12.01" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
设备管理
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button className={styles.menuItem} onClick={() => navigate('/profile/settings')}>
|
<button className={styles.menuItem} onClick={() => navigate('/profile/settings')}>
|
||||||
<span>⚙️ 设置</span>
|
<span className={styles.menuItemLeft}>
|
||||||
<span>→</span>
|
<span className={styles.menuIcon} style={{ background: '#EDF0FD' }}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#4F6EF7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
设置
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button className={styles.menuItem} onClick={() => navigate('/profile/settings/about')}>
|
<button className={styles.menuItem} onClick={() => navigate('/profile/settings/about')}>
|
||||||
<span>ℹ️ 关于</span>
|
<span className={styles.menuItemLeft}>
|
||||||
<span>→</span>
|
<span className={styles.menuIcon} style={{ background: '#E6F9F2' }}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#20C997" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="12" y1="16" x2="12" y2="12" />
|
||||||
|
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
关于
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,11 @@ export function AboutPage() {
|
|||||||
<div className="page--no-tab">
|
<div className="page--no-tab">
|
||||||
<PageHeader title="关于" />
|
<PageHeader title="关于" />
|
||||||
<div style={{ textAlign: 'center', padding: '40px 20px' }}>
|
<div style={{ textAlign: 'center', padding: '40px 20px' }}>
|
||||||
<div style={{ fontSize: 56 }}>💙</div>
|
<div style={{ fontSize: 56 }}>
|
||||||
|
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="#4F6EF7" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
<div style={{ fontSize: '18px', fontWeight: 700, marginTop: 12 }}>健康管家 Demo</div>
|
<div style={{ fontSize: '18px', fontWeight: 700, marginTop: 12 }}>健康管家 Demo</div>
|
||||||
<div style={{ fontSize: '13px', color: '#9CA3AF', marginTop: 4 }}>v1.0.0-demo</div>
|
<div style={{ fontSize: '13px', color: '#9CA3AF', marginTop: 4 }}>v1.0.0-demo</div>
|
||||||
<div style={{ fontSize: '13px', color: '#6B7280', marginTop: 16 }}>移动端 H5 Web Demo</div>
|
<div style={{ fontSize: '13px', color: '#6B7280', marginTop: 16 }}>移动端 H5 Web Demo</div>
|
||||||
|
|||||||
@@ -1,80 +1,135 @@
|
|||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
import { PageHeader } from '@/components/layout/PageHeader';
|
||||||
|
import { HubConnectionBuilder, HubConnection, HubConnectionState } from '@microsoft/signalr';
|
||||||
|
import { api } from '@/services/api-client';
|
||||||
import * as consultationService from '@/services/consultation.service';
|
import * as consultationService from '@/services/consultation.service';
|
||||||
import type { Consultation, ConsultationMessage, Doctor } from '@/types';
|
import type { Consultation, ConsultationMessage, Doctor } from '@/types';
|
||||||
import { formatRelative } from '@/utils/format';
|
import { formatRelative } from '@/utils/format';
|
||||||
import styles from './ChatPage.module.css';
|
import styles from './ChatPage.module.css';
|
||||||
|
|
||||||
|
function getToken(): string {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('hrt_auth');
|
||||||
|
if (!raw) return '';
|
||||||
|
const state = JSON.parse(raw);
|
||||||
|
return state?.state?.token ?? '';
|
||||||
|
} catch { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
export function ChatPage() {
|
export function ChatPage() {
|
||||||
const { doctorId } = useParams<{ doctorId: string }>();
|
|
||||||
const [doctor, setDoctor] = useState<Doctor | null>(null);
|
const [doctor, setDoctor] = useState<Doctor | null>(null);
|
||||||
const [consultation, setConsultation] = useState<Consultation | null>(null);
|
const [consultation, setConsultation] = useState<Consultation | null>(null);
|
||||||
const [messages, setMessages] = useState<ConsultationMessage[]>([]);
|
const [messages, setMessages] = useState<ConsultationMessage[]>([]);
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
const [sending, setSending] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
const connRef = useRef<HubConnection | null>(null);
|
||||||
|
const initRef = useRef(false);
|
||||||
|
|
||||||
|
// Init consultation once
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (doctorId) {
|
if (initRef.current) return;
|
||||||
consultationService.getDoctor(doctorId).then((d) => setDoctor(d || null));
|
initRef.current = true;
|
||||||
consultationService.getConsultation(doctorId).then(async (c) => {
|
|
||||||
if (c) {
|
consultationService.getDoctors().then((docs) => {
|
||||||
setConsultation(c);
|
if (docs.length > 0) {
|
||||||
|
const doc = docs[0];
|
||||||
|
setDoctor(doc);
|
||||||
|
api.get<Consultation[]>('/api/consultations').then((res) => {
|
||||||
|
const existing = (res.data as Record<string, unknown>[]).find(
|
||||||
|
(c) => c.doctorId === doc.id && c.status === 'active'
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
setConsultation(existing as unknown as Consultation);
|
||||||
} else {
|
} else {
|
||||||
const newC = await consultationService.startConsultation(doctorId);
|
consultationService.startConsultation(doc.id, '在线咨询').then((c) => {
|
||||||
setConsultation(newC);
|
setConsultation(c);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [doctorId]);
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Fetch messages when consultation is loaded
|
// Load initial messages + set up SignalR when consultation is ready
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (consultation?.id) {
|
if (!consultation?.id) return;
|
||||||
consultationService.getDoctorReply(consultation.id).then(() => {
|
|
||||||
// The messages are fetched as a side effect; fetch them directly
|
// Load message history
|
||||||
import('@/services/api-client').then(({ api }) => {
|
|
||||||
api.get<ConsultationMessage[]>(`/api/consultations/${consultation.id}/messages`)
|
api.get<ConsultationMessage[]>(`/api/consultations/${consultation.id}/messages`)
|
||||||
.then((res) => setMessages(res.data));
|
.then((res) => setMessages(res.data))
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
// Set up SignalR connection
|
||||||
|
const conn = new HubConnectionBuilder()
|
||||||
|
.withUrl('/hubs/chat', {
|
||||||
|
accessTokenFactory: () => getToken(),
|
||||||
|
})
|
||||||
|
.withAutomaticReconnect()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
conn.on('ReceiveMessage', (msg: ConsultationMessage) => {
|
||||||
|
setMessages((prev) => {
|
||||||
|
if (prev.some((m) => m.id === msg.id)) return prev;
|
||||||
|
return [...prev, msg];
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
conn.onreconnected(() => {
|
||||||
|
conn.invoke('JoinConsultation', consultation.id).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
conn.start()
|
||||||
|
.then(() => {
|
||||||
|
setConnected(true);
|
||||||
|
return conn.invoke('JoinConsultation', consultation.id);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
connRef.current = conn;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (conn.state === HubConnectionState.Connected) {
|
||||||
|
conn.invoke('LeaveConsultation', consultation.id).catch(() => {});
|
||||||
}
|
}
|
||||||
|
conn.stop();
|
||||||
|
};
|
||||||
}, [consultation?.id]);
|
}, [consultation?.id]);
|
||||||
|
|
||||||
|
// Auto-scroll on new messages
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = useCallback(async () => {
|
||||||
if (!text.trim() || !consultation || sending) return;
|
if (!text.trim() || !consultation?.id || !connRef.current) return;
|
||||||
setSending(true);
|
|
||||||
const msgText = text;
|
const msgText = text;
|
||||||
setText('');
|
setText('');
|
||||||
const sent = await consultationService.sendMessage(consultation.id, msgText);
|
try {
|
||||||
setMessages((prev) => [...prev, sent]);
|
await connRef.current.invoke('SendMessage', consultation.id, msgText);
|
||||||
setSending(false);
|
} catch { /* ignore */ }
|
||||||
// Poll for doctor reply after delay
|
}, [text, consultation?.id]);
|
||||||
setTimeout(async () => {
|
|
||||||
const reply = await consultationService.getDoctorReply(consultation.id);
|
|
||||||
if (reply) {
|
|
||||||
setMessages((prev) => {
|
|
||||||
if (prev.find((m) => m.id === reply.id)) return prev;
|
|
||||||
return [...prev, reply];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 1500);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
<PageHeader title={doctor?.name || '咨询'} />
|
<PageHeader
|
||||||
|
title={doctor?.name || '在线问诊'}
|
||||||
|
rightAction={
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block', width: 8, height: 8, borderRadius: '50%',
|
||||||
|
background: connected ? '#4caf50' : '#ccc',
|
||||||
|
marginLeft: 8,
|
||||||
|
}} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
<div className={styles.messages}>
|
<div className={styles.messages}>
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', color: '#9CA3AF', marginTop: 40, fontSize: 14 }}>
|
||||||
|
您好,我是{doctor?.name || '医生'},请问有什么可以帮您?
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{messages.map((msg) => (
|
{messages.map((msg) => (
|
||||||
<div
|
<div key={msg.id} className={`${styles.bubble} ${msg.senderRole === 'patient' ? styles.patient : styles.doctor}`}>
|
||||||
key={msg.id}
|
|
||||||
className={`${styles.bubble} ${msg.senderRole === 'patient' ? styles.patient : styles.doctor}`}
|
|
||||||
>
|
|
||||||
<div className={styles.bubbleContent}>{msg.content}</div>
|
<div className={styles.bubbleContent}>{msg.content}</div>
|
||||||
<div className={styles.bubbleTime}>{formatRelative(msg.createdAt)}</div>
|
<div className={styles.bubbleTime}>{formatRelative(msg.createdAt)}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,16 +137,9 @@ export function ChatPage() {
|
|||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.inputBar}>
|
<div className={styles.inputBar}>
|
||||||
<input
|
<input className={styles.input} value={text} onChange={(e) => setText(e.target.value)}
|
||||||
className={styles.input}
|
placeholder="输入消息..." onKeyDown={(e) => e.key === 'Enter' && handleSend()} />
|
||||||
value={text}
|
<button className={styles.sendBtn} onClick={handleSend}>↑</button>
|
||||||
onChange={(e) => setText(e.target.value)}
|
|
||||||
placeholder="输入消息..."
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
|
||||||
/>
|
|
||||||
<button className={styles.sendBtn} onClick={handleSend} disabled={sending}>
|
|
||||||
{sending ? '...' : '发送'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,24 +17,26 @@
|
|||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
background: var(--color-bg-secondary);
|
background: var(--color-bg-secondary);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.active { background: var(--color-primary-bg); color: var(--color-primary); }
|
.active { background: var(--color-primary-bg); color: var(--color-primary); font-weight: 600; }
|
||||||
|
|
||||||
.docCard { margin-bottom: 8px; }
|
.docCard { margin-bottom: 8px; }
|
||||||
|
|
||||||
.docHeader { display: flex; gap: 12px; margin-bottom: 12px; }
|
.docHeader { display: flex; gap: 12px; margin-bottom: 12px; }
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 48px; height: 48px;
|
width: 48px;
|
||||||
border-radius: 50%;
|
height: 48px;
|
||||||
|
border-radius: 16px;
|
||||||
background: var(--color-primary-bg);
|
background: var(--color-primary-bg);
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: var(--font-size-lg);
|
font-size: var(--font-size-lg);
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +51,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.onlineDot {
|
.onlineDot {
|
||||||
width: 8px; height: 8px;
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--color-success);
|
background: var(--color-success);
|
||||||
}
|
}
|
||||||
@@ -63,7 +66,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
border-top: 1px solid var(--color-border-light);
|
border-top: 1px solid var(--color-divider);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fee { font-size: var(--font-size-sm); color: var(--color-danger); font-weight: 600; }
|
.fee { font-size: var(--font-size-sm); color: var(--color-danger); font-weight: 600; }
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user