Compare commits

..

2 Commits

Author SHA1 Message Date
MingNian
db443b258e revert: remove .env loading, restore hardcoded config
- appsettings.json: restored hardcoded secrets
- Program.cs: removed .env file loader
- Frontend api-clients: restored hardcoded localhost:5000
- Removed .env, .env.example, vite-env.d.ts files
- Kept all audit fixes (endpoints, DTOs, field names, status labels)
2026-05-24 13:38:45 +08:00
MingNian
ede4a8d29e fix: audit issues - field mismatches, missing endpoints, data loss
- Report frontends: createdAt→uploadedAt field alignment with backend
- Dashboard: fix pending reports endpoint /api/reports/pending
- FollowUpListPage: status labels upcoming/cancelled
- MedicationController: add PUT/DELETE endpoints + service methods
- FollowUpController: add DELETE endpoint, Notes to CreateRequest
- Auth: UpdateProfileRequest includes doctor fields
- Auth: login restores soft-deleted users instead of crashing
2026-05-24 13:24:21 +08:00
22 changed files with 199 additions and 169 deletions

View File

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

View File

@@ -16,4 +16,5 @@ public record UserProfileResponse(
public record UpdateProfileRequest( public record UpdateProfileRequest(
string? Name, string? Gender, DateOnly? Birthday, string? Name, string? Gender, DateOnly? Birthday,
decimal? HeightCm, decimal? WeightKg, List<string>? MedicalHistory); decimal? HeightCm, decimal? WeightKg, List<string>? MedicalHistory,
string? Department, string? Title, string? Introduction, List<string>? Specialty);

View File

@@ -20,7 +20,7 @@ public class FollowUpService(AppDbContext db)
.OrderBy(f => f.ScheduledAt) .OrderBy(f => f.ScheduledAt)
.ToListAsync(); .ToListAsync();
public async Task<FollowUp> AddAsync(Guid patientId, string title, string? description, DateTime scheduledAt, bool reminderEnabled, Guid? doctorId = null) public async Task<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 +30,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();
@@ -59,4 +60,13 @@ public class FollowUpService(AppDbContext db)
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return followUp; return followUp;
} }
public async Task<bool> DeleteAsync(Guid id)
{
var followUp = await db.FollowUps.FindAsync(id);
if (followUp == null) return false;
db.FollowUps.Remove(followUp);
await db.SaveChangesAsync();
return true;
}
} }

View File

@@ -90,4 +90,34 @@ 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;
}
} }

View File

@@ -5,6 +5,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;
@@ -29,6 +30,19 @@ public class AuthController(
{ {
// Demo: auto-register new users // Demo: auto-register new users
var db = HttpContext.RequestServices.GetRequiredService<Infrastructure.Data.AppDbContext>(); var db = HttpContext.RequestServices.GetRequiredService<Infrastructure.Data.AppDbContext>();
// Check if this phone was soft-deleted — restore instead of creating duplicate
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 user = new User
{ {
Phone = request.Phone, Phone = request.Phone,
@@ -37,6 +51,7 @@ public class AuthController(
PasswordHash = AuthService.HashPassword("demo123"), PasswordHash = AuthService.HashPassword("demo123"),
}; };
db.Users.Add(user); db.Users.Add(user);
}
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
@@ -121,6 +136,10 @@ 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.Department != null) user.Department = request.Department;
if (request.Title != null) user.Title = request.Title;
if (request.Introduction != null) user.Introduction = request.Introduction;
if (request.Specialty != null) user.Specialty = request.Specialty;
user.UpdatedAt = DateTime.UtcNow; user.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();

View File

@@ -55,10 +55,19 @@ public class FollowUpController(FollowUpService followUpService) : ControllerBas
} }
var followUp = await followUpService.AddAsync(patientId, request.Title, request.Description, var followUp = await followUpService.AddAsync(patientId, request.Title, request.Description,
request.ScheduledAt, request.ReminderEnabled, doctorId); request.ScheduledAt, request.ReminderEnabled, doctorId, request.Notes);
return Ok(new { followUp.Id, followUp.Title, followUp.Status }); return Ok(new { followUp.Id, followUp.Title, followUp.Status });
} }
[HttpDelete("{id:guid}")]
[Authorize(Roles = "doctor")]
public async Task<IActionResult> DeleteFollowUp(Guid id)
{
var ok = await followUpService.DeleteAsync(id);
if (!ok) return NotFound(new { message = "复查不存在" });
return Ok(new { message = "删除成功" });
}
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
[Authorize(Roles = "doctor")] [Authorize(Roles = "doctor")]
public async Task<IActionResult> UpdateFollowUp(Guid id, [FromBody] FollowUpUpdateRequest request) public async Task<IActionResult> UpdateFollowUp(Guid id, [FromBody] FollowUpUpdateRequest request)
@@ -77,6 +86,7 @@ public class FollowUpCreateRequest
public DateTime ScheduledAt { get; set; } public DateTime ScheduledAt { get; set; }
public bool ReminderEnabled { get; set; } = true; public bool ReminderEnabled { get; set; } = true;
public Guid? PatientId { get; set; } public Guid? PatientId { get; set; }
public string? Notes { get; set; }
} }
public class FollowUpUpdateRequest public class FollowUpUpdateRequest

View File

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

View File

@@ -9,24 +9,6 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using Swashbuckle.AspNetCore.SwaggerGen; using Swashbuckle.AspNetCore.SwaggerGen;
// Load .env file into environment variables
var envPath = Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", "..", ".env");
if (File.Exists(envPath))
{
foreach (var line in File.ReadAllLines(envPath))
{
var trimmed = line.Trim();
if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith('#')) continue;
var eq = trimmed.IndexOf('=');
if (eq > 0)
{
var key = trimmed[..eq].Trim();
var value = trimmed[(eq + 1)..].Trim();
Environment.SetEnvironmentVariable(key, value);
}
}
}
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Database // Database

View File

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

View File

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

View File

@@ -57,7 +57,7 @@ export function DashboardPage() {
const [patients, consultations, reports, followUps] = await Promise.all([ const [patients, consultations, reports, followUps] = await Promise.all([
api.get<RawPatient[]>('/api/patients'), api.get<RawPatient[]>('/api/patients'),
api.get<RawConsultation[]>('/api/consultations'), api.get<RawConsultation[]>('/api/consultations'),
api.get<RawReport[]>('/api/reports?status=pending'), api.get<RawReport[]>('/api/reports/pending'),
api.get<RawFollowUp[]>('/api/follow-ups'), api.get<RawFollowUp[]>('/api/follow-ups'),
]); ]);
setStats({ setStats({

View File

@@ -16,9 +16,9 @@ export function FollowUpListPage() {
const statusLabel = (s: string) => { const statusLabel = (s: string) => {
switch (s) { switch (s) {
case 'pending': return { text: '待随访', color: '#F59E0B', bg: '#FFF8E6' }; case 'upcoming': return { text: '待随访', color: '#F59E0B', bg: '#FFF8E6' };
case 'completed': return { text: '已完成', color: '#20C997', bg: '#E6F9F2' }; case 'completed': return { text: '已完成', color: '#20C997', bg: '#E6F9F2' };
case 'missed': return { text: '已错过', color: '#EF4444', bg: '#FEE9E9' }; case 'cancelled': return { text: '已取消', color: '#EF4444', bg: '#FEE9E9' };
default: return { text: s, color: '#9BA0B4', bg: '#F5F6F9' }; default: return { text: s, color: '#9BA0B4', bg: '#F5F6F9' };
} }
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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