初始化医疗报告生成项目,添加核心代码文件

This commit is contained in:
2026-02-13 18:32:52 +08:00
commit faaf2158d4
69 changed files with 29836 additions and 0 deletions

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>医疗报告分析系统</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

2889
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
frontend/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "medical-report-analyzer",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"axios": "^1.6.0",
"lucide-react": "^0.292.0"
},
"devDependencies": {
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5",
"vite": "^5.0.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

261
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,261 @@
import React, { useState, useEffect } from 'react'
import { FileText, Search, Brain, Check, AlertCircle, Trash2, Layers } from 'lucide-react'
import FileUpload from './components/FileUpload'
import ReportList from './components/ReportList'
import ReportDetail from './components/ReportDetail'
import IntegrationResult from './components/IntegrationResult'
import BatchUpload from './components/BatchUpload'
import { api } from './services/api'
function App() {
const [reports, setReports] = useState([])
const [selectedReport, setSelectedReport] = useState(null)
const [integrationResult, setIntegrationResult] = useState(null)
const [selectedReports, setSelectedReports] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [activeTab, setActiveTab] = useState('batch') // 默认使用批量模式
const loadReports = async () => {
try {
const data = await api.getReports()
setReports(data.reports || [])
} catch (err) {
setError('加载报告列表失败')
}
}
const handleFileUpload = async (file) => {
try {
setLoading(true)
setError(null)
const result = await api.uploadFile(file)
await loadReports()
return result
} catch (err) {
setError('文件上传失败: ' + err.message)
throw err
} finally {
setLoading(false)
}
}
const handleOCR = async (fileId) => {
try {
setLoading(true)
setError(null)
await api.performOCR(fileId)
await loadReports()
} catch (err) {
setError('OCR识别失败: ' + err.message)
} finally {
setLoading(false)
}
}
const handleAnalyze = async (fileId) => {
try {
setLoading(true)
setError(null)
await api.analyzeReport(fileId)
await loadReports()
const detail = await api.getReportDetail(fileId)
setSelectedReport(detail.report)
} catch (err) {
setError('报告分析失败: ' + err.message)
} finally {
setLoading(false)
}
}
const handleIntegrate = async () => {
if (selectedReports.length === 0) {
setError('请至少选择一份报告进行整合')
return
}
try {
setLoading(true)
setError(null)
const result = await api.integrateReports(selectedReports)
setIntegrationResult(result)
} catch (err) {
setError('报告整合失败: ' + err.message)
} finally {
setLoading(false)
}
}
const handleDelete = async (fileId) => {
if (!confirm('确定要删除这份报告吗?')) return
try {
setLoading(true)
await api.deleteReport(fileId)
await loadReports()
if (selectedReport?.id === fileId) {
setSelectedReport(null)
}
setSelectedReports(prev => prev.filter(id => id !== fileId))
} catch (err) {
setError('删除失败: ' + err.message)
} finally {
setLoading(false)
}
}
const toggleReportSelection = (reportId) => {
setSelectedReports(prev =>
prev.includes(reportId)
? prev.filter(id => id !== reportId)
: [...prev, reportId]
)
}
const viewReportDetail = async (reportId) => {
try {
const detail = await api.getReportDetail(reportId)
setSelectedReport(detail.report)
setIntegrationResult(null)
} catch (err) {
setError('加载报告详情失败')
}
}
const handleGeneratePDF = async (fileId) => {
try {
setLoading(true)
setError(null)
const result = await api.generatePDF(fileId)
// 生成成功后直接下载
api.downloadPDF(fileId)
// 显示成功提示
alert('PDF报告生成成功')
} catch (err) {
setError('PDF生成失败: ' + err.message)
} finally {
setLoading(false)
}
}
const handleBatchGenerate = async (files, patientName) => {
try {
setLoading(true)
setError(null)
const result = await api.generateComprehensiveReport(files, patientName)
// 生成成功后直接下载
api.downloadComprehensiveReport(result.pdf_filename)
// 显示成功提示
alert(`综合健康报告生成成功!\n患者${result.patient_name}\n报告数量${result.report_count}`)
} catch (err) {
setError('综合报告生成失败: ' + err.message)
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
{/* Header */}
<header className="bg-white shadow-md">
<div className="max-w-7xl mx-auto px-4 py-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<FileText className="w-8 h-8 text-indigo-600" />
<h1 className="text-3xl font-bold text-gray-900">医疗报告分析系统</h1>
</div>
<div className="flex items-center space-x-2 text-sm text-gray-600">
<div className="flex items-center space-x-1">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>系统运行中</span>
</div>
</div>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 py-8">
{/* Error Alert */}
{error && (
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-4 flex items-start">
<AlertCircle className="w-5 h-5 text-red-500 mt-0.5 mr-3" />
<div>
<p className="text-red-800 font-medium">错误</p>
<p className="text-red-700 text-sm">{error}</p>
</div>
<button onClick={() => setError(null)} className="ml-auto text-red-500 hover:text-red-700">
×
</button>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Panel - Upload & Reports */}
<div className="lg:col-span-1 space-y-6">
{/* 选项卡切换 */}
<div className="bg-white rounded-lg shadow-md p-2 flex space-x-2">
<button
className="flex-1 py-2 px-4 rounded-md font-medium bg-indigo-600 text-white cursor-default"
>
<Layers className="w-4 h-4 inline mr-2" />
批量生成
</button>
</div>
{/* File Upload */}
<BatchUpload onGenerate={handleBatchGenerate} loading={loading} />
{/* Reports List - 只在单个上传模式显示 */}
{false && activeTab === 'single' && (
<ReportList
reports={reports}
selectedReports={selectedReports}
onToggleSelect={toggleReportSelection}
onView={viewReportDetail}
onOCR={handleOCR}
onAnalyze={handleAnalyze}
onDelete={handleDelete}
loading={loading}
/>
)}
{/* Integration Button */}
{false && reports.length > 0 && (
<div className="bg-white rounded-lg shadow-md p-4">
<button
onClick={handleIntegrate}
disabled={loading || selectedReports.length === 0}
className="w-full bg-indigo-600 text-white py-3 rounded-lg font-medium hover:bg-indigo-700 disabled:bg-gray-300 disabled:cursor-not-allowed flex items-center justify-center space-x-2 transition-colors"
>
<Brain className="w-5 h-5" />
<span>整合分析 ({selectedReports.length})</span>
</button>
</div>
)}
</div>
{/* Right Panel - Details */}
<div className="lg:col-span-2">
{integrationResult ? (
<IntegrationResult result={integrationResult} onClose={() => setIntegrationResult(null)} />
) : selectedReport ? (
<ReportDetail
report={selectedReport}
onClose={() => setSelectedReport(null)}
onGeneratePDF={handleGeneratePDF}
/>
) : (
<div className="bg-white rounded-lg shadow-md p-12 text-center">
<Brain className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<p className="text-gray-500 text-lg">上传医疗报告开始分析</p>
<p className="text-gray-400 text-sm mt-2">支持 JPGPNGPDF 格式</p>
</div>
)}
</div>
</div>
</main>
</div>
)
}
export default App

View File

@@ -0,0 +1,191 @@
import React, { useState } from 'react'
import { Upload, X, FileText, User, Loader } from 'lucide-react'
function BatchUpload({ onGenerate, loading }) {
const [selectedFiles, setSelectedFiles] = useState([])
const [patientName, setPatientName] = useState('')
const [dragActive, setDragActive] = useState(false)
const handleFileChange = (e) => {
const files = Array.from(e.target.files)
addFiles(files)
}
const addFiles = (files) => {
const validFiles = files.filter(file => {
const ext = file.name.toLowerCase()
return ext.endsWith('.pdf') || ext.endsWith('.jpg') ||
ext.endsWith('.jpeg') || ext.endsWith('.png')
})
setSelectedFiles(prev => [...prev, ...validFiles])
}
const removeFile = (index) => {
setSelectedFiles(prev => prev.filter((_, i) => i !== index))
}
const handleDrag = (e) => {
e.preventDefault()
e.stopPropagation()
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true)
} else if (e.type === 'dragleave') {
setDragActive(false)
}
}
const handleDrop = (e) => {
e.preventDefault()
e.stopPropagation()
setDragActive(false)
const files = Array.from(e.dataTransfer.files)
addFiles(files)
}
const handleSubmit = async () => {
if (selectedFiles.length === 0) {
alert('请至少选择一个文件')
return
}
if (!patientName.trim()) {
alert('请输入患者姓名')
return
}
await onGenerate(selectedFiles, patientName.trim())
}
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
}
return (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center">
<Upload className="w-5 h-5 mr-2 text-indigo-600" />
批量上传生成综合报告
</h2>
<p className="text-sm text-gray-600 mb-4">
上传多个检测报告血常规尿常规生化检查等系统将自动识别并生成综合健康报告
</p>
{/* 患者姓名输入 */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
<User className="w-4 h-4 inline mr-1" />
患者姓名 *
</label>
<input
type="text"
value={patientName}
onChange={(e) => setPatientName(e.target.value)}
placeholder="请输入患者姓名"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
disabled={loading}
/>
</div>
{/* 文件上传区域 */}
<div
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
dragActive
? 'border-indigo-500 bg-indigo-50'
: 'border-gray-300 hover:border-gray-400'
}`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
<Upload className="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p className="text-gray-600 mb-2">
拖拽文件到此处
<label className="text-indigo-600 hover:text-indigo-700 cursor-pointer ml-1">
点击选择文件
<input
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png"
onChange={handleFileChange}
className="hidden"
disabled={loading}
/>
</label>
</p>
<p className="text-sm text-gray-500">支持 PDFJPGPNG 格式可多选</p>
</div>
{/* 已选文件列表 */}
{selectedFiles.length > 0 && (
<div className="mt-4">
<h3 className="text-sm font-medium text-gray-700 mb-2">
已选择 {selectedFiles.length} 个文件
</h3>
<div className="space-y-2 max-h-64 overflow-y-auto">
{selectedFiles.map((file, index) => (
<div
key={index}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div className="flex items-center flex-1 min-w-0">
<FileText className="w-5 h-5 text-gray-400 mr-2 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{file.name}
</p>
<p className="text-xs text-gray-500">
{formatFileSize(file.size)}
</p>
</div>
</div>
<button
onClick={() => removeFile(index)}
disabled={loading}
className="ml-2 text-red-500 hover:text-red-700 disabled:opacity-50"
>
<X className="w-5 h-5" />
</button>
</div>
))}
</div>
</div>
)}
{/* 生成按钮 */}
<button
onClick={handleSubmit}
disabled={loading || selectedFiles.length === 0 || !patientName.trim()}
className="w-full mt-6 bg-indigo-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-indigo-700 disabled:bg-gray-300 disabled:cursor-not-allowed flex items-center justify-center space-x-2 transition-colors"
>
{loading ? (
<>
<Loader className="w-5 h-5 animate-spin" />
<span>正在生成综合报告...</span>
</>
) : (
<>
<FileText className="w-5 h-5" />
<span>生成综合健康报告</span>
</>
)}
</button>
{/* 说明 */}
<div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-xs text-yellow-800">
<strong>注意</strong>上传的文件仅用于生成报告处理完成后会自动删除不会永久保存在系统中
</p>
</div>
</div>
)
}
export default BatchUpload

View File

@@ -0,0 +1,91 @@
import React, { useState } from 'react'
import { Upload, Loader } from 'lucide-react'
function FileUpload({ onUpload, loading }) {
const [dragging, setDragging] = useState(false)
const handleDragOver = (e) => {
e.preventDefault()
setDragging(true)
}
const handleDragLeave = () => {
setDragging(false)
}
const handleDrop = (e) => {
e.preventDefault()
setDragging(false)
const files = Array.from(e.dataTransfer.files)
if (files.length > 0) {
handleFileSelect(files[0])
}
}
const handleFileSelect = async (file) => {
if (!file) return
const allowedTypes = ['image/jpeg', 'image/png', 'image/jpg', 'application/pdf']
if (!allowedTypes.includes(file.type)) {
alert('不支持的文件格式,请上传 JPG、PNG 或 PDF 文件')
return
}
try {
await onUpload(file)
} catch (err) {
console.error('Upload failed:', err)
}
}
return (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4 flex items-center">
<Upload className="w-5 h-5 mr-2 text-indigo-600" />
上传医疗报告
</h2>
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
dragging
? 'border-indigo-500 bg-indigo-50'
: 'border-gray-300 hover:border-indigo-400'
}`}
>
{loading ? (
<div className="flex flex-col items-center">
<Loader className="w-8 h-8 text-indigo-600 animate-spin mb-2" />
<p className="text-gray-600">上传中...</p>
</div>
) : (
<>
<Upload className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<p className="text-gray-700 mb-2">拖拽文件到此处或点击上传</p>
<p className="text-sm text-gray-500 mb-4">支持 JPGPNGPDF 格式</p>
<label className="inline-block">
<input
type="file"
className="hidden"
accept=".jpg,.jpeg,.png,.pdf"
onChange={(e) => {
if (e.target.files[0]) {
handleFileSelect(e.target.files[0])
}
}}
disabled={loading}
/>
<span className="px-4 py-2 bg-indigo-600 text-white rounded-lg cursor-pointer hover:bg-indigo-700 transition-colors">
选择文件
</span>
</label>
</>
)}
</div>
</div>
)
}
export default FileUpload

View File

@@ -0,0 +1,161 @@
import React from 'react'
import { X, Brain, TrendingUp, AlertTriangle, Lightbulb, Calendar } from 'lucide-react'
function IntegrationResult({ result, onClose }) {
const getSeverityColor = (severity) => {
switch (severity) {
case '高': return 'bg-red-100 border-red-300 text-red-900'
case '中': return 'bg-yellow-100 border-yellow-300 text-yellow-900'
case '低': return 'bg-green-100 border-green-300 text-green-900'
default: return 'bg-gray-100 border-gray-300 text-gray-900'
}
}
return (
<div className="bg-white rounded-lg shadow-md">
{/* Header */}
<div className="border-b border-gray-200 p-6 flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900 flex items-center">
<Brain className="w-6 h-6 mr-2 text-purple-600" />
综合分析报告
</h2>
<p className="text-sm text-gray-600 mt-1">
整合了 {result.report_count || result.reports_included?.length || 0} 份医疗报告
</p>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6 max-h-[calc(100vh-300px)] overflow-y-auto">
{/* Overall Summary */}
<div className="bg-gradient-to-r from-purple-50 to-indigo-50 border border-purple-200 rounded-lg p-6">
<h3 className="font-semibold text-purple-900 mb-3 text-lg">整体健康状况</h3>
<p className="text-purple-800 leading-relaxed">
{result.integrated_analysis?.overall_summary || result.overall_summary || '暂无摘要'}
</p>
</div>
{/* Health Trends */}
{(result.integrated_analysis?.health_trends || result.health_trends) && (
<div>
<h3 className="font-semibold text-gray-900 mb-4 flex items-center text-lg">
<TrendingUp className="w-5 h-5 mr-2 text-blue-600" />
健康趋势
</h3>
<div className="grid gap-3">
{(result.integrated_analysis?.health_trends || result.health_trends).map((trend, index) => (
<div key={index} className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-blue-900">{trend}</p>
</div>
))}
</div>
</div>
)}
{/* Priority Concerns */}
{(result.integrated_analysis?.priority_concerns || result.priority_concerns) &&
(result.integrated_analysis?.priority_concerns || result.priority_concerns).length > 0 && (
<div>
<h3 className="font-semibold text-gray-900 mb-4 flex items-center text-lg">
<AlertTriangle className="w-5 h-5 mr-2 text-red-600" />
优先关注事项
</h3>
<div className="space-y-3">
{(result.integrated_analysis?.priority_concerns || result.priority_concerns).map((concern, index) => (
<div
key={index}
className={`border rounded-lg p-4 ${getSeverityColor(concern.severity)}`}
>
<div className="flex items-start justify-between mb-2">
<p className="font-semibold">{concern.concern}</p>
<span className="text-xs font-medium px-2 py-1 rounded">
{concern.severity}
</span>
</div>
<p className="text-sm">{concern.description}</p>
</div>
))}
</div>
</div>
)}
{/* Comprehensive Assessment */}
{(result.integrated_analysis?.comprehensive_assessment || result.comprehensive_assessment) && (
<div className="bg-gray-50 border border-gray-300 rounded-lg p-5">
<h3 className="font-semibold text-gray-900 mb-3">综合评估</h3>
<p className="text-gray-800 leading-relaxed">
{result.integrated_analysis?.comprehensive_assessment || result.comprehensive_assessment}
</p>
</div>
)}
{/* Integrated Recommendations */}
{(result.integrated_analysis?.integrated_recommendations || result.integrated_recommendations) && (
<div>
<h3 className="font-semibold text-gray-900 mb-4 flex items-center text-lg">
<Lightbulb className="w-5 h-5 mr-2 text-yellow-600" />
综合建议
</h3>
<ul className="space-y-2">
{(result.integrated_analysis?.integrated_recommendations || result.integrated_recommendations).map((rec, index) => (
<li key={index} className="flex items-start bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<span className="inline-block w-2 h-2 bg-yellow-500 rounded-full mt-2 mr-3 flex-shrink-0"></span>
<span className="text-gray-800">{rec}</span>
</li>
))}
</ul>
</div>
)}
{/* Follow-up Suggestions */}
{(result.integrated_analysis?.follow_up_suggestions || result.follow_up_suggestions) && (
<div>
<h3 className="font-semibold text-gray-900 mb-4 flex items-center text-lg">
<Calendar className="w-5 h-5 mr-2 text-green-600" />
后续跟踪建议
</h3>
<ul className="space-y-2">
{(result.integrated_analysis?.follow_up_suggestions || result.follow_up_suggestions).map((suggestion, index) => (
<li key={index} className="flex items-start">
<span className="inline-block w-2 h-2 bg-green-500 rounded-full mt-2 mr-3 flex-shrink-0"></span>
<span className="text-gray-700">{suggestion}</span>
</li>
))}
</ul>
</div>
)}
{/* Reports Included */}
{result.reports_included && result.reports_included.length > 0 && (
<div className="border-t pt-6">
<h3 className="font-semibold text-gray-900 mb-3 text-sm">包含的报告</h3>
<div className="space-y-2">
{result.reports_included.map((report, index) => (
<div key={index} className="text-sm text-gray-600 bg-gray-50 rounded p-2">
<span className="font-medium">{index + 1}. </span>
{report.filename}
</div>
))}
</div>
</div>
)}
{/* Note */}
{result.note && (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
<p className="text-xs text-gray-600 italic">{result.note}</p>
</div>
)}
</div>
</div>
)
}
export default IntegrationResult

View File

@@ -0,0 +1,184 @@
import React, { useState } from 'react'
import { X, FileText, AlertTriangle, CheckCircle, Activity, Download } from 'lucide-react'
function ReportDetail({ report, onClose, onGeneratePDF }) {
const [generatingPDF, setGeneratingPDF] = useState(false)
const handleGeneratePDF = async () => {
setGeneratingPDF(true)
try {
await onGeneratePDF(report.id)
} finally {
setGeneratingPDF(false)
}
}
const analysis = report.analysis || {}
// 兼容不同结构的返回结果
const keyFindings = Array.isArray(analysis.key_findings)
? analysis.key_findings.map((item) =>
typeof item === 'string'
? item
: item?.finding || item?.text || JSON.stringify(item)
)
: []
const abnormalItems = Array.isArray(analysis.abnormal_items)
? analysis.abnormal_items
: []
let riskAssessmentText = ''
if (typeof analysis.risk_assessment === 'string') {
riskAssessmentText = analysis.risk_assessment
} else if (analysis.risk_assessment && typeof analysis.risk_assessment === 'object') {
const ra = analysis.risk_assessment
const parts = []
if (Array.isArray(ra.high_risk) && ra.high_risk.length > 0) {
parts.push(`【高风险】${ra.high_risk.join('')}`)
}
if (Array.isArray(ra.medium_risk) && ra.medium_risk.length > 0) {
parts.push(`【中风险】${ra.medium_risk.join('')}`)
}
if (Array.isArray(ra.low_risk) && ra.low_risk.length > 0) {
parts.push(`【低风险】${ra.low_risk.join('')}`)
}
riskAssessmentText = parts.join('\n')
}
const recommendations = Array.isArray(analysis.recommendations)
? analysis.recommendations.map((item) =>
typeof item === 'string'
? item
: item?.recommendation || item?.text || JSON.stringify(item)
)
: []
return (
<div className="bg-white rounded-lg shadow-md">
{/* Header */}
<div className="border-b border-gray-200 p-6 flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900 flex items-center">
<FileText className="w-6 h-6 mr-2 text-indigo-600" />
报告详情
</h2>
<p className="text-sm text-gray-600 mt-1">{report.filename}</p>
</div>
<div className="flex items-center space-x-3">
<button
onClick={handleGeneratePDF}
disabled={generatingPDF}
className="flex items-center space-x-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
>
<Download className="w-4 h-4" />
<span>{generatingPDF ? '生成中...' : '生成PDF报告'}</span>
</button>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
</div>
{/* Content */}
<div className="p-6 space-y-6 max-h-[calc(100vh-300px)] overflow-y-auto">
{/* Summary */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 className="font-semibold text-blue-900 mb-2 flex items-center">
<Activity className="w-5 h-5 mr-2" />
摘要
</h3>
<p className="text-blue-800">{analysis.summary || '暂无摘要'}</p>
</div>
{/* Key Findings */}
{keyFindings.length > 0 && (
<div>
<h3 className="font-semibold text-gray-900 mb-3 flex items-center">
<CheckCircle className="w-5 h-5 mr-2 text-green-600" />
关键发现
</h3>
<ul className="space-y-2">
{keyFindings.map((finding, index) => (
<li key={index} className="flex items-start">
<span className="inline-block w-2 h-2 bg-green-500 rounded-full mt-2 mr-3 flex-shrink-0"></span>
<span className="text-gray-700">{finding}</span>
</li>
))}
</ul>
</div>
)}
{/* Abnormal Items */}
{abnormalItems.length > 0 && (
<div>
<h3 className="font-semibold text-gray-900 mb-3 flex items-center">
<AlertTriangle className="w-5 h-5 mr-2 text-red-600" />
异常指标
</h3>
<div className="space-y-3">
{abnormalItems.map((item, index) => (
<div key={index} className="bg-red-50 border border-red-200 rounded-lg p-3">
<p className="font-medium text-red-900">{item.name || item}</p>
{item.value && (
<p className="text-sm text-red-700 mt-1">
测量值: {item.value} {item.reference && `(参考: ${item.reference})`}
</p>
)}
</div>
))}
</div>
</div>
)}
{/* Risk Assessment */}
{riskAssessmentText && (
<div>
<h3 className="font-semibold text-gray-900 mb-3">风险评估</h3>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<p className="text-yellow-900 whitespace-pre-wrap">{riskAssessmentText}</p>
</div>
</div>
)}
{/* Recommendations */}
{recommendations.length > 0 && (
<div>
<h3 className="font-semibold text-gray-900 mb-3">建议</h3>
<ul className="space-y-2">
{recommendations.map((rec, index) => (
<li key={index} className="flex items-start">
<span className="inline-block w-2 h-2 bg-indigo-500 rounded-full mt-2 mr-3 flex-shrink-0"></span>
<span className="text-gray-700">{rec}</span>
</li>
))}
</ul>
</div>
)}
{/* OCR Text */}
{report.ocr_text && (
<div>
<h3 className="font-semibold text-gray-900 mb-3">原始文本</h3>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<pre className="text-sm text-gray-700 whitespace-pre-wrap font-mono">
{report.ocr_text}
</pre>
</div>
</div>
)}
{/* Note */}
{analysis.note && (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
<p className="text-xs text-gray-600 italic">{analysis.note}</p>
</div>
)}
</div>
</div>
)
}
export default ReportDetail

View File

@@ -0,0 +1,113 @@
import React from 'react'
import { FileText, Search, Brain, Trash2, CheckCircle, Clock, AlertCircle } from 'lucide-react'
function ReportList({ reports, selectedReports, onToggleSelect, onView, onOCR, onAnalyze, onDelete, loading }) {
const getStatusIcon = (report) => {
if (report.has_analysis) return <CheckCircle className="w-4 h-4 text-green-500" />
if (report.has_ocr) return <Clock className="w-4 h-4 text-blue-500" />
return <AlertCircle className="w-4 h-4 text-gray-400" />
}
const getStatusText = (report) => {
if (report.has_analysis) return '已分析'
if (report.has_ocr) return '已识别'
return '已上传'
}
return (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
报告列表 ({reports.length})
</h2>
{reports.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<FileText className="w-12 h-12 mx-auto mb-2 text-gray-300" />
<p>暂无报告</p>
</div>
) : (
<div className="space-y-3 max-h-[500px] overflow-y-auto">
{reports.map((report) => (
<div
key={report.id}
className={`border rounded-lg p-4 transition-all ${
selectedReports.includes(report.id)
? 'border-indigo-500 bg-indigo-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="flex items-start space-x-3">
<input
type="checkbox"
checked={selectedReports.includes(report.id)}
onChange={() => onToggleSelect(report.id)}
className="mt-1 w-4 h-4 text-indigo-600 rounded focus:ring-indigo-500"
disabled={!report.has_analysis}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2 mb-1">
<FileText className="w-4 h-4 text-gray-600 flex-shrink-0" />
<p className="text-sm font-medium text-gray-900 truncate">
{report.filename}
</p>
</div>
<div className="flex items-center space-x-2 text-xs text-gray-500 mb-2">
{getStatusIcon(report)}
<span>{getStatusText(report)}</span>
</div>
<div className="flex flex-wrap gap-2">
{!report.has_ocr && (
<button
onClick={() => onOCR(report.id)}
disabled={loading}
className="text-xs px-2 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-300 flex items-center space-x-1"
>
<Search className="w-3 h-3" />
<span>识别</span>
</button>
)}
{report.has_ocr && !report.has_analysis && (
<button
onClick={() => onAnalyze(report.id)}
disabled={loading}
className="text-xs px-2 py-1 bg-purple-500 text-white rounded hover:bg-purple-600 disabled:bg-gray-300 flex items-center space-x-1"
>
<Brain className="w-3 h-3" />
<span>分析</span>
</button>
)}
{report.has_analysis && (
<button
onClick={() => onView(report.id)}
className="text-xs px-2 py-1 bg-green-500 text-white rounded hover:bg-green-600 flex items-center space-x-1"
>
<FileText className="w-3 h-3" />
<span>查看</span>
</button>
)}
<button
onClick={() => onDelete(report.id)}
disabled={loading}
className="text-xs px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600 disabled:bg-gray-300 flex items-center space-x-1"
>
<Trash2 className="w-3 h-3" />
<span>删除</span>
</button>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}
export default ReportList

16
frontend/src/index.css Normal file
View File

@@ -0,0 +1,16 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}

10
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,96 @@
import axios from 'axios'
const API_BASE_URL = 'http://localhost:8001'
const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
})
export const api = {
// 上传文件
uploadFile: async (file) => {
const formData = new FormData()
formData.append('file', file)
const response = await axios.post(`${API_BASE_URL}/api/upload`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
return response.data
},
// 执行OCR
performOCR: async (fileId) => {
const response = await apiClient.post(`/api/ocr/${fileId}`)
return response.data
},
// 分析报告
analyzeReport: async (fileId) => {
const response = await apiClient.post(`/api/analyze/${fileId}`)
return response.data
},
// 整合报告
integrateReports: async (fileIds) => {
const response = await apiClient.post('/api/integrate', fileIds)
return response.data
},
// 获取所有报告
getReports: async () => {
const response = await apiClient.get('/api/reports')
return response.data
},
// 获取报告详情
getReportDetail: async (fileId) => {
const response = await apiClient.get(`/api/report/${fileId}`)
return response.data
},
// 删除报告
deleteReport: async (fileId) => {
const response = await apiClient.delete(`/api/report/${fileId}`)
return response.data
},
// 生成PDF报告
generatePDF: async (fileId) => {
const response = await apiClient.post(`/api/report/${fileId}/pdf`)
return response.data
},
// 下载PDF报告
downloadPDF: (fileId) => {
window.open(`${API_BASE_URL}/api/report/${fileId}/pdf/download`, '_blank')
},
// 批量上传并生成综合报告
generateComprehensiveReport: async (files, patientName = '患者') => {
const formData = new FormData()
// 添加所有文件
files.forEach(file => {
formData.append('files', file)
})
// 添加患者姓名
formData.append('patient_name', patientName)
const response = await axios.post(
`${API_BASE_URL}/api/comprehensive-report`,
formData,
{
headers: { 'Content-Type': 'multipart/form-data' }
}
)
return response.data
},
// 下载综合报告
downloadComprehensiveReport: (pdfFilename) => {
window.open(`${API_BASE_URL}/api/comprehensive-report/download/${pdfFilename}`, '_blank')
},
}

View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

15
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8001',
changeOrigin: true
}
}
}
})