add result preview and exports

This commit is contained in:
2026-05-08 21:58:39 +08:00
parent 491009c0d7
commit fe6fcf9826
3 changed files with 495 additions and 204 deletions

View File

@@ -3,14 +3,20 @@ import shutil
import tempfile
import uuid
from pathlib import Path
from urllib.parse import quote
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi import FastAPI, File, Form, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse, HTMLResponse
from .processor import ProcessingError, run_processing
from .processor import (
ProcessingError,
ProcessingResult,
create_result_zip,
find_output_file,
run_processing,
)
APP_ROOT = Path(__file__).resolve().parent
WORK_ROOT = Path(tempfile.gettempdir()) / "his_sur_data_deal_jobs"
WORK_ROOT.mkdir(parents=True, exist_ok=True)
@@ -19,185 +25,48 @@ app = FastAPI(title="检测数据处理")
@app.get("/", response_class=HTMLResponse)
def index() -> str:
return """
<!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;
--danger: #b42318;
}
* { 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(960px, 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 {
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 {
width: fit-content;
min-width: 148px;
border: 0;
border-radius: 6px;
padding: 12px 18px;
background: var(--primary);
color: #fff;
font: inherit;
font-weight: 700;
cursor: pointer;
}
button:hover { background: var(--primary-dark); }
.note {
color: var(--muted);
font-size: 13px;
line-height: 1.6;
margin-top: 18px;
border-top: 1px solid var(--line);
padding-top: 16px;
}
.error {
margin-bottom: 16px;
color: var(--danger);
font-weight: 650;
}
@media (max-width: 720px) {
header { display: block; }
.grid { grid-template-columns: 1fr; }
button { width: 100%; }
}
</style>
</head>
<body>
<main class="shell">
<header>
<div>
<h1>检测数据处理</h1>
<div class="sub">上传“待处理检测数据.zip”处理完成后自动下载结果压缩包。</div>
</div>
</header>
<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>
</main>
</body>
</html>
"""
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")
@app.post("/process", response_class=HTMLResponse)
async def process(
file: UploadFile = File(...),
mode: str = Form("auto"),
@@ -205,7 +74,7 @@ async def process(
result_name: str = Form("Result"),
show_not_match: str | None = Form(None),
show_all_infos: str | None = Form(None),
) -> FileResponse:
) -> str:
if not file.filename or not file.filename.lower().endswith(".zip"):
raise HTTPException(status_code=400, detail="请上传 zip 文件。")
@@ -216,7 +85,7 @@ async def process(
with upload_path.open("wb") as out:
shutil.copyfileobj(file.file, out)
result_zip = run_processing(
result = run_processing(
zip_path=upload_path,
job_dir=job_dir,
mode=mode,
@@ -226,11 +95,20 @@ async def process(
show_all_infos=show_all_infos == "true",
)
except ProcessingError as exc:
safe_detail = html.escape(str(exc))
raise HTTPException(status_code=400, detail=safe_detail) from 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",
@@ -238,7 +116,344 @@ async def process(
)
@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>
"""