fix: bat uses dotnet directly, doctor frontend gets port 5174
This commit is contained in:
104
backend/src/HealthManager.WebApi/Controllers/FileController.cs
Normal file
104
backend/src/HealthManager.WebApi/Controllers/FileController.cs
Normal 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",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
const navItems = [
|
||||
@@ -16,6 +16,7 @@ const textMuted = '#8E9DB5';
|
||||
export function DoctorLayout() {
|
||||
const { user, logout } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
@@ -76,7 +77,9 @@ export function DoctorLayout() {
|
||||
|
||||
{/* Main content */}
|
||||
<main style={{ flex: 1, background: '#F2F5FA', overflow: 'auto' }}>
|
||||
<Outlet />
|
||||
<div key={location.pathname} style={{ animation: 'fadeIn 0.2s ease-out' }}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,13 +8,26 @@ interface RawReport {
|
||||
summary?: string; suggestions?: string;
|
||||
patientName?: string; doctorName?: 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;
|
||||
}
|
||||
|
||||
export function ReportDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [report, setReport] = useState<RawReport | null>(null);
|
||||
const [interpretation, setInterpretation] = useState('');
|
||||
const [lightbox, setLightbox] = useState<string | null>(null);
|
||||
|
||||
// Interpretation form
|
||||
const [summary, setSummary] = useState('');
|
||||
const [riskLevel, setRiskLevel] = useState('normal');
|
||||
const [suggestions, setSuggestions] = useState('');
|
||||
const [items, setItems] = useState<{ itemName: string; resultValue: string; unit: string; referenceRange: string; isAbnormal: boolean }[]>([
|
||||
{ itemName: '', resultValue: '', unit: '', referenceRange: '', isAbnormal: false },
|
||||
]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -22,69 +35,130 @@ export function ReportDetailPage() {
|
||||
api.get<RawReport>(`/api/reports/${id}`).then((r) => setReport(r.data)).catch(() => {});
|
||||
}, [id]);
|
||||
|
||||
const addItem = () => {
|
||||
setItems((prev) => [...prev, { itemName: '', resultValue: '', unit: '', referenceRange: '', isAbnormal: false }]);
|
||||
};
|
||||
|
||||
const updateItem = (index: number, field: string, value: string | boolean) => {
|
||||
setItems((prev) => prev.map((item, i) => i === index ? { ...item, [field]: value } : item));
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
if (items.length <= 1) return;
|
||||
setItems((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleInterpret = async () => {
|
||||
if (!interpretation.trim() || !id) return;
|
||||
if (!summary.trim() || !id) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await api.post(`/api/reports/${id}/interpret`, {
|
||||
summary: interpretation,
|
||||
items: [],
|
||||
riskLevel: 'normal',
|
||||
suggestions: null,
|
||||
summary,
|
||||
items: items.filter((it) => it.itemName.trim()),
|
||||
riskLevel,
|
||||
suggestions: suggestions || null,
|
||||
});
|
||||
// Refetch report to show updated status
|
||||
const updated = await api.get<RawReport>(`/api/reports/${id}`);
|
||||
setReport(updated.data);
|
||||
setInterpretation('');
|
||||
alert('interpretation submitted');
|
||||
} catch { alert('submit failed'); }
|
||||
finally { setSubmitting(false); }
|
||||
};
|
||||
|
||||
if (!report) return <div style={{ padding: 24 }}>loading...</div>;
|
||||
if (!report) return <div style={{ padding: 24 }}>Loading...</div>;
|
||||
|
||||
const isCompleted = report.status === 'completed';
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Link to="/reports" style={{ fontSize: 13, color: '#1976d2' }}>← back to reports</Link>
|
||||
<Link to="/reports" style={{ fontSize: 13, color: '#1976d2' }}>← Back to Reports</Link>
|
||||
|
||||
<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={{ marginTop: 12, fontSize: 14, color: '#666' }}>
|
||||
<div>Patient: {report.patientName || 'unknown'}</div>
|
||||
<div>Category: {report.category}</div>
|
||||
<div>Status: {report.status}</div>
|
||||
<div>Submitted: {report.createdAt?.split('T')[0]}</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0 }}>{report.title}</h2>
|
||||
<div style={{ marginTop: 8, fontSize: 13, color: '#888' }}>
|
||||
Patient: {report.patientName || 'unknown'} |
|
||||
Category: {report.category} |
|
||||
Date: {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 ? 'Completed' : 'Pending Review'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Image gallery */}
|
||||
{report.imageUrls && report.imageUrls.length > 0 && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<h4 style={{ fontSize: 14, marginBottom: 8 }}>Report Images</h4>
|
||||
{report.imageUrls.map((url, i) => (
|
||||
<div key={i} style={{ padding: '8px 12px', background: '#f5f5f5', borderRadius: 4, marginBottom: 4, fontSize: 12 }}>
|
||||
{url}
|
||||
</div>
|
||||
))}
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<h4 style={{ fontSize: 14, marginBottom: 8 }}>Uploaded Images ({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: 8, overflow: 'hidden',
|
||||
cursor: 'pointer', border: '2px solid #eee',
|
||||
background: '#f5f5f5', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`http://localhost:5000${url}`}
|
||||
alt={`report-${i}`}
|
||||
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'cover' }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report.status === 'completed' && (
|
||||
<div style={{ marginTop: 16, padding: 16, background: '#e8f5e9', borderRadius: 8 }}>
|
||||
<h4 style={{ fontSize: 14, marginBottom: 4 }}>Interpretation Result</h4>
|
||||
<p style={{ fontSize: 13, color: '#555' }}>{report.summary || 'No summary'}</p>
|
||||
{report.riskLevel && <p style={{ fontSize: 12, color: '#888' }}>Risk: {report.riskLevel}</p>}
|
||||
{report.suggestions && <p style={{ fontSize: 12, color: '#888' }}>Suggestions: {report.suggestions}</p>}
|
||||
{/* Lightbox */}
|
||||
{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={`http://localhost:5000${lightbox}`} alt="preview" style={{ maxWidth: '90vw', maxHeight: '90vh', borderRadius: 8 }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completed interpretation display */}
|
||||
{isCompleted && (
|
||||
<div style={{ marginTop: 20, padding: 16, background: '#e8f5e9', borderRadius: 8 }}>
|
||||
<h4 style={{ fontSize: 14, marginBottom: 8 }}>Interpretation Result</h4>
|
||||
<div style={{ fontSize: 13 }}>
|
||||
<p><strong>Risk Level:</strong> <span style={{
|
||||
color: report.riskLevel === 'normal' ? '#2e7d32' : report.riskLevel === 'abnormal' ? '#c62828' : '#f57c00',
|
||||
fontWeight: 600,
|
||||
}}>{report.riskLevel || '-'}</span></p>
|
||||
<p><strong>Summary:</strong> {report.summary || '-'}</p>
|
||||
{report.suggestions && <p><strong>Suggestions:</strong> {report.suggestions}</p>}
|
||||
</div>
|
||||
|
||||
{report.items && report.items.length > 0 && (
|
||||
<table style={{ width: '100%', marginTop: 8, borderCollapse: 'collapse', fontSize: 12 }}>
|
||||
<thead><tr style={{ textAlign: 'left', borderBottom: '1px solid #ddd' }}>
|
||||
<th style={{ padding: 4 }}>Item</th><th style={{ padding: 4 }}>Value</th><th style={{ padding: 4 }}>Range</th><th style={{ padding: 4 }}>Abnormal</th>
|
||||
<table style={{ width: '100%', marginTop: 12, borderCollapse: 'collapse', fontSize: 12 }}>
|
||||
<thead><tr style={{ textAlign: 'left', borderBottom: '2px solid #c8e6c9' }}>
|
||||
<th style={{ padding: '6px 8px' }}>Item</th>
|
||||
<th style={{ padding: '6px 8px' }}>Result</th>
|
||||
<th style={{ padding: '6px 8px' }}>Reference</th>
|
||||
<th style={{ padding: '6px 8px' }}>Abnormal</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{report.items.map((item) => (
|
||||
<tr key={item.id} style={{ borderBottom: '1px solid #f0f0f0' }}>
|
||||
<td style={{ padding: 4 }}>{item.itemName}</td>
|
||||
<td style={{ padding: 4 }}>{item.resultValue} {item.unit || ''}</td>
|
||||
<td style={{ padding: 4 }}>{item.referenceRange || '-'}</td>
|
||||
<td style={{ padding: 4, color: item.isAbnormal ? '#c62828' : '#2e7d32' }}>{item.isAbnormal ? 'Yes' : 'No'}</td>
|
||||
<tr key={item.id} style={{ borderBottom: '1px solid #e8f5e9' }}>
|
||||
<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 ? '#c62828' : '#2e7d32', fontWeight: 500 }}>
|
||||
{item.isAbnormal ? 'Yes' : 'No'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -93,16 +167,72 @@ export function ReportDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report.status !== 'completed' && (
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<h4 style={{ fontSize: 14, marginBottom: 8 }}>Doctor Interpretation</h4>
|
||||
<textarea value={interpretation} onChange={(e) => setInterpretation(e.target.value)}
|
||||
placeholder="Enter your interpretation..."
|
||||
rows={5}
|
||||
style={{ width: '100%', padding: 12, border: '1px solid #ddd', borderRadius: 4, fontSize: 14, resize: 'vertical' }} />
|
||||
{/* Interpretation form (only for pending reports) */}
|
||||
{!isCompleted && (
|
||||
<div style={{ marginTop: 24, borderTop: '1px solid #eee', paddingTop: 20 }}>
|
||||
<h3 style={{ fontSize: 15, marginBottom: 16 }}>Doctor Interpretation</h3>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 4 }}>Summary</label>
|
||||
<textarea
|
||||
value={summary}
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
placeholder="Enter your interpretation summary..."
|
||||
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 }}>Risk Level</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">Normal</option>
|
||||
<option value="attention">Attention</option>
|
||||
<option value="abnormal">Abnormal</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 4 }}>Suggestions</label>
|
||||
<input
|
||||
value={suggestions}
|
||||
onChange={(e) => setSuggestions(e.target.value)}
|
||||
placeholder="e.g. Continue current medication"
|
||||
style={{ width: '100%', padding: '8px 12px', border: '1px solid #ddd', borderRadius: 6, fontSize: 13, fontFamily: 'inherit', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test items */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}>Test Items</label>
|
||||
{items.map((item, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 8, marginBottom: 6, alignItems: 'center' }}>
|
||||
<input placeholder="Item name" 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="Result" 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="Unit" value={item.unit} onChange={(e) => updateItem(i, 'unit', e.target.value)}
|
||||
style={{ width: 80, padding: '6px 10px', border: '1px solid #ddd', borderRadius: 4, fontSize: 12, fontFamily: 'inherit' }} />
|
||||
<input placeholder="Range" 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: 4 }}>
|
||||
<input type="checkbox" checked={item.isAbnormal} onChange={(e) => updateItem(i, 'isAbnormal', e.target.checked)} />
|
||||
Abnormal
|
||||
</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 }}>
|
||||
+ Add Item
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button onClick={handleInterpret} disabled={submitting} style={{
|
||||
marginTop: 8, padding: '10px 24px', background: '#1976d2', color: '#fff',
|
||||
border: 'none', borderRadius: 4, fontSize: 14, opacity: submitting ? 0.7 : 1,
|
||||
padding: '10px 28px', background: '#1976d2', color: '#fff',
|
||||
border: 'none', borderRadius: 6, fontSize: 14, cursor: 'pointer',
|
||||
opacity: submitting ? 0.7 : 1, marginTop: 8,
|
||||
}}>
|
||||
{submitting ? 'Submitting...' : 'Submit Interpretation'}
|
||||
</button>
|
||||
|
||||
@@ -4,4 +4,7 @@ import react from '@vitejs/plugin-react'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5174,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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 styles from './AppLayout.module.css';
|
||||
|
||||
export function AppLayout() {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<div className={styles.layout}>
|
||||
<main className={styles.main}>
|
||||
<Outlet />
|
||||
<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 />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</main>
|
||||
<TabBar />
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export function ReportUploadPage() {
|
||||
const [title, setTitle] = useState('');
|
||||
const [category, setCategory] = useState('血液检查');
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const categories = ['血液检查', '心电图', '影像学', '尿液检查', '其他'];
|
||||
|
||||
@@ -21,7 +21,6 @@ export function ReportUploadPage() {
|
||||
if (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 = '';
|
||||
};
|
||||
|
||||
@@ -31,15 +30,33 @@ export function ReportUploadPage() {
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim()) { toast('请输入报告名称', 'error'); return; }
|
||||
setLoading(true);
|
||||
setUploading(true);
|
||||
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('上传成功');
|
||||
setTimeout(() => navigate('/services/reports'), 800);
|
||||
} catch {
|
||||
toast('上传失败,请检查后端是否运行', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -59,30 +76,25 @@ export function ReportUploadPage() {
|
||||
<div className={styles.uploadArea} onClick={() => fileRef.current?.click()}>
|
||||
<span style={{ fontSize: 36 }}>📷</span>
|
||||
<span style={{ fontSize: 14, color: '#6B7280' }}>点击选择报告图片</span>
|
||||
<span style={{ fontSize: 11, color: '#9CA3AF' }}>支持 jpg、png,可多选</span>
|
||||
<span style={{ fontSize: 11, color: '#9CA3AF' }}>{files.length > 0 ? `已选 ${files.length} 个文件` : '支持 jpg、png、pdf,可多选'}</span>
|
||||
</div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<input ref={fileRef} type="file" accept="image/*,.pdf" multiple style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className={styles.fileList}>
|
||||
{files.map((file, i) => (
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button variant="primary" size="lg" fullWidth loading={loading} onClick={handleSubmit}>
|
||||
提交上传
|
||||
<Button variant="primary" size="lg" fullWidth loading={uploading} onClick={handleSubmit}>
|
||||
{uploading ? '上传中...' : '提交上传'}
|
||||
</Button>
|
||||
</div>
|
||||
<ToastContainer />
|
||||
|
||||
@@ -47,11 +47,11 @@ export async function getReports(): Promise<Report[]> {
|
||||
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', {
|
||||
title: data.title,
|
||||
category: data.category,
|
||||
imageUrls: [],
|
||||
imageUrls: data.imageUrls || [],
|
||||
});
|
||||
return mapReport(res.data);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ echo ==========================================
|
||||
echo HealthManager Dev Environment
|
||||
echo ==========================================
|
||||
|
||||
set "DOTNET=C:\Program Files\dotnet\dotnet.exe"
|
||||
set "REDIS=C:\Program Files\Redis\redis-server.exe"
|
||||
set "PG_DATA=D:\APP\data\pgdata"
|
||||
set "PG_BIN=D:\PostgreSQL\18\pgsql\bin"
|
||||
@@ -48,15 +47,11 @@ if errorlevel 1 (
|
||||
echo.
|
||||
echo [4/6] Starting Backend API...
|
||||
cd /d "%~dp0backend"
|
||||
if exist "%DOTNET%" (
|
||||
start "HealthManager API" "%DOTNET%" run --project src\HealthManager.WebApi --urls "http://localhost:5000" --environment Development
|
||||
echo Backend API starting (http://localhost:5000)
|
||||
echo Swagger: http://localhost:5000/swagger
|
||||
echo Waiting 15s for backend to boot...
|
||||
timeout /t 15 /nobreak >nul
|
||||
) else (
|
||||
echo [ERROR] .NET SDK not found at %DOTNET%
|
||||
)
|
||||
start "HealthManager API" dotnet run --project src\HealthManager.WebApi --urls "http://localhost:5000" --environment Development
|
||||
echo Backend API starting (http://localhost:5000)
|
||||
echo Swagger: http://localhost:5000/swagger
|
||||
echo Waiting 15s for backend to boot...
|
||||
timeout /t 15 /nobreak >nul
|
||||
|
||||
echo.
|
||||
echo [5/6] Starting Patient Frontend...
|
||||
|
||||
Reference in New Issue
Block a user