Compare commits

...

2 Commits

14 changed files with 446 additions and 111 deletions

View File

@@ -59,6 +59,30 @@ public class ConsultationService(AppDbContext db)
ImageUrl = imageUrl, ImageUrl = imageUrl,
}; };
db.ConsultationMessages.Add(message); db.ConsultationMessages.Add(message);
// Create notification for the recipient
var consultation = await db.Consultations
.Include(c => c.Patient)
.Include(c => c.Doctor)
.FirstOrDefaultAsync(c => c.Id == consultationId);
if (consultation != null)
{
var targetUserId = senderRole == "patient" ? consultation.DoctorId : consultation.PatientId;
var senderName = senderRole == "patient" ? consultation.Patient?.Name : consultation.Doctor?.Name;
var notifyTitle = senderRole == "patient" ? "新患者消息" : "医生已回复";
var notifyContent = $"{senderName ?? ""}{TruncateContent(content)}";
db.Notifications.Add(new Notification
{
UserId = targetUserId,
Type = "consultation",
Title = notifyTitle,
Content = notifyContent,
RelatedId = consultationId,
});
}
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return message; return message;
} }
@@ -70,4 +94,7 @@ public class ConsultationService(AppDbContext db)
query = query.Where(u => u.Department == department); query = query.Where(u => u.Department == department);
return await query.ToListAsync(); return await query.ToListAsync();
} }
private static string TruncateContent(string content) =>
content.Length > 50 ? content[..50] + "..." : content;
} }

View File

@@ -0,0 +1,104 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace HealthManager.WebApi.Controllers;
[ApiController]
[Route("api/files")]
[Authorize]
public class FileController : ControllerBase
{
private static readonly string UploadDir = Path.Combine(
Directory.GetCurrentDirectory(), "..", "..", "..", "data", "uploads");
public FileController()
{
if (!Directory.Exists(UploadDir))
Directory.CreateDirectory(UploadDir);
}
[HttpPost("upload")]
public async Task<IActionResult> Upload(IFormFile file)
{
if (file == null || file.Length == 0)
return BadRequest(new { message = "No file selected" });
if (file.Length > 10 * 1024 * 1024)
return BadRequest(new { message = "File too large (max 10MB)" });
var allowedTypes = new[] { "image/jpeg", "image/png", "image/gif", "image/webp", "application/pdf" };
if (!allowedTypes.Contains(file.ContentType.ToLower()))
return BadRequest(new { message = "Unsupported file type" });
var fileName = $"{Guid.NewGuid()}{Path.GetExtension(file.FileName)}";
var filePath = Path.Combine(UploadDir, fileName);
using (var stream = new FileStream(filePath, FileMode.Create))
{
await file.CopyToAsync(stream);
}
var url = $"/api/files/{fileName}";
return Ok(new { url, fileName });
}
[HttpPost("upload-multiple")]
public async Task<IActionResult> UploadMultiple(List<IFormFile> files)
{
if (files == null || files.Count == 0)
return BadRequest(new { message = "No files selected" });
var results = new List<object>();
foreach (var file in files)
{
if (file.Length == 0) continue;
if (file.Length > 10 * 1024 * 1024) continue;
var allowedTypes = new[] { "image/jpeg", "image/png", "image/gif", "image/webp", "application/pdf" };
if (!allowedTypes.Contains(file.ContentType.ToLower())) continue;
var fileName = $"{Guid.NewGuid()}{Path.GetExtension(file.FileName)}";
var filePath = Path.Combine(UploadDir, fileName);
using (var stream = new FileStream(filePath, FileMode.Create))
{
await file.CopyToAsync(stream);
}
results.Add(new { url = $"/api/files/{fileName}", fileName });
}
return Ok(results);
}
[HttpGet("{fileName}")]
[AllowAnonymous]
public IActionResult Download(string fileName)
{
var filePath = Path.Combine(UploadDir, fileName);
// Security: prevent path traversal
if (!filePath.StartsWith(UploadDir))
return NotFound();
if (!System.IO.File.Exists(filePath))
return NotFound();
var contentType = GetContentType(fileName);
return PhysicalFile(filePath, contentType);
}
private static string GetContentType(string fileName)
{
var ext = Path.GetExtension(fileName).ToLower();
return ext switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
".pdf" => "application/pdf",
_ => "application/octet-stream",
};
}
}

View File

@@ -1,4 +1,4 @@
import { Outlet, NavLink, useNavigate } 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 navItems = [ const navItems = [
@@ -16,6 +16,7 @@ const textMuted = '#8E9DB5';
export function DoctorLayout() { export function DoctorLayout() {
const { user, logout } = useAuthStore(); const { user, logout } = useAuthStore();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const handleLogout = () => { const handleLogout = () => {
logout(); logout();
@@ -76,7 +77,9 @@ export function DoctorLayout() {
{/* Main content */} {/* 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' }}>
<Outlet /> <Outlet />
</div>
</main> </main>
</div> </div>
); );

View File

@@ -8,13 +8,28 @@ interface RawReport {
summary?: string; suggestions?: string; summary?: string; suggestions?: string;
patientName?: string; doctorName?: string; patientName?: string; doctorName?: string;
createdAt: string; completedAt?: string; createdAt: string; completedAt?: string;
items?: { id: string; itemName: string; resultValue: string; unit?: string; referenceRange?: string; isAbnormal: boolean }[]; items?: RawItem[];
} }
interface RawItem {
id: string; itemName: string; resultValue: string;
unit?: string; referenceRange?: string; isAbnormal: boolean;
}
const categoryMap: Record<string, string> = {
'血液检查': '血液检查', '心电图': '心电图', '影像学': '影像学', '尿液检查': '尿液检查', '其他': '其他',
'Blood Test': '血液检查', 'ECG': '心电图', 'Imaging': '影像学',
};
export function ReportDetailPage() { export function ReportDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [report, setReport] = useState<RawReport | null>(null); const [report, setReport] = useState<RawReport | null>(null);
const [interpretation, setInterpretation] = useState(''); const [lightbox, setLightbox] = useState<string | null>(null);
const [summary, setSummary] = useState('');
const [riskLevel, setRiskLevel] = useState('normal');
const [suggestions, setSuggestions] = useState('');
const [items, setItems] = useState([{ itemName: '', resultValue: '', unit: '', referenceRange: '', isAbnormal: false }]);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
useEffect(() => { useEffect(() => {
@@ -22,69 +37,118 @@ export function ReportDetailPage() {
api.get<RawReport>(`/api/reports/${id}`).then((r) => setReport(r.data)).catch(() => {}); api.get<RawReport>(`/api/reports/${id}`).then((r) => setReport(r.data)).catch(() => {});
}, [id]); }, [id]);
const addItem = () => setItems((prev) => [...prev, { itemName: '', resultValue: '', unit: '', referenceRange: '', isAbnormal: false }]);
const updateItem = (i: number, field: string, value: string | boolean) =>
setItems((prev) => prev.map((it, idx) => idx === i ? { ...it, [field]: value } : it));
const removeItem = (i: number) => { if (items.length > 1) setItems((prev) => prev.filter((_, idx) => idx !== i)); };
const handleInterpret = async () => { const handleInterpret = async () => {
if (!interpretation.trim() || !id) return; if (!summary.trim() || !id) return;
setSubmitting(true); setSubmitting(true);
try { try {
await api.post(`/api/reports/${id}/interpret`, { await api.post(`/api/reports/${id}/interpret`, {
summary: interpretation, summary, items: items.filter((it) => it.itemName.trim()), riskLevel,
items: [], suggestions: suggestions || null,
riskLevel: 'normal',
suggestions: null,
}); });
// Refetch report to show updated status
const updated = await api.get<RawReport>(`/api/reports/${id}`); const updated = await api.get<RawReport>(`/api/reports/${id}`);
setReport(updated.data); setReport(updated.data);
setInterpretation(''); } catch { alert('提交失败'); }
alert('interpretation submitted');
} catch { alert('submit failed'); }
finally { setSubmitting(false); } finally { setSubmitting(false); }
}; };
if (!report) return <div style={{ padding: 24 }}>loading...</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: '#2e7d32' },
attention: { text: '关注', color: '#f57c00' },
abnormal: { text: '异常', color: '#c62828' },
};
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<Link to="/reports" style={{ fontSize: 13, color: '#1976d2' }}>&larr; back to reports</Link> <Link to="/reports" style={{ fontSize: 13, color: '#1976d2' }}> </Link>
<div style={{ background: '#fff', marginTop: 16, padding: 24, borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}> <div style={{ background: '#fff', marginTop: 16, padding: 24, borderRadius: 8, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}>
<h2>{report.title}</h2> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ marginTop: 12, fontSize: 14, color: '#666' }}> <div>
<div>Patient: {report.patientName || 'unknown'}</div> <h2 style={{ margin: 0 }}>{report.title}</h2>
<div>Category: {report.category}</div> <div style={{ marginTop: 8, fontSize: 13, color: '#888' }}>
<div>Status: {report.status}</div> {report.patientName || '未知'} &nbsp;|&nbsp;
<div>Submitted: {report.createdAt?.split('T')[0]}</div> {categoryMap[report.category] || report.category} &nbsp;|&nbsp;
{report.createdAt?.split('T')[0]}
</div>
</div>
<span style={{
padding: '4px 12px', borderRadius: 12, fontSize: 12, fontWeight: 500,
background: isCompleted ? '#e8f5e9' : '#fff3e0',
color: isCompleted ? '#2e7d32' : '#f57c00',
}}>
{isCompleted ? '已完成' : '待审核'}
</span>
</div> </div>
{/* 图片 */}
{report.imageUrls && report.imageUrls.length > 0 && ( {report.imageUrls && report.imageUrls.length > 0 && (
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 20 }}>
<h4 style={{ fontSize: 14, marginBottom: 8 }}>Report Images</h4> <h4 style={{ fontSize: 14, marginBottom: 8 }}>{report.imageUrls.length}</h4>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{report.imageUrls.map((url, i) => ( {report.imageUrls.map((url, i) => (
<div key={i} style={{ padding: '8px 12px', background: '#f5f5f5', borderRadius: 4, marginBottom: 4, fontSize: 12 }}> <div key={i} onClick={() => setLightbox(url)} style={{
{url} width: 120, height: 120, borderRadius: 8, overflow: 'hidden',
cursor: 'pointer', border: '2px solid #eee', background: '#f5f5f5',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<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> </div>
))} ))}
</div> </div>
</div>
)} )}
{report.status === 'completed' && ( {/* 灯箱 */}
<div style={{ marginTop: 16, padding: 16, background: '#e8f5e9', borderRadius: 8 }}> {lightbox && (
<h4 style={{ fontSize: 14, marginBottom: 4 }}>Interpretation Result</h4> <div onClick={() => setLightbox(null)} style={{
<p style={{ fontSize: 13, color: '#555' }}>{report.summary || 'No summary'}</p> position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)',
{report.riskLevel && <p style={{ fontSize: 12, color: '#888' }}>Risk: {report.riskLevel}</p>} display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 999, cursor: 'pointer',
{report.suggestions && <p style={{ fontSize: 12, color: '#888' }}>Suggestions: {report.suggestions}</p>} }}>
<img src={`http://localhost:5000${lightbox}`} alt="预览" style={{ maxWidth: '90vw', maxHeight: '90vh', borderRadius: 8 }} />
</div>
)}
{/* 已完成解读 */}
{isCompleted && (
<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><strong></strong>{report.summary || '-'}</p>
{report.suggestions && <p><strong></strong>{report.suggestions}</p>}
</div>
{report.items && report.items.length > 0 && ( {report.items && report.items.length > 0 && (
<table style={{ width: '100%', marginTop: 8, borderCollapse: 'collapse', fontSize: 12 }}> <table style={{ width: '100%', marginTop: 12, borderCollapse: 'collapse', fontSize: 12 }}>
<thead><tr style={{ textAlign: 'left', borderBottom: '1px solid #ddd' }}> <thead><tr style={{ textAlign: 'left', borderBottom: '2px solid #c8e6c9' }}>
<th style={{ padding: 4 }}>Item</th><th style={{ padding: 4 }}>Value</th><th style={{ padding: 4 }}>Range</th><th style={{ padding: 4 }}>Abnormal</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> </tr></thead>
<tbody> <tbody>
{report.items.map((item) => ( {report.items.map((item) => (
<tr key={item.id} style={{ borderBottom: '1px solid #f0f0f0' }}> <tr key={item.id} style={{ borderBottom: '1px solid #e8f5e9' }}>
<td style={{ padding: 4 }}>{item.itemName}</td> <td style={{ padding: '6px 8px' }}>{item.itemName}</td>
<td style={{ padding: 4 }}>{item.resultValue} {item.unit || ''}</td> <td style={{ padding: '6px 8px' }}>{item.resultValue} {item.unit || ''}</td>
<td style={{ padding: 4 }}>{item.referenceRange || '-'}</td> <td style={{ padding: '6px 8px' }}>{item.referenceRange || '-'}</td>
<td style={{ padding: 4, color: item.isAbnormal ? '#c62828' : '#2e7d32' }}>{item.isAbnormal ? 'Yes' : 'No'}</td> <td style={{ padding: '6px 8px', color: item.isAbnormal ? '#c62828' : '#2e7d32', fontWeight: 500 }}>
{item.isAbnormal ? '是' : '否'}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -93,18 +157,70 @@ export function ReportDetailPage() {
</div> </div>
)} )}
{report.status !== 'completed' && ( {/* 解读表单 */}
<div style={{ marginTop: 20 }}> {!isCompleted && (
<h4 style={{ fontSize: 14, marginBottom: 8 }}>Doctor Interpretation</h4> <div style={{ marginTop: 24, borderTop: '1px solid #eee', paddingTop: 20 }}>
<textarea value={interpretation} onChange={(e) => setInterpretation(e.target.value)} <h3 style={{ fontSize: 15, marginBottom: 16 }}></h3>
placeholder="Enter your interpretation..."
rows={5} <div style={{ marginBottom: 12 }}>
style={{ width: '100%', padding: 12, border: '1px solid #ddd', borderRadius: 4, fontSize: 14, resize: 'vertical' }} /> <label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 4 }}></label>
<textarea value={summary} onChange={(e) => setSummary(e.target.value)}
placeholder="请输入您的专业解读总结..."
rows={4}
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: 12 }}>
<div style={{ flex: 1 }}>
<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, marginBottom: 4 }}></label>
<input value={suggestions} onChange={(e) => setSuggestions(e.target.value)}
placeholder="如:继续当前用药方案"
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 6, fontSize: 13, fontFamily: 'inherit', boxSizing: 'border-box' }} />
</div>
</div>
<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: 6, alignItems: 'center' }}>
<input placeholder="项目名称" value={item.itemName} onChange={(e) => updateItem(i, 'itemName', e.target.value)}
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: '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: '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: '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: '#c62828', cursor: 'pointer', fontSize: 16 }}
disabled={items.length <= 1}></button>
</div>
))}
<button onClick={addItem} style={{
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={{ <button onClick={handleInterpret} disabled={submitting} style={{
marginTop: 8, padding: '10px 24px', background: '#1976d2', color: '#fff', padding: '10px 28px', background: '#1976d2', color: '#fff',
border: 'none', borderRadius: 4, fontSize: 14, opacity: submitting ? 0.7 : 1, border: 'none', borderRadius: 6, fontSize: 14, cursor: 'pointer',
opacity: submitting ? 0.7 : 1, marginTop: 8,
}}> }}>
{submitting ? 'Submitting...' : 'Submit Interpretation'} {submitting ? '提交中...' : '提交解读'}
</button> </button>
</div> </div>
)} )}

View File

@@ -4,4 +4,7 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: {
port: 5174,
},
}) })

View File

@@ -1,12 +1,25 @@
import { Outlet } from 'react-router-dom'; import { Outlet, useLocation } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { TabBar } from './TabBar'; import { TabBar } from './TabBar';
import styles from './AppLayout.module.css'; import styles from './AppLayout.module.css';
export function AppLayout() { export function AppLayout() {
const location = useLocation();
return ( return (
<div className={styles.layout}> <div className={styles.layout}>
<main className={styles.main}> <main className={styles.main}>
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
>
<Outlet /> <Outlet />
</motion.div>
</AnimatePresence>
</main> </main>
<TabBar /> <TabBar />
</div> </div>

View File

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

View File

@@ -42,3 +42,25 @@
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
font-weight: 500; font-weight: 500;
} }
.tabIcon {
position: relative;
}
.badge {
position: absolute;
top: -6px;
right: -10px;
min-width: 16px;
height: 16px;
padding: 0 4px;
background: #EF4444;
color: #fff;
border-radius: 10px;
font-size: 10px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}

View File

@@ -1,10 +1,12 @@
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { NAV_ITEMS } from '@/utils/constants'; import { NAV_ITEMS } from '@/utils/constants';
import { useNotificationStore } from '@/stores/notification.store';
import styles from './TabBar.module.css'; import styles from './TabBar.module.css';
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}>
@@ -16,7 +18,12 @@ 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}>{item.icon}</span> <span className={styles.tabIcon}>
{item.icon}
{item.path === '/services' && unreadCount > 0 && (
<span className={styles.badge}>{unreadCount > 99 ? '99+' : unreadCount}</span>
)}
</span>
<span className={styles.tabLabel}>{item.label}</span> <span className={styles.tabLabel}>{item.label}</span>
</button> </button>
); );

View File

@@ -25,7 +25,7 @@ export function ProfilePage() {
<Card className={styles.profileCard} onClick={() => navigate('/profile/edit')}> <Card className={styles.profileCard} onClick={() => navigate('/profile/edit')}>
<div className={styles.avatar}>{user?.nickname?.[0] || '用'}</div> <div className={styles.avatar}>{user?.nickname?.[0] || '用'}</div>
<div className={styles.profileInfo}> <div className={styles.profileInfo}>
<div className={styles.nickname}>{user?.nickname || '用户'} <span className={styles.editHint}></span></div> <div className={styles.nickname}>{user?.nickname || '用户'} <span className={styles.editHint}>&#8250;</span></div>
<div className={styles.phone}>{user?.phone}</div> <div className={styles.phone}>{user?.phone}</div>
</div> </div>
</Card> </Card>

View File

@@ -2,7 +2,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh; height: 100vh;
background: var(--color-bg); background: #EDF1F7;
} }
.messages { .messages {
@@ -10,74 +10,92 @@
overflow-y: auto; overflow-y: auto;
padding: 16px; padding: 16px;
padding-top: calc(var(--header-height) + 8px); padding-top: calc(var(--header-height) + 8px);
display: flex;
flex-direction: column;
gap: 4px;
} }
.bubble { .bubble {
margin-bottom: 14px; max-width: 78%;
max-width: 80%; animation: bubbleIn 0.25s ease-out;
}
@keyframes bubbleIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
} }
.patient { .patient {
margin-left: auto; align-self: flex-end;
text-align: right;
} }
.doctor { .doctor {
margin-right: auto; align-self: flex-start;
} }
.bubbleContent { .bubbleContent {
padding: 10px 14px; padding: 10px 14px;
border-radius: var(--radius-md); border-radius: 18px;
font-size: var(--font-size-sm); font-size: var(--font-size-base);
line-height: 1.5; line-height: 1.5;
display: inline-block; word-break: break-word;
text-align: left;
} }
.patient .bubbleContent { .patient .bubbleContent {
background: var(--color-primary); background: linear-gradient(135deg, #1E6BFF, #4D8FFF);
color: var(--color-text-inverse); color: #fff;
border-bottom-right-radius: 4px; border-bottom-right-radius: 4px;
} }
.doctor .bubbleContent { .doctor .bubbleContent {
background: var(--color-white); background: #fff;
color: var(--color-text-primary); color: var(--color-text-primary);
border-bottom-left-radius: 4px; border-bottom-left-radius: 4px;
box-shadow: var(--shadow-sm); box-shadow: 0 1px 2px rgba(0,0,0,0.06);
} }
.bubbleTime { .bubbleTime {
font-size: 10px; font-size: 10px;
color: var(--color-text-tertiary); color: var(--color-text-tertiary);
margin-top: 4px; margin-top: 4px;
padding: 0 4px;
} }
.patient .bubbleTime { text-align: right; }
.inputBar { .inputBar {
display: flex; display: flex;
gap: 8px; gap: 8px;
padding: 10px 14px; padding: 10px 14px;
background: var(--color-white); background: #fff;
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
padding-bottom: env(safe-area-inset-bottom, 10px); padding-bottom: env(safe-area-inset-bottom, 10px);
} }
.input { .input {
flex: 1; flex: 1;
padding: 10px 14px; padding: 10px 16px;
background: var(--color-bg); background: #EDF1F7;
border-radius: var(--radius-full); border: none;
font-size: var(--font-size-sm); border-radius: 24px;
font-size: var(--font-size-base);
outline: none;
font-family: var(--font-family);
} }
.sendBtn { .sendBtn {
padding: 10px 18px; width: 42px;
height: 42px;
background: var(--color-primary); background: var(--color-primary);
color: var(--color-text-inverse); color: #fff;
border-radius: var(--radius-full); border: none;
font-size: var(--font-size-sm); border-radius: 50%;
font-weight: 500; font-size: 18px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
} }
.sendBtn:disabled { .sendBtn:disabled {

View File

@@ -13,7 +13,7 @@ export function ReportUploadPage() {
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [category, setCategory] = useState('血液检查'); const [category, setCategory] = useState('血液检查');
const [files, setFiles] = useState<File[]>([]); const [files, setFiles] = useState<File[]>([]);
const [loading, setLoading] = useState(false); const [uploading, setUploading] = useState(false);
const categories = ['血液检查', '心电图', '影像学', '尿液检查', '其他']; const categories = ['血液检查', '心电图', '影像学', '尿液检查', '其他'];
@@ -21,7 +21,6 @@ export function ReportUploadPage() {
if (e.target.files) { if (e.target.files) {
setFiles((prev) => [...prev, ...Array.from(e.target.files!)]); setFiles((prev) => [...prev, ...Array.from(e.target.files!)]);
} }
// Reset so the same file can be selected again
if (fileRef.current) fileRef.current.value = ''; if (fileRef.current) fileRef.current.value = '';
}; };
@@ -31,15 +30,33 @@ export function ReportUploadPage() {
const handleSubmit = async () => { const handleSubmit = async () => {
if (!title.trim()) { toast('请输入报告名称', 'error'); return; } if (!title.trim()) { toast('请输入报告名称', 'error'); return; }
setLoading(true); setUploading(true);
try { try {
await reportService.uploadReport({ title, category }); // Step 1: Upload files
const imageUrls: string[] = [];
for (const file of files) {
const formData = new FormData();
formData.append('file', file);
const token = JSON.parse(localStorage.getItem('hrt_auth') || '{}')?.state?.token;
const res = await fetch('http://localhost:5000/api/files/upload', {
method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: formData,
});
if (res.ok) {
const data = await res.json();
imageUrls.push(data.url);
}
}
// Step 2: Create report
await reportService.uploadReport({ title, category, imageUrls });
toast('上传成功'); toast('上传成功');
setTimeout(() => navigate('/services/reports'), 800); setTimeout(() => navigate('/services/reports'), 800);
} catch { } catch {
toast('上传失败,请检查后端是否运行', 'error'); toast('上传失败,请检查后端是否运行', 'error');
} finally { } finally {
setLoading(false); setUploading(false);
} }
}; };
@@ -59,30 +76,25 @@ export function ReportUploadPage() {
<div className={styles.uploadArea} onClick={() => fileRef.current?.click()}> <div className={styles.uploadArea} onClick={() => fileRef.current?.click()}>
<span style={{ fontSize: 36 }}>📷</span> <span style={{ fontSize: 36 }}>📷</span>
<span style={{ fontSize: 14, color: '#6B7280' }}></span> <span style={{ fontSize: 14, color: '#6B7280' }}></span>
<span style={{ fontSize: 11, color: '#9CA3AF' }}> jpgpng</span> <span style={{ fontSize: 11, color: '#9CA3AF' }}>{files.length > 0 ? `已选 ${files.length} 个文件` : '支持 jpg、png、pdf可多选'}</span>
</div> </div>
<input <input ref={fileRef} type="file" accept="image/*,.pdf" multiple style={{ display: 'none' }} onChange={handleFileChange} />
ref={fileRef}
type="file"
accept="image/*"
multiple
style={{ display: 'none' }}
onChange={handleFileChange}
/>
{files.length > 0 && ( {files.length > 0 && (
<div className={styles.fileList}> <div className={styles.fileList}>
{files.map((file, i) => ( {files.map((file, i) => (
<div key={i} className={styles.fileItem}> <div key={i} className={styles.fileItem}>
<span className={styles.fileName}>📎 {file.name}</span> <span className={styles.fileName}>
{file.type.startsWith('image/') ? '🖼' : '📄'} {file.name} ({(file.size / 1024).toFixed(0)}KB)
</span>
<button className={styles.fileRemove} onClick={() => removeFile(i)}></button> <button className={styles.fileRemove} onClick={() => removeFile(i)}></button>
</div> </div>
))} ))}
</div> </div>
)} )}
<Button variant="primary" size="lg" fullWidth loading={loading} onClick={handleSubmit}> <Button variant="primary" size="lg" fullWidth loading={uploading} onClick={handleSubmit}>
{uploading ? '上传中...' : '提交上传'}
</Button> </Button>
</div> </div>
<ToastContainer /> <ToastContainer />

View File

@@ -47,11 +47,11 @@ export async function getReports(): Promise<Report[]> {
return res.data.map(mapReport); return res.data.map(mapReport);
} }
export async function uploadReport(data: { title: string; category: string }): Promise<Report> { export async function uploadReport(data: { title: string; category: string; imageUrls?: string[] }): Promise<Report> {
const res = await api.post<RawReport>('/api/reports', { const res = await api.post<RawReport>('/api/reports', {
title: data.title, title: data.title,
category: data.category, category: data.category,
imageUrls: [], imageUrls: data.imageUrls || [],
}); });
return mapReport(res.data); return mapReport(res.data);
} }

View File

@@ -6,7 +6,6 @@ echo ==========================================
echo HealthManager Dev Environment echo HealthManager Dev Environment
echo ========================================== echo ==========================================
set "DOTNET=C:\Program Files\dotnet\dotnet.exe"
set "REDIS=C:\Program Files\Redis\redis-server.exe" set "REDIS=C:\Program Files\Redis\redis-server.exe"
set "PG_DATA=D:\APP\data\pgdata" set "PG_DATA=D:\APP\data\pgdata"
set "PG_BIN=D:\PostgreSQL\18\pgsql\bin" set "PG_BIN=D:\PostgreSQL\18\pgsql\bin"
@@ -48,15 +47,11 @@ if errorlevel 1 (
echo. echo.
echo [4/6] Starting Backend API... echo [4/6] Starting Backend API...
cd /d "%~dp0backend" cd /d "%~dp0backend"
if exist "%DOTNET%" ( start "HealthManager API" dotnet run --project src\HealthManager.WebApi --urls "http://localhost:5000" --environment Development
start "HealthManager API" "%DOTNET%" run --project src\HealthManager.WebApi --urls "http://localhost:5000" --environment Development echo Backend API starting (http://localhost:5000)
echo Backend API starting (http://localhost:5000) echo Swagger: http://localhost:5000/swagger
echo Swagger: http://localhost:5000/swagger echo Waiting 15s for backend to boot...
echo Waiting 15s for backend to boot... timeout /t 15 /nobreak >nul
timeout /t 15 /nobreak >nul
) else (
echo [ERROR] .NET SDK not found at %DOTNET%
)
echo. echo.
echo [5/6] Starting Patient Frontend... echo [5/6] Starting Patient Frontend...