Files
Mdeical_Sur_Report/src/pages/ReportManage.tsx

477 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Sidebar from '../components/Sidebar';
import { Search, Eye, Edit, Trash2, FileText, History, X, Download, Printer } from 'lucide-react';
import { User, Report, DEFAULT_FORM_FIELDS } from '../types';
import { storage } from '../utils/storage';
import { printDocument } from '../utils/print';
const formatDateTime = (iso: string) => {
if (!iso) return '-';
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
export default function ReportManage() {
const navigate = useNavigate();
const [reports, setReports] = useState<Report[]>([]);
const [filteredReports, setFilteredReports] = useState<Report[]>([]);
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [dateFilter, setDateFilter] = useState('');
const [historyModalOpen, setHistoryModalOpen] = useState(false);
const [historyReport, setHistoryReport] = useState<Report | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportTarget, setExportTarget] = useState<Report | null>(null);
useEffect(() => {
const user = storage.get<User | null>('currentUser', null);
if (!user) {
navigate('/');
return;
}
setCurrentUser(user);
const savedReports = storage.get<Report[]>('reports', []);
setReports(savedReports);
}, [navigate]);
useEffect(() => {
if (!currentUser) return;
let filtered = [...reports];
if (currentUser.role === 'user') {
filtered = filtered.filter(r => r.author === currentUser.username);
}
if (searchTerm) {
const term = searchTerm.toLowerCase();
filtered = filtered.filter(r =>
r.title.toLowerCase().includes(term) ||
r.patientName.toLowerCase().includes(term) ||
r.hospitalId.toLowerCase().includes(term)
);
}
if (statusFilter) {
filtered = filtered.filter(r => r.status === statusFilter);
}
if (dateFilter) {
const now = new Date();
filtered = filtered.filter(r => {
const reportDate = new Date(r.createdAt);
if (dateFilter === 'today') {
return reportDate.toDateString() === now.toDateString();
} else if (dateFilter === 'week') {
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
return reportDate >= weekAgo;
} else if (dateFilter === 'month') {
return reportDate.getMonth() === now.getMonth() && reportDate.getFullYear() === now.getFullYear();
}
return true;
});
}
setFilteredReports(filtered);
}, [reports, currentUser, searchTerm, statusFilter, dateFilter]);
const deleteReport = (id: string) => {
if (window.confirm('确定要删除此报告吗?')) {
const updatedReports = reports.filter(r => r.id !== id);
setReports(updatedReports);
storage.set('reports', updatedReports);
setSelectedIds(prev => prev.filter(pid => pid !== id));
}
};
const viewReport = (id: string) => {
navigate(`/report-view/${id}`);
};
const editReport = (id: string) => {
navigate(`/report-editor?id=${id}`);
};
const openHistory = (report: Report) => {
setHistoryReport(report);
setHistoryModalOpen(true);
};
const restoreHistory = (content: string) => {
if (!historyReport) return;
if (!window.confirm('确定要恢复此历史版本到编辑器吗?当前未保存的内容将丢失。')) return;
navigate(`/report-editor?id=${historyReport.id}&restore=1`);
storage.setSession(`restore_${historyReport.id}`, content);
setHistoryModalOpen(false);
};
const toggleSelect = (id: string) => {
setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
};
const toggleSelectAll = () => {
if (selectedIds.length === filteredReports.length && filteredReports.length > 0) {
setSelectedIds([]);
} else {
setSelectedIds(filteredReports.map(r => r.id));
}
};
const handleBulkDelete = () => {
if (!window.confirm(`确定要删除选中的 ${selectedIds.length} 份报告吗?`)) return;
const updated = reports.filter(r => !selectedIds.includes(r.id));
setReports(updated);
storage.set('reports', updated);
setSelectedIds([]);
};
const buildExportData = (report: Report) => {
const fields: Record<string, any> = {};
DEFAULT_FORM_FIELDS.forEach(f => {
fields[f.key] = (report as any)[f.key];
});
return {
meta: {
id: report.id,
title: report.title,
createdAt: report.createdAt,
updatedAt: report.updatedAt,
author: report.author,
authorName: report.authorName,
status: report.status
},
fields
};
};
const downloadJSON = (data: any, filename: string) => {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
const exportSinglePDF = (report: Report) => {
printDocument(report.content);
};
const exportSingleJSON = (report: Report) => {
const data = buildExportData(report);
downloadJSON(data, `报告_${report.patientName || '未命名'}_${report.id}.json`);
};
const exportBulkPDF = () => {
const selectedReports = reports.filter(r => selectedIds.includes(r.id));
const mergedHTML = selectedReports.map(r => r.content).join('<div style="page-break-after: always;"></div>');
printDocument(mergedHTML);
};
const exportBulkJSON = () => {
const selectedReports = reports.filter(r => selectedIds.includes(r.id));
const data = selectedReports.map(r => buildExportData(r));
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
downloadJSON(data, `reports_export_${timestamp}.json`);
};
const openExportModal = (report: Report) => {
setExportTarget(report);
setExportModalOpen(true);
};
if (!currentUser) return null;
return (
<div className="flex min-h-screen bg-bg">
<Sidebar />
<main className="flex-1 p-10 overflow-y-auto">
<header className="flex justify-between items-center mb-8">
<div>
<h1 className="text-2xl font-bold tracking-tight text-text-main"></h1>
<p className="text-text-muted text-sm mt-1">
{currentUser.role === 'user' ? '查看、编辑、打印自己创建的报告' : '查看/检索全院所有已撰写的报告'}
</p>
</div>
</header>
<div className="flex flex-wrap gap-4 mb-4">
<div className="relative flex-1 min-w-[240px] max-w-[400px]">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 text-text-muted" size={18} />
<input
type="text"
placeholder="搜索报告标题或患者姓名..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="input-minimal pl-11"
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="input-minimal max-w-[160px] bg-white"
>
<option value=""></option>
<option value="draft">稿</option>
<option value="completed"></option>
</select>
<select
value={dateFilter}
onChange={(e) => setDateFilter(e.target.value)}
className="input-minimal max-w-[160px] bg-white"
>
<option value=""></option>
<option value="today"></option>
<option value="week"></option>
<option value="month"></option>
</select>
</div>
{selectedIds.length > 0 && (
<div className="flex items-center gap-3 mb-4 p-3 bg-slate-50 border border-border rounded-lg">
<span className="text-sm font-semibold text-text-main"> {selectedIds.length} </span>
<div className="flex-1"></div>
<button
onClick={exportBulkPDF}
className="px-3 py-1.5 text-sm font-medium rounded-lg bg-white border border-border hover:bg-slate-100 transition-colors flex items-center gap-1"
>
<Printer size={14} /> PDF
</button>
<button
onClick={exportBulkJSON}
className="px-3 py-1.5 text-sm font-medium rounded-lg bg-white border border-border hover:bg-slate-100 transition-colors flex items-center gap-1"
>
<Download size={14} /> JSON
</button>
<button
onClick={handleBulkDelete}
className="px-3 py-1.5 text-sm font-medium rounded-lg bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
>
</button>
<button
onClick={() => setSelectedIds([])}
className="px-3 py-1.5 text-sm font-medium rounded-lg text-text-muted hover:bg-slate-100 transition-colors"
>
</button>
</div>
)}
<div className="card-minimal p-0 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="bg-slate-50">
<th className="px-4 py-4 text-left border-b border-border w-10">
<input
type="checkbox"
className="w-4 h-4 rounded border-border text-accent focus:ring-accent"
checked={filteredReports.length > 0 && selectedIds.length === filteredReports.length}
onChange={toggleSelectAll}
/>
</th>
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border"></th>
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border"></th>
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border"></th>
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border"></th>
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border w-40"></th>
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border w-24"></th>
<th className="px-6 py-4 text-left text-[11px] font-bold text-text-muted uppercase tracking-wider border-b border-border"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filteredReports.length > 0 ? (
filteredReports.map((report) => (
<tr key={report.id} className="hover:bg-slate-50 transition-colors group">
<td className="px-4 py-4 border-b border-border">
<input
type="checkbox"
className="w-4 h-4 rounded border-border text-accent focus:ring-accent"
checked={selectedIds.includes(report.id)}
onChange={() => toggleSelect(report.id)}
/>
</td>
<td className="px-6 py-4">
<div className="text-sm font-semibold text-text-main">{report.title}</div>
<div className="text-xs text-text-muted font-mono mt-1">{report.id}</div>
</td>
<td className="px-6 py-4 text-sm text-text-main">{report.patientName}</td>
<td className="px-6 py-4 text-sm text-text-main">{report.hospitalId}</td>
<td className="px-6 py-4 text-sm text-text-main">{report.authorName}</td>
<td className="px-6 py-4 text-sm text-text-muted leading-relaxed">
<div>: {formatDateTime(report.createdAt)}</div>
<div>: {formatDateTime(report.updatedAt || report.createdAt)}</div>
</td>
<td className="px-6 py-4">
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-bold ${
report.status === 'draft'
? 'bg-amber-100 text-amber-700'
: 'bg-green-100 text-green-700'
}`}>
{report.status === 'draft' ? '草稿' : '已完成'}
</span>
</td>
<td className="px-6 py-4">
<div className="flex gap-2">
<button
onClick={() => viewReport(report.id)}
className="p-2 rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 transition-colors"
title="查看"
>
<Eye size={16} />
</button>
{(currentUser.role !== 'user' || report.author === currentUser.username) && (
<>
<button
onClick={() => editReport(report.id)}
className="p-2 rounded-lg bg-slate-100 text-slate-600 hover:bg-slate-200 transition-colors"
title="编辑"
>
<Edit size={16} />
</button>
<button
onClick={() => deleteReport(report.id)}
className="p-2 rounded-lg bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
title="删除"
>
<Trash2 size={16} />
</button>
</>
)}
<button
onClick={() => openHistory(report)}
className="p-2 rounded-lg bg-amber-50 text-amber-600 hover:bg-amber-100 transition-colors"
title="历史版本"
>
<History size={16} />
</button>
<button
onClick={() => openExportModal(report)}
className="p-2 rounded-lg bg-emerald-50 text-emerald-600 hover:bg-emerald-100 transition-colors"
title="导出"
>
<Download size={16} />
</button>
</div>
</td>
</tr>
))
) : (
<tr>
<td colSpan={8} className="px-6 py-24 text-center">
<div className="flex flex-col items-center text-text-muted">
<FileText size={48} className="mb-4 opacity-20" />
<h3 className="text-base font-semibold text-text-main mb-1"></h3>
<p className="text-sm">"新建报告"</p>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</main>
{historyModalOpen && historyReport && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl p-8 w-full max-w-[600px] max-h-[80vh] overflow-y-auto shadow-2xl border border-border">
<div className="flex justify-between items-center mb-6">
<div>
<h3 className="text-xl font-bold text-text-main"></h3>
<p className="text-sm text-text-muted">: {historyReport.title}</p>
</div>
<button
onClick={() => setHistoryModalOpen(false)}
className="p-2 rounded-lg hover:bg-slate-100 text-text-muted transition-colors"
>
<X size={20} />
</button>
</div>
<div className="space-y-3">
{[...(historyReport.history || [])].reverse().map((item, idx) => (
<div key={idx} className="border border-border rounded-lg p-4 bg-slate-50">
<div className="flex justify-between items-center mb-2">
<span className={`text-xs font-bold uppercase tracking-wider px-2 py-0.5 rounded ${
item.action === 'complete_report'
? 'bg-green-100 text-green-700'
: 'bg-amber-100 text-amber-700'
}`}>
{item.action === 'complete_report' ? '完成报告' : '保存草稿'}
</span>
<span className="text-xs text-text-muted">{formatDateTime(item.updatedAt)}</span>
</div>
<p className="text-sm text-text-main mb-3"> {item.updatedBy} {item.action === 'complete_report' ? '完成' : '保存'}</p>
<button
onClick={() => restoreHistory(item.content)}
className="text-xs font-bold text-accent hover:underline"
>
</button>
</div>
))}
<div className="border border-border rounded-lg p-4 bg-white">
<div className="flex justify-between items-center mb-2">
<span className="text-xs font-bold text-accent uppercase tracking-wider"></span>
<span className="text-xs text-text-muted">{formatDateTime(historyReport.updatedAt || historyReport.createdAt)}</span>
</div>
<p className="text-sm text-text-main"></p>
</div>
</div>
</div>
</div>
)}
{exportModalOpen && exportTarget && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold text-text-main"></h3>
<button
onClick={() => setExportModalOpen(false)}
className="p-2 rounded-lg hover:bg-slate-100 text-text-muted transition-colors"
>
<X size={18} />
</button>
</div>
<p className="text-sm text-text-muted mb-4"></p>
<div className="flex flex-col gap-3">
<button
onClick={() => { exportSinglePDF(exportTarget); setExportModalOpen(false); }}
className="w-full px-4 py-3 rounded-lg bg-slate-50 hover:bg-slate-100 transition-colors flex items-center gap-3"
>
<Printer size={18} className="text-text-muted" />
<div className="text-left">
<div className="text-sm font-semibold text-text-main"> PDF</div>
<div className="text-xs text-text-muted"> PDF</div>
</div>
</button>
<button
onClick={() => { exportSingleJSON(exportTarget); setExportModalOpen(false); }}
className="w-full px-4 py-3 rounded-lg bg-slate-50 hover:bg-slate-100 transition-colors flex items-center gap-3"
>
<Download size={18} className="text-text-muted" />
<div className="text-left">
<div className="text-sm font-semibold text-text-main"> JSON</div>
<div className="text-xs text-text-muted"></div>
</div>
</button>
</div>
</div>
</div>
)}
</div>
);
}