move export options to results

This commit is contained in:
2026-05-08 23:06:00 +08:00
parent 9fe218eb8e
commit 5d0df4646b
3 changed files with 156 additions and 24 deletions

View File

@@ -33,7 +33,7 @@ V2zip 解压后包含 `Patients_info.csv`,并按患者目录分别保存检
患者编号类型可选择自动识别。自动识别会读取 `Patients_info.csv` 中的 `pat_no`,并与 `Tests_List` 文件名或患者目录名比对:若更匹配 10 位补零编号,则使用 `pat_no`;若更匹配原始编号,则使用 `zhuyuanhao` 患者编号类型可选择自动识别。自动识别会读取 `Patients_info.csv` 中的 `pat_no`,并与 `Tests_List` 文件名或患者目录名比对:若更匹配 10 位补零编号,则使用 `pat_no`;若更匹配原始编号,则使用 `zhuyuanhao`
导出的压缩包默认只包含 Excel 结果,不包含处理日志。系统默认输出全部检测记录,并可选择是否保留: 导出的压缩包默认只包含 Excel 结果,不包含处理日志。系统默认输出完整结果;处理完成后可在结果页选择导出时是否保留:
- 基本工作表 - 基本工作表
- 未匹配检测内容项 - 未匹配检测内容项

View File

@@ -62,11 +62,6 @@ def index() -> str:
<input id="preview_rows" name="preview_rows" type="number" min="5" max="200" value="20"> <input id="preview_rows" name="preview_rows" type="number" min="5" max="200" value="20">
</div> </div>
</div> </div>
<div class="checks">
<label><input type="checkbox" name="include_basic_sheets" value="true" checked> \u57fa\u672c\u5de5\u4f5c\u8868</label>
<label><input type="checkbox" name="include_unmatched_items" value="true" checked> \u672a\u5339\u914d\u68c0\u6d4b\u5185\u5bb9\u9879</label>
<label><input type="checkbox" name="include_summary_sheet" value="true" checked> \u672a\u68c0\u6d4b\u5230\u5185\u5bb9\u6c47\u603b\u8868</label>
</div>
<button type="submit">\u5f00\u59cb\u5904\u7406</button> <button type="submit">\u5f00\u59cb\u5904\u7406</button>
</form> </form>
<div class="note">\u9ed8\u8ba4\u8f93\u51fa\u5168\u90e8\u68c0\u6d4b\u8bb0\u5f55\uff0c\u5e76\u4fdd\u7559\u201c{UNMATCHED}\u201d\u5217\uff1b\u8be5\u5217\u4e3a\u989d\u5916\u8f85\u52a9\u4fe1\u606f\uff0c\u4fbf\u4e8e\u6838\u5bf9\u672a\u5f52\u7c7b\u9879\u76ee\u3002</div> <div class="note">\u9ed8\u8ba4\u8f93\u51fa\u5168\u90e8\u68c0\u6d4b\u8bb0\u5f55\uff0c\u5e76\u4fdd\u7559\u201c{UNMATCHED}\u201d\u5217\uff1b\u8be5\u5217\u4e3a\u989d\u5916\u8f85\u52a9\u4fe1\u606f\uff0c\u4fbf\u4e8e\u6838\u5bf9\u672a\u5f52\u7c7b\u9879\u76ee\u3002</div>
@@ -82,9 +77,6 @@ async def process(
data_type: str = Form("auto"), data_type: str = Form("auto"),
result_name: str = Form("Result"), result_name: str = Form("Result"),
preview_rows: int = Form(20), preview_rows: int = Form(20),
include_basic_sheets: str | None = Form(None),
include_unmatched_items: str | None = Form(None),
include_summary_sheet: str | None = Form(None),
) -> str: ) -> str:
if not file.filename or not file.filename.lower().endswith(".zip"): if not file.filename or not file.filename.lower().endswith(".zip"):
raise HTTPException(status_code=400, detail="\u8bf7\u4e0a\u4f20 zip \u6587\u4ef6\u3002") raise HTTPException(status_code=400, detail="\u8bf7\u4e0a\u4f20 zip \u6587\u4ef6\u3002")
@@ -106,9 +98,9 @@ async def process(
show_not_match=True, show_not_match=True,
show_all_infos=True, show_all_infos=True,
preview_rows=rows, preview_rows=rows,
include_basic_sheets=include_basic_sheets == "true", include_basic_sheets=True,
include_unmatched_items=include_unmatched_items == "true", include_unmatched_items=True,
include_summary_sheet=include_summary_sheet == "true", include_summary_sheet=True,
) )
except ProcessingError as exc: except ProcessingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
@@ -130,20 +122,42 @@ def result_page(job_id: str, preview_rows: int = Query(20, ge=5, le=200)) -> str
@app.get("/download/all/{job_id}") @app.get("/download/all/{job_id}")
def download_all(job_id: str) -> FileResponse: def download_all(
job_id: str,
include_basic_sheets: bool = Query(True),
include_unmatched_items: bool = Query(True),
include_summary_sheet: bool = Query(True),
) -> FileResponse:
job_dir = _get_job_dir(job_id) job_dir = _get_job_dir(job_id)
try: try:
result_zip = create_result_zip(job_dir) result_zip = create_result_zip(
job_dir,
include_basic_sheets=include_basic_sheets,
include_unmatched_items=include_unmatched_items,
include_summary_sheet=include_summary_sheet,
)
except ProcessingError as exc: except ProcessingError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
return FileResponse(result_zip, media_type="application/zip", filename="result.zip") return FileResponse(result_zip, media_type="application/zip", filename="result.zip")
@app.get("/download/file/{job_id}") @app.get("/download/file/{job_id}")
def download_file(job_id: str, path: str = Query(...)) -> FileResponse: def download_file(
job_id: str,
path: str = Query(...),
include_basic_sheets: bool = Query(True),
include_unmatched_items: bool = Query(True),
include_summary_sheet: bool = Query(True),
) -> FileResponse:
job_dir = _get_job_dir(job_id) job_dir = _get_job_dir(job_id)
try: try:
target = find_output_file(job_dir, path) target = find_output_file(
job_dir,
path,
include_basic_sheets=include_basic_sheets,
include_unmatched_items=include_unmatched_items,
include_summary_sheet=include_summary_sheet,
)
except ProcessingError as exc: except ProcessingError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
return FileResponse( return FileResponse(
@@ -184,9 +198,15 @@ def _render_result(result: ProcessingResult, preview_rows: int = 20) -> str:
<div class="metric"><span>\u6570\u636e\u884c</span><strong>{total_rows}</strong></div> <div class="metric"><span>\u6570\u636e\u884c</span><strong>{total_rows}</strong></div>
</section> </section>
<section class="actions"> <section class="actions">
<a class="button" href="/download/all/{html.escape(result.job_id)}">\u5bfc\u51fa\u5168\u90e8 Excel</a> <a class="button export-link" data-base="/download/all/{html.escape(result.job_id)}" href="/download/all/{html.escape(result.job_id)}">\u5bfc\u51fa\u5168\u90e8 Excel</a>
<a class="ghost" href="/">\u7ee7\u7eed\u5904\u7406\u65b0\u6587\u4ef6</a> <a class="ghost" href="/">\u7ee7\u7eed\u5904\u7406\u65b0\u6587\u4ef6</a>
</section> </section>
<section class="export-options">
<strong>\u5bfc\u51fa\u5185\u5bb9\u9009\u62e9</strong>
<label><input class="export-option" type="checkbox" data-param="include_basic_sheets" checked> \u57fa\u672c\u5de5\u4f5c\u8868</label>
<label><input class="export-option" type="checkbox" data-param="include_unmatched_items" checked> \u672a\u5339\u914d\u68c0\u6d4b\u5185\u5bb9\u9879</label>
<label><input class="export-option" type="checkbox" data-param="include_summary_sheet" checked> \u672a\u68c0\u6d4b\u5230\u5185\u5bb9\u6c47\u603b\u8868</label>
</section>
<form class="row-control" method="get" action="/result/{html.escape(result.job_id)}"> <form class="row-control" method="get" action="/result/{html.escape(result.job_id)}">
<label for="preview_rows">\u6bcf\u4e2a\u5de5\u4f5c\u8868\u9884\u89c8\u884c\u6570</label> <label for="preview_rows">\u6bcf\u4e2a\u5de5\u4f5c\u8868\u9884\u89c8\u884c\u6570</label>
<input id="preview_rows" name="preview_rows" type="number" min="5" max="200" value="{preview_rows}"> <input id="preview_rows" name="preview_rows" type="number" min="5" max="200" value="{preview_rows}">
@@ -210,7 +230,7 @@ def _render_file(file, job_id: str) -> str:
<h2>{html.escape(file.filename)}</h2> <h2>{html.escape(file.filename)}</h2>
<p>{len(file.sheets)} \u4e2a\u5de5\u4f5c\u8868</p> <p>{len(file.sheets)} \u4e2a\u5de5\u4f5c\u8868</p>
</div> </div>
<a class="small-button" href="{file_url}">\u5bfc\u51fa\u6b64 Excel</a> <a class="small-button export-link" data-base="{file_url}" href="{file_url}">\u5bfc\u51fa\u6b64 Excel</a>
</div> </div>
{sheet_items} {sheet_items}
</article> </article>
@@ -227,12 +247,13 @@ def _render_sheet(sheet) -> str:
unmatched_start = preview[0].index(UNMATCHED) unmatched_start = preview[0].index(UNMATCHED)
rows = [] rows = []
for index, row in enumerate(preview): for index, row in enumerate(preview):
row_class = ' class="group-header"' if _is_summary_group_header(row) else ""
cells = [] cells = []
for col_index, value in enumerate((row + [""] * max_cols)[:max_cols]): for col_index, value in enumerate((row + [""] * max_cols)[:max_cols]):
tag = "th" if index == 0 else "td" tag = "th" if index == 0 else "td"
css_class = ' class="extra-col"' if unmatched_start is not None and col_index >= unmatched_start else "" css_class = ' class="extra-col"' if unmatched_start is not None and col_index >= unmatched_start else ""
cells.append(f"<{tag}{css_class}>{html.escape(value)}</{tag}>") cells.append(f"<{tag}{css_class}>{html.escape(value)}</{tag}>")
rows.append("<tr>" + "".join(cells) + "</tr>") rows.append(f"<tr{row_class}>" + "".join(cells) + "</tr>")
table = f'<div class="table-wrap"><table>{"".join(rows)}</table></div>' table = f'<div class="table-wrap"><table>{"".join(rows)}</table></div>'
return f""" return f"""
<details class="sheet" open> <details class="sheet" open>
@@ -330,6 +351,25 @@ def _page_shell(body: str, subtitle: str = "\u4e0a\u4f20\u201c\u5f85\u5904\u7406
color: var(--text); color: var(--text);
font-size: 14px; font-size: 14px;
}} }}
.export-options {{
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
padding: 10px 12px;
margin-bottom: 14px;
font-size: 14px;
}}
.export-options label {{
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
font-weight: 500;
}}
.checks label {{ .checks label {{
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -463,6 +503,7 @@ def _page_shell(body: str, subtitle: str = "\u4e0a\u4f20\u201c\u5f85\u5904\u7406
white-space: nowrap; white-space: nowrap;
}} }}
th {{ background: #fbfcfd; font-weight: 700; }} th {{ background: #fbfcfd; font-weight: 700; }}
tr.group-header td {{ font-weight: 700; background: #fbfcfd; }}
th.extra-col, td.extra-col {{ background: var(--extra); }} th.extra-col, td.extra-col {{ background: var(--extra); }}
.empty {{ .empty {{
color: var(--muted); color: var(--muted);
@@ -471,7 +512,7 @@ def _page_shell(body: str, subtitle: str = "\u4e0a\u4f20\u201c\u5f85\u5904\u7406
}} }}
@media (max-width: 760px) {{ @media (max-width: 760px) {{
.grid, .summary {{ grid-template-columns: 1fr; }} .grid, .summary {{ grid-template-columns: 1fr; }}
.actions, .file-head, .row-control {{ align-items: stretch; flex-direction: column; }} .actions, .file-head, .row-control, .export-options {{ align-items: stretch; flex-direction: column; }}
.row-control input {{ width: 100%; }} .row-control input {{ width: 100%; }}
button, .button, .small-button, .ghost {{ width: 100%; }} button, .button, .small-button, .ghost {{ width: 100%; }}
}} }}
@@ -485,6 +526,24 @@ def _page_shell(body: str, subtitle: str = "\u4e0a\u4f20\u201c\u5f85\u5904\u7406
</header> </header>
{body} {body}
</main> </main>
<script>
function updateExportLinks() {{
const options = Array.from(document.querySelectorAll('.export-option'));
const params = new URLSearchParams();
options.forEach((option) => params.set(option.dataset.param, option.checked ? 'true' : 'false'));
document.querySelectorAll('.export-link').forEach((link) => {{
const base = link.dataset.base;
const sep = base.includes('?') ? '&' : '?';
link.href = base + sep + params.toString();
}});
}}
document.querySelectorAll('.export-option').forEach((option) => option.addEventListener('change', updateExportLinks));
updateExportLinks();
</script>
</body> </body>
</html> </html>
""" """
def _is_summary_group_header(row: list[str]) -> bool:
return len(row) >= 4 and row[0] == "\u59d3\u540d" and row[1] == "\u4f4f\u9662\u53f7" and row[2] == "\u91c7\u6837\u65f6\u95f4" and row[3].startswith("\u68c0\u6d4b\u539f\u56e0")

View File

@@ -3,6 +3,7 @@ import os
import shutil import shutil
import subprocess import subprocess
import sys import sys
import uuid
import zipfile import zipfile
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -154,12 +155,31 @@ def run_processing(
) )
def create_result_zip(job_dir: Path) -> Path: def create_result_zip(
job_dir: Path,
include_basic_sheets: bool = True,
include_unmatched_items: bool = True,
include_summary_sheet: bool = True,
) -> Path:
output_dir = job_dir / "output" output_dir = job_dir / "output"
result_zip = job_dir / "result.zip" result_zip = job_dir / "result.zip"
if not output_dir.exists(): if not output_dir.exists():
raise ProcessingError("结果目录不存在。") raise ProcessingError("结果目录不存在。")
_create_result_zip(output_dir, result_zip) export_dir = _new_export_dir(job_dir)
for path in sorted(output_dir.rglob("*.xlsx")):
if path.is_file():
target = export_dir / path.relative_to(output_dir)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, target)
_apply_export_options(
target,
include_basic_sheets=include_basic_sheets,
include_unmatched_items=include_unmatched_items,
include_summary_sheet=include_summary_sheet,
)
result_zip = export_dir / "result.zip"
_create_result_zip(export_dir, result_zip)
return result_zip return result_zip
@@ -181,14 +201,67 @@ def summarize_job(job_dir: Path, preview_rows: int = 20) -> ProcessingResult:
) )
def find_output_file(job_dir: Path, relpath: str) -> Path: def find_output_file(
job_dir: Path,
relpath: str,
include_basic_sheets: bool = True,
include_unmatched_items: bool = True,
include_summary_sheet: bool = True,
) -> Path:
output_dir = (job_dir / "output").resolve() output_dir = (job_dir / "output").resolve()
target = (output_dir / relpath).resolve() target = (output_dir / relpath).resolve()
if not str(target).startswith(str(output_dir)) or not target.is_file(): if not str(target).startswith(str(output_dir)) or not target.is_file():
raise ProcessingError("结果文件不存在。") raise ProcessingError("结果文件不存在。")
if target.suffix.lower() != ".xlsx": if target.suffix.lower() != ".xlsx":
raise ProcessingError("只能导出 Excel 结果文件。") raise ProcessingError("只能导出 Excel 结果文件。")
return target export_dir = _new_export_dir(job_dir)
export_target = export_dir / target.name
shutil.copy2(target, export_target)
_apply_export_options(
export_target,
include_basic_sheets=include_basic_sheets,
include_unmatched_items=include_unmatched_items,
include_summary_sheet=include_summary_sheet,
)
return export_target
def _new_export_dir(job_dir: Path) -> Path:
export_root = job_dir / "exports"
export_root.mkdir(exist_ok=True)
export_dir = export_root / uuid.uuid4().hex
export_dir.mkdir()
return export_dir
def _apply_export_options(
path: Path,
include_basic_sheets: bool,
include_unmatched_items: bool,
include_summary_sheet: bool,
) -> None:
if not include_basic_sheets and not include_summary_sheet:
raise ProcessingError("\u81f3\u5c11\u9700\u8981\u5bfc\u51fa\u57fa\u672c\u5de5\u4f5c\u8868\u6216\u672a\u68c0\u6d4b\u5230\u5185\u5bb9\u6c47\u603b\u8868\u3002")
workbook = load_workbook(path)
try:
if not include_unmatched_items:
_remove_unmatched_columns(workbook)
if not include_summary_sheet and SUMMARY_SHEET_NAME in workbook.sheetnames:
workbook.remove(workbook[SUMMARY_SHEET_NAME])
if not include_basic_sheets:
for sheet in list(workbook.worksheets):
if sheet.title != SUMMARY_SHEET_NAME:
workbook.remove(sheet)
if not workbook.worksheets:
workbook.create_sheet(SUMMARY_SHEET_NAME)
workbook.save(path)
finally:
workbook.close()
def _read_mode(job_dir: Path) -> str: def _read_mode(job_dir: Path) -> str: