move export options to results
This commit is contained in:
@@ -33,7 +33,7 @@ V2:zip 解压后包含 `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 结果,不包含处理日志。系统默认输出完整结果;处理完成后可在结果页选择导出时是否保留:
|
||||||
|
|
||||||
- 基本工作表
|
- 基本工作表
|
||||||
- 未匹配检测内容项
|
- 未匹配检测内容项
|
||||||
|
|||||||
97
app/main.py
97
app/main.py
@@ -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")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user