Compare commits
2 Commits
d6a432aec4
...
db443b258e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db443b258e | ||
|
|
ede4a8d29e |
@@ -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
|
||||
@@ -16,4 +16,5 @@ public record UserProfileResponse(
|
||||
|
||||
public record UpdateProfileRequest(
|
||||
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);
|
||||
|
||||
@@ -20,7 +20,7 @@ public class FollowUpService(AppDbContext db)
|
||||
.OrderBy(f => f.ScheduledAt)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<FollowUp> AddAsync(Guid patientId, string title, string? description, DateTime scheduledAt, bool reminderEnabled, Guid? doctorId = null)
|
||||
public async Task<FollowUp> AddAsync(Guid patientId, string title, string? description, DateTime scheduledAt, bool reminderEnabled, Guid? doctorId = null, string? notes = null)
|
||||
{
|
||||
var followUp = new FollowUp
|
||||
{
|
||||
@@ -30,6 +30,7 @@ public class FollowUpService(AppDbContext db)
|
||||
Description = description,
|
||||
ScheduledAt = DateTime.SpecifyKind(scheduledAt, DateTimeKind.Utc),
|
||||
ReminderEnabled = reminderEnabled,
|
||||
Notes = notes,
|
||||
};
|
||||
db.FollowUps.Add(followUp);
|
||||
await db.SaveChangesAsync();
|
||||
@@ -59,4 +60,13 @@ public class FollowUpService(AppDbContext db)
|
||||
await db.SaveChangesAsync();
|
||||
return followUp;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id)
|
||||
{
|
||||
var followUp = await db.FollowUps.FindAsync(id);
|
||||
if (followUp == null) return false;
|
||||
db.FollowUps.Remove(followUp);
|
||||
await db.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,4 +90,34 @@ public class MedicationService(AppDbContext db)
|
||||
|
||||
return totalCount > 0 ? Math.Round((decimal)takenCount / totalCount * 100, 1) : 0;
|
||||
}
|
||||
|
||||
public async Task<Medication?> UpdateAsync(Guid medicationId, Guid userId, string? drugName,
|
||||
string? dosage, string? frequency, List<string>? timeSlots,
|
||||
DateOnly? startDate, DateOnly? endDate, string? notes, string? status)
|
||||
{
|
||||
var med = await db.Medications.FindAsync(medicationId);
|
||||
if (med == null || med.UserId != userId) return null;
|
||||
|
||||
if (drugName != null) med.DrugName = drugName;
|
||||
if (dosage != null) med.Dosage = dosage;
|
||||
if (frequency != null) med.Frequency = frequency;
|
||||
if (timeSlots != null) med.TimeSlots = timeSlots;
|
||||
if (startDate.HasValue) med.StartDate = startDate.Value;
|
||||
if (endDate.HasValue) med.EndDate = endDate;
|
||||
if (notes != null) med.Notes = notes;
|
||||
if (status != null) med.Status = status;
|
||||
med.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return med;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid medicationId, Guid userId)
|
||||
{
|
||||
var med = await db.Medications.FindAsync(medicationId);
|
||||
if (med == null || med.UserId != userId) return false;
|
||||
db.Medications.Remove(med);
|
||||
await db.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ using HealthManager.Application.Services;
|
||||
using HealthManager.Domain.Entities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace HealthManager.WebApi.Controllers;
|
||||
|
||||
@@ -29,6 +30,19 @@ public class AuthController(
|
||||
{
|
||||
// Demo: auto-register new users
|
||||
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
|
||||
{
|
||||
Phone = request.Phone,
|
||||
@@ -37,6 +51,7 @@ public class AuthController(
|
||||
PasswordHash = AuthService.HashPassword("demo123"),
|
||||
};
|
||||
db.Users.Add(user);
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
@@ -121,6 +136,10 @@ public class AuthController(
|
||||
if (request.HeightCm.HasValue) user.HeightCm = request.HeightCm;
|
||||
if (request.WeightKg.HasValue) user.WeightKg = request.WeightKg;
|
||||
if (request.MedicalHistory != null) user.MedicalHistory = request.MedicalHistory;
|
||||
if (request.Department != null) user.Department = request.Department;
|
||||
if (request.Title != null) user.Title = request.Title;
|
||||
if (request.Introduction != null) user.Introduction = request.Introduction;
|
||||
if (request.Specialty != null) user.Specialty = request.Specialty;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
@@ -55,10 +55,19 @@ public class FollowUpController(FollowUpService followUpService) : ControllerBas
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize(Roles = "doctor")]
|
||||
public async Task<IActionResult> DeleteFollowUp(Guid id)
|
||||
{
|
||||
var ok = await followUpService.DeleteAsync(id);
|
||||
if (!ok) return NotFound(new { message = "复查不存在" });
|
||||
return Ok(new { message = "删除成功" });
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize(Roles = "doctor")]
|
||||
public async Task<IActionResult> UpdateFollowUp(Guid id, [FromBody] FollowUpUpdateRequest request)
|
||||
@@ -77,6 +86,7 @@ public class FollowUpCreateRequest
|
||||
public DateTime ScheduledAt { get; set; }
|
||||
public bool ReminderEnabled { get; set; } = true;
|
||||
public Guid? PatientId { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public class FollowUpUpdateRequest
|
||||
|
||||
@@ -71,10 +71,32 @@ public class MedicationController(MedicationService medicationService) : Control
|
||||
var rate = await medicationService.GetAdherenceRateAsync(id);
|
||||
return Ok(new { medicationId = id, rate });
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
public async Task<IActionResult> UpdateMedication(Guid id, [FromBody] MedicationUpdateRequest request)
|
||||
{
|
||||
var med = await medicationService.UpdateAsync(id, UserId, request.DrugName, request.Dosage,
|
||||
request.Frequency, request.TimeSlots, request.StartDate, request.EndDate, request.Notes, request.Status);
|
||||
if (med == null) return NotFound(new { message = "药品不存在" });
|
||||
return Ok(new { med.Id, med.DrugName, med.Dosage, med.Status });
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<IActionResult> DeleteMedication(Guid id)
|
||||
{
|
||||
var ok = await medicationService.DeleteAsync(id, UserId);
|
||||
if (!ok) return NotFound(new { message = "药品不存在" });
|
||||
return Ok(new { message = "删除成功" });
|
||||
}
|
||||
}
|
||||
|
||||
public record MedicationCreateRequest(
|
||||
string DrugName, string Dosage, string Frequency,
|
||||
List<string> TimeSlots, DateOnly StartDate, DateOnly? EndDate, string? Notes);
|
||||
|
||||
public record MedicationUpdateRequest(
|
||||
string? DrugName, string? Dosage, string? Frequency,
|
||||
List<string>? TimeSlots, DateOnly? StartDate, DateOnly? EndDate,
|
||||
string? Notes, string? Status);
|
||||
|
||||
public record MarkTakenRequest(string TimeSlot);
|
||||
|
||||
@@ -9,24 +9,6 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
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);
|
||||
|
||||
// Database
|
||||
|
||||
@@ -7,19 +7,19 @@
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"Default": ""
|
||||
"Default": "Host=localhost;Port=5432;Database=health_manager;Username=postgres;Password=postgres123"
|
||||
},
|
||||
"Jwt": {
|
||||
"Secret": "",
|
||||
"Secret": "health-manager-jwt-secret-key-2026-super-secure-long-enough!",
|
||||
"Issuer": "HealthManager",
|
||||
"Audience": "HealthManagerApp"
|
||||
},
|
||||
"Redis": {
|
||||
"Connection": ""
|
||||
"Connection": "localhost:6379"
|
||||
},
|
||||
"MinIO": {
|
||||
"Endpoint": "",
|
||||
"AccessKey": "",
|
||||
"SecretKey": ""
|
||||
"Endpoint": "localhost:9000",
|
||||
"AccessKey": "minioadmin",
|
||||
"SecretKey": "minioadmin"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export function ChatPage() {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const connRef = useRef<HubConnection | null>(null);
|
||||
|
||||
// Load initial messages via HTTP
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
api.get<Message[]>(`/api/consultations/${id}/messages`)
|
||||
@@ -32,11 +33,12 @@ export function ChatPage() {
|
||||
.catch(() => {});
|
||||
}, [id]);
|
||||
|
||||
// Set up SignalR connection
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
const conn = new HubConnectionBuilder()
|
||||
.withUrl(`${import.meta.env.VITE_API_URL}/hubs/chat`, {
|
||||
.withUrl('http://localhost:5000/hubs/chat', {
|
||||
accessTokenFactory: () => getToken(),
|
||||
})
|
||||
.withAutomaticReconnect()
|
||||
@@ -44,6 +46,7 @@ export function ChatPage() {
|
||||
|
||||
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];
|
||||
});
|
||||
@@ -70,6 +73,7 @@ export function ChatPage() {
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
// Auto-scroll on new messages
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
@@ -85,37 +89,31 @@ export function ChatPage() {
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 0px)' }}>
|
||||
<div style={{
|
||||
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)',
|
||||
}}>
|
||||
<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 ? '#20C997' : '#C0C5D2',
|
||||
background: connected ? '#4caf50' : '#ccc',
|
||||
display: 'inline-block',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: 24, background: '#F5F7FB' }}>
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: 20, background: '#fafafa' }}>
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} style={{
|
||||
display: 'flex', justifyContent: msg.senderRole === 'doctor' ? 'flex-end' : 'flex-start',
|
||||
marginBottom: 14,
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: '70%', padding: '12px 16px', borderRadius: 14, fontSize: 14,
|
||||
background: msg.senderRole === 'doctor'
|
||||
? 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)'
|
||||
: '#fff',
|
||||
color: msg.senderRole === 'doctor' ? '#fff' : '#1A1D28',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
|
||||
maxWidth: '70%', padding: '10px 14px', borderRadius: 12, fontSize: 14,
|
||||
background: msg.senderRole === 'doctor' ? '#1976d2' : '#fff',
|
||||
color: msg.senderRole === 'doctor' ? '#fff' : '#333',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
|
||||
}}>
|
||||
<div>{msg.content}</div>
|
||||
<div style={{
|
||||
fontSize: 10, marginTop: 6, textAlign: 'right',
|
||||
opacity: 0.65,
|
||||
fontSize: 10, marginTop: 4, textAlign: 'right',
|
||||
opacity: 0.7,
|
||||
}}>
|
||||
{msg.createdAt?.split('T')[1]?.slice(0, 5)}
|
||||
</div>
|
||||
@@ -125,23 +123,14 @@ export function ChatPage() {
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
padding: '14px 24px', background: '#fff', borderTop: '1px solid #F0F2F5',
|
||||
display: 'flex', gap: 12, boxShadow: '0 -1px 4px rgba(0,0,0,0.03)',
|
||||
}}>
|
||||
<div style={{ padding: '12px 20px', background: '#fff', borderTop: '1px solid #eee', display: 'flex', gap: 12 }}>
|
||||
<input value={input} onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
||||
placeholder="输入回复..."
|
||||
style={{
|
||||
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'} />
|
||||
style={{ flex: 1, padding: '10px 14px', border: '1px solid #ddd', borderRadius: 20, fontSize: 14 }} />
|
||||
<button onClick={handleSend} style={{
|
||||
padding: '11px 24px', background: 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)', color: '#fff',
|
||||
border: 'none', borderRadius: 24, fontSize: 14, fontWeight: 600,
|
||||
boxShadow: '0 4px 14px rgba(79,110,247,0.3)',
|
||||
padding: '10px 24px', background: '#1976d2', color: '#fff',
|
||||
border: 'none', borderRadius: 20, fontSize: 14,
|
||||
}}>
|
||||
发送
|
||||
</button>
|
||||
|
||||
@@ -57,7 +57,7 @@ export function DashboardPage() {
|
||||
const [patients, consultations, reports, followUps] = await Promise.all([
|
||||
api.get<RawPatient[]>('/api/patients'),
|
||||
api.get<RawConsultation[]>('/api/consultations'),
|
||||
api.get<RawReport[]>('/api/reports?status=pending'),
|
||||
api.get<RawReport[]>('/api/reports/pending'),
|
||||
api.get<RawFollowUp[]>('/api/follow-ups'),
|
||||
]);
|
||||
setStats({
|
||||
|
||||
@@ -16,9 +16,9 @@ export function FollowUpListPage() {
|
||||
|
||||
const statusLabel = (s: string) => {
|
||||
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 'missed': return { text: '已错过', color: '#EF4444', bg: '#FEE9E9' };
|
||||
case 'cancelled': return { text: '已取消', color: '#EF4444', bg: '#FEE9E9' };
|
||||
default: return { text: s, color: '#9BA0B4', bg: '#F5F6F9' };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -56,54 +56,50 @@ export function ReportDetailPage() {
|
||||
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 riskMap: Record<string, { text: string; color: string }> = {
|
||||
normal: { text: '正常', color: '#20C997' },
|
||||
attention: { text: '关注', color: '#F59E0B' },
|
||||
abnormal: { text: '异常', color: '#EF4444' },
|
||||
};
|
||||
|
||||
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',
|
||||
normal: { text: '正常', color: '#2e7d32' },
|
||||
attention: { text: '关注', color: '#f57c00' },
|
||||
abnormal: { text: '异常', color: '#c62828' },
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 28 }}>
|
||||
<Link to="/reports" style={{ fontSize: 13, color: '#4F6EF7', fontWeight: 500 }}>← 返回报告列表</Link>
|
||||
<div style={{ padding: 24 }}>
|
||||
<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>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>{report.title}</h2>
|
||||
<div style={{ marginTop: 8, fontSize: 13, color: '#9BA0B4' }}>
|
||||
<h2 style={{ margin: 0 }}>{report.title}</h2>
|
||||
<div style={{ marginTop: 8, fontSize: 13, color: '#888' }}>
|
||||
患者:{report.patientName || '未知'} |
|
||||
分类:{categoryMap[report.category] || report.category} |
|
||||
日期:{report.createdAt?.split('T')[0]}
|
||||
</div>
|
||||
</div>
|
||||
<span style={{
|
||||
padding: '6px 14px', borderRadius: 12, fontSize: 12, fontWeight: 600,
|
||||
background: isCompleted ? '#E6F9F2' : '#FFF8E6',
|
||||
color: isCompleted ? '#20C997' : '#F59E0B',
|
||||
padding: '4px 12px', borderRadius: 12, fontSize: 12, fontWeight: 500,
|
||||
background: isCompleted ? '#e8f5e9' : '#fff3e0',
|
||||
color: isCompleted ? '#2e7d32' : '#f57c00',
|
||||
}}>
|
||||
{isCompleted ? '已完成' : '待审核'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 图片 */}
|
||||
{report.imageUrls && report.imageUrls.length > 0 && (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<h4 style={{ fontSize: 14, fontWeight: 600, marginBottom: 10, color: '#5A6072' }}>上传图片({report.imageUrls.length}张)</h4>
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<h4 style={{ fontSize: 14, marginBottom: 8 }}>上传图片({report.imageUrls.length}张)</h4>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
{report.imageUrls.map((url, i) => (
|
||||
<div key={i} onClick={() => setLightbox(url)} style={{
|
||||
width: 120, height: 120, borderRadius: 12, overflow: 'hidden',
|
||||
cursor: 'pointer', border: '2px solid #F0F2F5', background: '#F9FAFC',
|
||||
width: 120, height: 120, borderRadius: 8, overflow: 'hidden',
|
||||
cursor: 'pointer', border: '2px solid #eee', background: '#f5f5f5',
|
||||
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' }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||
</div>
|
||||
@@ -112,35 +108,37 @@ export function ReportDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 灯箱 */}
|
||||
{lightbox && (
|
||||
<div onClick={() => setLightbox(null)} style={{
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 999, cursor: 'pointer',
|
||||
}}>
|
||||
<img src={`${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>
|
||||
)}
|
||||
|
||||
{/* 已完成解读 */}
|
||||
{isCompleted && (
|
||||
<div style={{ marginTop: 24, padding: 20, background: '#E6F9F2', borderRadius: 12 }}>
|
||||
<h4 style={{ fontSize: 15, fontWeight: 600, marginBottom: 12, color: '#20C997' }}>解读结果</h4>
|
||||
<div style={{ fontSize: 13, color: '#5A6072' }}>
|
||||
<p style={{ margin: '6px 0' }}><strong style={{ color: '#1A1D28' }}>风险等级:</strong>
|
||||
<div style={{ marginTop: 20, padding: 16, background: '#e8f5e9', borderRadius: 8 }}>
|
||||
<h4 style={{ fontSize: 14, marginBottom: 8 }}>解读结果</h4>
|
||||
<div style={{ fontSize: 13 }}>
|
||||
<p><strong>风险等级:</strong>
|
||||
<span style={{ color: riskMap[report.riskLevel || '']?.color, fontWeight: 600 }}>
|
||||
{riskMap[report.riskLevel || '']?.text || report.riskLevel || '-'}
|
||||
</span>
|
||||
</p>
|
||||
<p style={{ margin: '6px 0' }}><strong style={{ color: '#1A1D28' }}>总结:</strong>{report.summary || '-'}</p>
|
||||
{report.suggestions && <p style={{ margin: '6px 0' }}><strong style={{ color: '#1A1D28' }}>建议:</strong>{report.suggestions}</p>}
|
||||
<p><strong>总结:</strong>{report.summary || '-'}</p>
|
||||
{report.suggestions && <p><strong>建议:</strong>{report.suggestions}</p>}
|
||||
</div>
|
||||
|
||||
{report.items && report.items.length > 0 && (
|
||||
<table style={{ width: '100%', marginTop: 14, borderCollapse: 'collapse', fontSize: 12 }}>
|
||||
<table style={{ width: '100%', marginTop: 12, borderCollapse: 'collapse', fontSize: 12 }}>
|
||||
<thead><tr style={{ textAlign: 'left', borderBottom: '2px solid #c8e6c9' }}>
|
||||
<th style={{ padding: '6px 8px', color: '#5A6072' }}>检查项目</th>
|
||||
<th style={{ padding: '6px 8px', color: '#5A6072' }}>结果</th>
|
||||
<th style={{ padding: '6px 8px', color: '#5A6072' }}>参考范围</th>
|
||||
<th style={{ padding: '6px 8px', color: '#5A6072' }}>是否异常</th>
|
||||
<th style={{ padding: '6px 8px' }}>检查项目</th>
|
||||
<th style={{ padding: '6px 8px' }}>结果</th>
|
||||
<th style={{ padding: '6px 8px' }}>参考范围</th>
|
||||
<th style={{ padding: '6px 8px' }}>是否异常</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{report.items.map((item) => (
|
||||
@@ -148,7 +146,7 @@ export function ReportDetailPage() {
|
||||
<td style={{ padding: '6px 8px' }}>{item.itemName}</td>
|
||||
<td style={{ padding: '6px 8px' }}>{item.resultValue} {item.unit || ''}</td>
|
||||
<td style={{ padding: '6px 8px' }}>{item.referenceRange || '-'}</td>
|
||||
<td style={{ padding: '6px 8px', color: item.isAbnormal ? '#EF4444' : '#20C997', fontWeight: 600 }}>
|
||||
<td style={{ padding: '6px 8px', color: item.isAbnormal ? '#c62828' : '#2e7d32', fontWeight: 500 }}>
|
||||
{item.isAbnormal ? '是' : '否'}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -159,66 +157,68 @@ export function ReportDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 解读表单 */}
|
||||
{!isCompleted && (
|
||||
<div style={{ marginTop: 28, borderTop: '1px solid #F0F2F5', paddingTop: 24 }}>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: 18, color: '#1A1D28' }}>医生解读</h3>
|
||||
<div style={{ marginTop: 24, borderTop: '1px solid #eee', paddingTop: 20 }}>
|
||||
<h3 style={{ fontSize: 15, marginBottom: 16 }}>医生解读</h3>
|
||||
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 5 }}>解读总结</label>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 4 }}>解读总结</label>
|
||||
<textarea value={summary} onChange={(e) => setSummary(e.target.value)}
|
||||
placeholder="请输入您的专业解读总结..."
|
||||
rows={4}
|
||||
style={{ ...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 style={{ display: 'flex', gap: 16, marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', gap: 16, marginBottom: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 5 }}>风险等级</label>
|
||||
<select value={riskLevel} onChange={(e) => setRiskLevel(e.target.value)} style={inputStyle}>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 4 }}>风险等级</label>
|
||||
<select value={riskLevel} onChange={(e) => setRiskLevel(e.target.value)}
|
||||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 6, fontSize: 13, fontFamily: 'inherit' }}>
|
||||
<option value="normal">正常</option>
|
||||
<option value="attention">需关注</option>
|
||||
<option value="abnormal">异常</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, 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)}
|
||||
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 style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 8 }}>检查项目</label>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}>检查项目</label>
|
||||
{items.map((item, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 8, marginBottom: 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)}
|
||||
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)}
|
||||
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)}
|
||||
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)}
|
||||
style={{ flex: 1, padding: '8px 12px', border: '1.5px solid #E1E5ED', borderRadius: 8, fontSize: 12, outline: 'none' }} />
|
||||
<label style={{ fontSize: 12, whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: 4, color: '#5A6072' }}>
|
||||
style={{ flex: 1, padding: '6px 10px', border: '1px solid #ddd', borderRadius: 4, fontSize: 12, fontFamily: 'inherit' }} />
|
||||
<label style={{ fontSize: 12, whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: 3 }}>
|
||||
<input type="checkbox" checked={item.isAbnormal} onChange={(e) => updateItem(i, 'isAbnormal', e.target.checked)} />
|
||||
异常
|
||||
</label>
|
||||
<button onClick={() => removeItem(i)}
|
||||
style={{ background: 'none', border: 'none', color: '#EF4444', cursor: 'pointer', fontSize: 18, fontWeight: 700 }}
|
||||
disabled={items.length <= 1}>×</button>
|
||||
style={{ background: 'none', border: 'none', color: '#c62828', cursor: 'pointer', fontSize: 16 }}
|
||||
disabled={items.length <= 1}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={addItem} style={{
|
||||
padding: '6px 14px', border: '1.5px dashed #4F6EF7', borderRadius: 8,
|
||||
background: 'none', color: '#4F6EF7', cursor: 'pointer', fontSize: 12, fontWeight: 500,
|
||||
padding: '4px 12px', border: '1px dashed #1976d2', borderRadius: 4,
|
||||
background: 'none', color: '#1976d2', cursor: 'pointer', fontSize: 12,
|
||||
}}>+ 添加项目</button>
|
||||
</div>
|
||||
|
||||
<button onClick={handleInterpret} disabled={submitting} style={{
|
||||
padding: '11px 32px', background: 'linear-gradient(135deg, #4F6EF7 0%, #6C8AFF 100%)', color: '#fff',
|
||||
border: 'none', borderRadius: 10, fontSize: 14, cursor: 'pointer', fontWeight: 600,
|
||||
opacity: submitting ? 0.7 : 1, marginTop: 8, boxShadow: '0 4px 16px rgba(79,110,247,0.25)',
|
||||
padding: '10px 28px', background: '#1976d2', color: '#fff',
|
||||
border: 'none', borderRadius: 6, fontSize: 14, cursor: 'pointer',
|
||||
opacity: submitting ? 0.7 : 1, marginTop: 8,
|
||||
}}>
|
||||
{submitting ? '提交中...' : '提交解读'}
|
||||
</button>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { api } from '../../services/api-client';
|
||||
|
||||
interface RawReportItem {
|
||||
id: string; patientId: string; patientName?: string;
|
||||
title: string; category: string; status: string; createdAt: string;
|
||||
title: string; category: string; status: string; uploadedAt: string;
|
||||
}
|
||||
|
||||
export function ReportListPage() {
|
||||
@@ -52,7 +52,7 @@ export function ReportListPage() {
|
||||
{s.text}
|
||||
</span>
|
||||
</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' }}>
|
||||
<Link to={`/reports/${r.id}`} style={{
|
||||
color: '#4F6EF7', fontSize: 12, fontWeight: 600,
|
||||
|
||||
@@ -6,7 +6,7 @@ interface ApiResponse<T> {
|
||||
message: string;
|
||||
}
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||
const BASE_URL = 'http://localhost:5000';
|
||||
|
||||
// Endpoints that should NEVER include auth token
|
||||
const PUBLIC_ENDPOINTS = ['/api/auth/login', '/api/auth/register', '/api/auth/send-sms', '/api/auth/refresh'];
|
||||
|
||||
9
frontend-doctor/src/vite-env.d.ts
vendored
9
frontend-doctor/src/vite-env.d.ts
vendored
@@ -1,9 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -62,7 +62,7 @@ export function ChatPage() {
|
||||
|
||||
// Set up SignalR connection
|
||||
const conn = new HubConnectionBuilder()
|
||||
.withUrl(`${import.meta.env.VITE_API_URL}/hubs/chat`, {
|
||||
.withUrl('http://localhost:5000/hubs/chat', {
|
||||
accessTokenFactory: () => getToken(),
|
||||
})
|
||||
.withAutomaticReconnect()
|
||||
|
||||
@@ -53,7 +53,7 @@ export function ReportDetailPage() {
|
||||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8 }}>报告图片</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{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' }} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ export function ReportUploadPage() {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
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',
|
||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
|
||||
body: formData,
|
||||
|
||||
@@ -9,7 +9,7 @@ interface ApiResponse<T> {
|
||||
message: string;
|
||||
}
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||
const BASE_URL = 'http://localhost:5000';
|
||||
|
||||
// Endpoints that should NEVER include auth token
|
||||
const PUBLIC_ENDPOINTS = ['/api/auth/login', '/api/auth/register', '/api/auth/send-sms', '/api/auth/refresh'];
|
||||
|
||||
@@ -9,7 +9,7 @@ interface RawReport {
|
||||
imageUrls: string[];
|
||||
status: string;
|
||||
result?: string;
|
||||
createdAt: string;
|
||||
uploadedAt: string;
|
||||
interpretedAt?: string;
|
||||
interpretedBy?: string;
|
||||
}
|
||||
@@ -35,7 +35,7 @@ function mapReport(r: RawReport): Report {
|
||||
userId: r.patientId,
|
||||
title: r.title,
|
||||
imageUrls: r.imageUrls,
|
||||
uploadAt: r.createdAt,
|
||||
uploadAt: r.uploadedAt,
|
||||
status: r.status as Report['status'],
|
||||
category: r.category,
|
||||
result,
|
||||
|
||||
9
frontend-patient/src/vite-env.d.ts
vendored
9
frontend-patient/src/vite-env.d.ts
vendored
@@ -1,9 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
Reference in New Issue
Block a user