Files
HIS_Sur_Data_Deal/app/main.py
2026-05-08 21:58:39 +08:00

460 lines
13 KiB
Python
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 html
import shutil
import tempfile
import uuid
from pathlib import Path
from urllib.parse import quote
from fastapi import FastAPI, File, Form, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse, HTMLResponse
from .processor import (
ProcessingError,
ProcessingResult,
create_result_zip,
find_output_file,
run_processing,
)
WORK_ROOT = Path(tempfile.gettempdir()) / "his_sur_data_deal_jobs"
WORK_ROOT.mkdir(parents=True, exist_ok=True)
app = FastAPI(title="检测数据处理")
@app.get("/", response_class=HTMLResponse)
def index() -> str:
return _page_shell(
"""
<section class="panel">
<form action="/process" method="post" enctype="multipart/form-data">
<div>
<label for="file">待处理检测数据.zip</label>
<input id="file" name="file" type="file" accept=".zip,application/zip" required>
</div>
<div class="grid">
<div>
<label for="mode">处理模式</label>
<select id="mode" name="mode">
<option value="auto">自动识别</option>
<option value="v1">V1 整批汇总</option>
<option value="v2">V2 单患者文件</option>
</select>
</div>
<div>
<label for="data_type">患者编号类型</label>
<select id="data_type" name="data_type">
<option value="pat_no">患者号 pat_no</option>
<option value="zhuyuanhao">住院号 zhuyuanhao</option>
</select>
</div>
<div>
<label for="result_name">结果文件名</label>
<input id="result_name" name="result_name" type="text" value="Result">
</div>
</div>
<div class="checks">
<label><input type="checkbox" name="show_not_match" value="true" checked> 输出未匹配内容</label>
<label><input type="checkbox" name="show_all_infos" value="true"> 输出全部检测记录</label>
</div>
<button type="submit">开始处理</button>
</form>
<div class="note">V1 适用于含有 Patients_info.csv、Tests_List、Tests_Detail_List 的批量数据V2 适用于每个患者单独目录的数据。</div>
</section>
"""
)
@app.post("/process", response_class=HTMLResponse)
async def process(
file: UploadFile = File(...),
mode: str = Form("auto"),
data_type: str = Form("pat_no"),
result_name: str = Form("Result"),
show_not_match: str | None = Form(None),
show_all_infos: str | None = Form(None),
) -> str:
if not file.filename or not file.filename.lower().endswith(".zip"):
raise HTTPException(status_code=400, detail="请上传 zip 文件。")
job_dir = WORK_ROOT / uuid.uuid4().hex
job_dir.mkdir(parents=True, exist_ok=True)
upload_path = job_dir / "input.zip"
try:
with upload_path.open("wb") as out:
shutil.copyfileobj(file.file, out)
result = run_processing(
zip_path=upload_path,
job_dir=job_dir,
mode=mode,
data_type=data_type,
result_name=result_name,
show_not_match=show_not_match == "true",
show_all_infos=show_all_infos == "true",
)
except ProcessingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"处理失败:{exc}") from exc
return _render_result(result)
@app.get("/download/all/{job_id}")
def download_all(job_id: str) -> FileResponse:
job_dir = _get_job_dir(job_id)
try:
result_zip = create_result_zip(job_dir)
except ProcessingError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return FileResponse(
result_zip,
media_type="application/zip",
filename="检测数据处理结果.zip",
)
@app.get("/download/file/{job_id}")
def download_file(job_id: str, path: str = Query(...)) -> FileResponse:
job_dir = _get_job_dir(job_id)
try:
target = find_output_file(job_dir, path)
except ProcessingError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return FileResponse(
target,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
filename=target.name,
)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
def _get_job_dir(job_id: str) -> Path:
if not job_id.isalnum():
raise HTTPException(status_code=404, detail="结果不存在。")
job_dir = (WORK_ROOT / job_id).resolve()
if not str(job_dir).startswith(str(WORK_ROOT.resolve())) or not job_dir.exists():
raise HTTPException(status_code=404, detail="结果不存在。")
return job_dir
def _render_result(result: ProcessingResult) -> str:
total_sheets = sum(len(file.sheets) for file in result.files)
total_rows = sum(max(sheet.rows - 1, 0) for file in result.files for sheet in file.sheets)
file_items = "\n".join(_render_file(file, result.job_id) for file in result.files)
body = f"""
<section class="summary">
<div class="metric"><span>处理模式</span><strong>{html.escape(result.mode.upper())}</strong></div>
<div class="metric"><span>Excel 文件</span><strong>{len(result.files)}</strong></div>
<div class="metric"><span>工作表</span><strong>{total_sheets}</strong></div>
<div class="metric"><span>数据行</span><strong>{total_rows}</strong></div>
</section>
<section class="actions">
<a class="button" href="/download/all/{html.escape(result.job_id)}">导出全部 Excel</a>
<a class="ghost" href="/">继续处理新文件</a>
</section>
<section class="results">
{file_items}
</section>
"""
return _page_shell(body, subtitle="处理完成,可先查看结果摘要和部分预览,再选择导出。")
def _render_file(file, job_id: str) -> str:
sheet_items = "\n".join(_render_sheet(sheet) for sheet in file.sheets[:6])
more = ""
if len(file.sheets) > 6:
more = f'<div class="more">还有 {len(file.sheets) - 6} 个工作表未展开预览,可导出 Excel 查看完整内容。</div>'
file_url = f"/download/file/{html.escape(job_id)}?path={quote(file.relpath)}"
return f"""
<article class="file-block">
<div class="file-head">
<div>
<h2>{html.escape(file.filename)}</h2>
<p>{len(file.sheets)} 个工作表</p>
</div>
<a class="small-button" href="{file_url}">导出此 Excel</a>
</div>
{sheet_items}
{more}
</article>
"""
def _render_sheet(sheet) -> str:
preview = sheet.preview[:6]
table = '<div class="empty">此工作表没有可预览的数据。</div>'
if preview:
max_cols = min(max((len(row) for row in preview), default=0), 12)
rows = []
for index, row in enumerate(preview):
cells = []
for value in (row + [""] * max_cols)[:max_cols]:
tag = "th" if index == 0 else "td"
cells.append(f"<{tag}>{html.escape(value)}</{tag}>")
rows.append("<tr>" + "".join(cells) + "</tr>")
table = f"<div class=\"table-wrap\"><table>{''.join(rows)}</table></div>"
return f"""
<details class="sheet" open>
<summary>
<span>{html.escape(sheet.name)}</span>
<small>{sheet.rows} 行 · {sheet.columns} 列</small>
</summary>
{table}
</details>
"""
def _page_shell(body: str, subtitle: str = "上传“待处理检测数据.zip”处理完成后在网页中查看结果。") -> str:
return f"""
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>检测数据处理</title>
<style>
:root {{
color-scheme: light;
--bg: #f6f7f9;
--panel: #ffffff;
--text: #1f2937;
--muted: #64748b;
--line: #d7dde5;
--primary: #146c5c;
--primary-dark: #0f574b;
--soft: #eef7f5;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
}}
.shell {{
width: min(1120px, calc(100vw - 32px));
margin: 0 auto;
padding: 36px 0;
}}
header {{
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
margin-bottom: 24px;
}}
h1 {{
margin: 0;
font-size: 28px;
line-height: 1.2;
font-weight: 750;
letter-spacing: 0;
}}
.sub {{
margin-top: 8px;
color: var(--muted);
font-size: 14px;
}}
.panel, .file-block {{
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 24px;
box-shadow: 0 10px 26px rgba(17, 24, 39, 0.06);
}}
form {{
display: grid;
gap: 18px;
}}
label {{
display: block;
margin-bottom: 8px;
font-size: 14px;
font-weight: 650;
}}
input[type="file"], select, input[type="text"] {{
width: 100%;
border: 1px solid var(--line);
border-radius: 6px;
padding: 11px 12px;
font: inherit;
background: #fff;
color: var(--text);
}}
.grid {{
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}}
.checks {{
display: flex;
flex-wrap: wrap;
gap: 16px;
color: var(--text);
font-size: 14px;
}}
.checks label {{
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
font-weight: 500;
}}
button, .button, .small-button, .ghost {{
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 42px;
border-radius: 6px;
padding: 0 16px;
font: inherit;
font-weight: 700;
text-decoration: none;
cursor: pointer;
}}
button, .button, .small-button {{
border: 0;
background: var(--primary);
color: #fff;
}}
button:hover, .button:hover, .small-button:hover {{ background: var(--primary-dark); }}
.ghost {{
border: 1px solid var(--line);
color: var(--text);
background: #fff;
}}
.note {{
color: var(--muted);
font-size: 13px;
line-height: 1.6;
margin-top: 18px;
border-top: 1px solid var(--line);
padding-top: 16px;
}}
.summary {{
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
margin-bottom: 16px;
}}
.metric {{
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
padding: 16px;
}}
.metric span {{
display: block;
color: var(--muted);
font-size: 13px;
margin-bottom: 8px;
}}
.metric strong {{
font-size: 24px;
line-height: 1;
}}
.actions {{
display: flex;
gap: 12px;
margin: 0 0 18px;
}}
.results {{
display: grid;
gap: 16px;
}}
.file-head {{
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}}
h2 {{
margin: 0;
font-size: 18px;
letter-spacing: 0;
}}
.file-head p {{
margin: 6px 0 0;
color: var(--muted);
font-size: 13px;
}}
.sheet {{
border: 1px solid var(--line);
border-radius: 8px;
margin-top: 10px;
overflow: hidden;
}}
summary {{
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
cursor: pointer;
background: var(--soft);
font-weight: 700;
}}
summary small {{
color: var(--muted);
font-weight: 500;
}}
.table-wrap {{
width: 100%;
overflow: auto;
background: #fff;
}}
table {{
width: 100%;
border-collapse: collapse;
font-size: 13px;
min-width: 720px;
}}
th, td {{
border-top: 1px solid var(--line);
border-right: 1px solid var(--line);
padding: 8px 10px;
text-align: left;
vertical-align: top;
white-space: nowrap;
}}
th {{
background: #fbfcfd;
font-weight: 700;
}}
.empty, .more {{
color: var(--muted);
font-size: 13px;
padding: 12px 14px;
}}
@media (max-width: 760px) {{
header {{ display: block; }}
.grid, .summary {{ grid-template-columns: 1fr; }}
.actions, .file-head {{ align-items: stretch; flex-direction: column; }}
button, .button, .small-button, .ghost {{ width: 100%; }}
}}
</style>
</head>
<body>
<main class="shell">
<header>
<div>
<h1>检测数据处理</h1>
<div class="sub">{html.escape(subtitle)}</div>
</div>
</header>
{body}
</main>
</body>
</html>
"""