631 lines
21 KiB
Python
631 lines
21 KiB
Python
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,
|
|
summarize_job,
|
|
)
|
|
|
|
|
|
WORK_ROOT = Path(tempfile.gettempdir()) / "his_sur_data_deal_jobs"
|
|
WORK_ROOT.mkdir(parents=True, exist_ok=True)
|
|
|
|
APP_TITLE = "\u68c0\u6d4b\u6570\u636e\u5904\u7406"
|
|
UNMATCHED = "\u672a\u5339\u914d\u68c0\u6d4b\u5185\u5bb9"
|
|
|
|
app = FastAPI(title=APP_TITLE)
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
def index() -> str:
|
|
body = f"""
|
|
<section class="panel">
|
|
<form action="/process" method="post" enctype="multipart/form-data">
|
|
<div>
|
|
<label for="file">\u5f85\u5904\u7406\u68c0\u6d4b\u6570\u636e.zip</label>
|
|
<input id="file" name="file" type="file" accept=".zip,application/zip" required>
|
|
</div>
|
|
<div class="grid">
|
|
<div>
|
|
<label for="mode">\u5904\u7406\u6a21\u5f0f</label>
|
|
<select id="mode" name="mode">
|
|
<option value="auto">\u81ea\u52a8\u8bc6\u522b</option>
|
|
<option value="v1">V1 \u6574\u6279\u6c47\u603b</option>
|
|
<option value="v2">V2 \u5355\u60a3\u8005\u6587\u4ef6</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label for="data_type">\u60a3\u8005\u7f16\u53f7\u7c7b\u578b</label>
|
|
<select id="data_type" name="data_type">
|
|
<option value="auto">\u81ea\u52a8\u8bc6\u522b</option>
|
|
<option value="pat_no">\u60a3\u8005\u53f7 pat_no</option>
|
|
<option value="zhuyuanhao">\u4f4f\u9662\u53f7 zhuyuanhao</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label for="result_name">\u7ed3\u679c\u6587\u4ef6\u540d</label>
|
|
<input id="result_name" name="result_name" type="text" value="Result">
|
|
</div>
|
|
<div>
|
|
<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="20">
|
|
</div>
|
|
</div>
|
|
<button type="submit">\u5f00\u59cb\u5904\u7406</button>
|
|
</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>
|
|
</section>
|
|
"""
|
|
return _page_shell(body)
|
|
|
|
|
|
@app.post("/process", response_class=HTMLResponse)
|
|
async def process(
|
|
file: UploadFile = File(...),
|
|
mode: str = Form("auto"),
|
|
data_type: str = Form("auto"),
|
|
result_name: str = Form("Result"),
|
|
preview_rows: int = Form(20),
|
|
) -> str:
|
|
if not file.filename or not file.filename.lower().endswith(".zip"):
|
|
raise HTTPException(status_code=400, detail="\u8bf7\u4e0a\u4f20 zip \u6587\u4ef6\u3002")
|
|
|
|
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)
|
|
|
|
rows = _clean_preview_rows(preview_rows)
|
|
result = run_processing(
|
|
zip_path=upload_path,
|
|
job_dir=job_dir,
|
|
mode=mode,
|
|
data_type=data_type,
|
|
result_name=result_name,
|
|
show_not_match=True,
|
|
show_all_infos=True,
|
|
preview_rows=rows,
|
|
include_basic_sheets=True,
|
|
include_unmatched_items=True,
|
|
include_summary_sheet=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"\u5904\u7406\u5931\u8d25\uff1a{exc}") from exc
|
|
|
|
return _render_result(result, preview_rows=rows)
|
|
|
|
|
|
@app.get("/result/{job_id}", response_class=HTMLResponse)
|
|
def result_page(
|
|
job_id: str,
|
|
preview_rows: int = Query(20, ge=5, le=200),
|
|
include_basic_sheets: bool = Query(True),
|
|
include_unmatched_items: bool = Query(True),
|
|
include_summary_sheet: bool = Query(True),
|
|
) -> str:
|
|
job_dir = _get_job_dir(job_id)
|
|
try:
|
|
rows = _clean_preview_rows(preview_rows)
|
|
result = summarize_job(
|
|
job_dir,
|
|
rows,
|
|
include_basic_sheets=include_basic_sheets,
|
|
include_unmatched_items=include_unmatched_items,
|
|
include_summary_sheet=include_summary_sheet,
|
|
)
|
|
except ProcessingError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
return _render_result(
|
|
result,
|
|
preview_rows=rows,
|
|
include_basic_sheets=include_basic_sheets,
|
|
include_unmatched_items=include_unmatched_items,
|
|
include_summary_sheet=include_summary_sheet,
|
|
)
|
|
|
|
|
|
@app.get("/download/all/{job_id}")
|
|
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)
|
|
try:
|
|
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:
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
return FileResponse(result_zip, media_type="application/zip", filename="result.zip")
|
|
|
|
|
|
@app.get("/download/file/{job_id}")
|
|
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)
|
|
try:
|
|
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:
|
|
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="\u7ed3\u679c\u4e0d\u5b58\u5728\u3002")
|
|
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="\u7ed3\u679c\u4e0d\u5b58\u5728\u3002")
|
|
return job_dir
|
|
|
|
|
|
def _clean_preview_rows(preview_rows: int) -> int:
|
|
return max(5, min(int(preview_rows or 20), 200))
|
|
|
|
|
|
def _checked(value: bool) -> str:
|
|
return "checked" if value else ""
|
|
|
|
|
|
def _render_result(
|
|
result: ProcessingResult,
|
|
preview_rows: int = 20,
|
|
include_basic_sheets: bool = True,
|
|
include_unmatched_items: bool = True,
|
|
include_summary_sheet: bool = True,
|
|
) -> 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>\u5904\u7406\u6a21\u5f0f</span><strong>{html.escape(result.mode.upper())}</strong></div>
|
|
<div class="metric"><span>Excel \u6587\u4ef6</span><strong>{len(result.files)}</strong></div>
|
|
<div class="metric"><span>\u5de5\u4f5c\u8868</span><strong>{total_sheets}</strong></div>
|
|
<div class="metric"><span>\u6570\u636e\u884c</span><strong>{total_rows}</strong></div>
|
|
</section>
|
|
<section class="export-options">
|
|
<strong>\u5185\u5bb9\u9009\u62e9</strong>
|
|
<label><input class="export-option" type="checkbox" data-param="include_basic_sheets" {_checked(include_basic_sheets)}> \u57fa\u672c\u5de5\u4f5c\u8868</label>
|
|
<label><input class="export-option" type="checkbox" data-param="include_unmatched_items" {_checked(include_unmatched_items)}> \u672a\u5339\u914d\u68c0\u6d4b\u5185\u5bb9\u9879</label>
|
|
<label><input class="export-option" type="checkbox" data-param="include_summary_sheet" {_checked(include_summary_sheet)}> \u672a\u68c0\u6d4b\u5230\u5185\u5bb9\u6c47\u603b\u8868</label>
|
|
</section>
|
|
<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>
|
|
<input id="preview_rows" name="preview_rows" type="number" min="5" max="200" value="{preview_rows}">
|
|
<button type="submit">\u5237\u65b0\u9884\u89c8</button>
|
|
</form>
|
|
<section class="hint">\u7ed3\u679c\u4e2d\u201c{UNMATCHED}\u201d\u5217\u4e3a\u989d\u5916\u8f85\u52a9\u4fe1\u606f\uff0c\u7528\u6765\u6807\u660e\u672a\u5f52\u5165\u6807\u51c6\u9879\u76ee\u7684\u68c0\u6d4b\u5185\u5bb9\u3002</section>
|
|
<section class="results">
|
|
{file_items}
|
|
</section>
|
|
"""
|
|
return _page_shell(
|
|
body,
|
|
subtitle="\u5904\u7406\u5b8c\u6210\uff0c\u53ef\u5411\u4e0b\u6eda\u52a8\u67e5\u770b\u66f4\u591a\u5de5\u4f5c\u8868\uff0c\u4e5f\u53ef\u8c03\u6574\u9884\u89c8\u884c\u6570\u540e\u5237\u65b0\u3002",
|
|
top_action=True,
|
|
)
|
|
|
|
|
|
def _render_file(file, job_id: str) -> str:
|
|
sheet_items = "\n".join(_render_sheet(sheet) for sheet in file.sheets)
|
|
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)} \u4e2a\u5de5\u4f5c\u8868</p>
|
|
</div>
|
|
<a class="small-button export-link" data-base="{file_url}" href="{file_url}">\u5bfc\u51fa\u6b64 Excel</a>
|
|
</div>
|
|
{sheet_items}
|
|
</article>
|
|
"""
|
|
|
|
|
|
def _render_sheet(sheet) -> str:
|
|
preview = sheet.preview
|
|
table = '<div class="empty">\u6b64\u5de5\u4f5c\u8868\u6ca1\u6709\u53ef\u9884\u89c8\u7684\u6570\u636e\u3002</div>'
|
|
if preview:
|
|
max_cols = min(max((len(row) for row in preview), default=0), 18)
|
|
unmatched_start = None
|
|
if preview and UNMATCHED in preview[0]:
|
|
unmatched_start = preview[0].index(UNMATCHED)
|
|
rows = []
|
|
for index, row in enumerate(preview):
|
|
row_class = ' class="group-header"' if _is_summary_group_header(row) else ""
|
|
cells = []
|
|
for col_index, value in enumerate((row + [""] * max_cols)[:max_cols]):
|
|
tag = "th" if index == 0 else "td"
|
|
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}>")
|
|
rows.append(f"<tr{row_class}>" + "".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} \u884c · {sheet.columns} \u5217</small>
|
|
</summary>
|
|
{table}
|
|
</details>
|
|
"""
|
|
|
|
|
|
def _page_shell(
|
|
body: str,
|
|
subtitle: str = "\u4e0a\u4f20\u201c\u5f85\u5904\u7406\u68c0\u6d4b\u6570\u636e.zip\u201d\uff0c\u5904\u7406\u5b8c\u6210\u540e\u5728\u7f51\u9875\u4e2d\u67e5\u770b\u7ed3\u679c\u3002",
|
|
top_action: bool = False,
|
|
) -> str:
|
|
action_html = ""
|
|
if top_action:
|
|
action_html = '<a class="top-action" href="/">\u9000\u51fa / \u7ee7\u7eed\u5904\u7406\u65b0\u6587\u4ef6</a>'
|
|
return f"""
|
|
<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>{APP_TITLE}</title>
|
|
<style>
|
|
:root {{
|
|
color-scheme: light;
|
|
--bg: #f6f7f9;
|
|
--panel: #ffffff;
|
|
--text: #1f2937;
|
|
--muted: #64748b;
|
|
--line: #d7dde5;
|
|
--primary: #146c5c;
|
|
--primary-dark: #0f574b;
|
|
--soft: #eef7f5;
|
|
--extra: #fff7e6;
|
|
}}
|
|
* {{ box-sizing: border-box; }}
|
|
html, body {{ overflow-x: hidden; }}
|
|
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(1600px, calc(100vw - 24px));
|
|
margin: 0 auto;
|
|
padding: 24px 0;
|
|
min-width: 0;
|
|
}}
|
|
header {{
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
margin-bottom: 18px;
|
|
}}
|
|
h1 {{
|
|
margin: 0;
|
|
font-size: 26px;
|
|
line-height: 1.2;
|
|
font-weight: 750;
|
|
letter-spacing: 0;
|
|
}}
|
|
.sub {{
|
|
margin-top: 6px;
|
|
color: var(--muted);
|
|
font-size: 14px;
|
|
}}
|
|
.panel, .file-block {{
|
|
background: var(--panel);
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
padding: 18px;
|
|
box-shadow: 0 8px 22px rgba(17, 24, 39, 0.05);
|
|
min-width: 0;
|
|
}}
|
|
form {{ display: grid; gap: 16px; }}
|
|
label {{
|
|
display: block;
|
|
margin-bottom: 7px;
|
|
font-size: 14px;
|
|
font-weight: 650;
|
|
}}
|
|
input[type="file"], select, input[type="text"], input[type="number"] {{
|
|
width: 100%;
|
|
border: 1px solid var(--line);
|
|
border-radius: 6px;
|
|
padding: 10px 11px;
|
|
font: inherit;
|
|
background: #fff;
|
|
color: var(--text);
|
|
}}
|
|
.grid {{
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
gap: 12px;
|
|
}}
|
|
.checks {{
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 14px;
|
|
color: var(--text);
|
|
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 {{
|
|
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: 40px;
|
|
border-radius: 6px;
|
|
padding: 0 14px;
|
|
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;
|
|
}}
|
|
.top-action {{
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 40px;
|
|
border-radius: 6px;
|
|
padding: 0 14px;
|
|
background: var(--primary);
|
|
color: #fff;
|
|
font-weight: 700;
|
|
text-decoration: none;
|
|
white-space: nowrap;
|
|
}}
|
|
.top-action:hover {{ background: var(--primary-dark); }}
|
|
.note, .hint {{
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
line-height: 1.6;
|
|
}}
|
|
.note {{
|
|
margin-top: 2px;
|
|
border-top: 1px solid var(--line);
|
|
padding-top: 14px;
|
|
}}
|
|
.summary {{
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
gap: 12px;
|
|
margin-bottom: 14px;
|
|
}}
|
|
.metric {{
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
background: #fff;
|
|
padding: 14px;
|
|
}}
|
|
.metric span {{
|
|
display: block;
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
margin-bottom: 8px;
|
|
}}
|
|
.metric strong {{ font-size: 24px; line-height: 1; }}
|
|
.actions, .row-control {{
|
|
display: flex;
|
|
align-items: end;
|
|
gap: 10px;
|
|
margin: 0 0 14px;
|
|
}}
|
|
.row-control label {{
|
|
margin: 0;
|
|
color: var(--muted);
|
|
font-weight: 650;
|
|
}}
|
|
.row-control input {{ width: 120px; }}
|
|
.hint {{
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
background: #fff;
|
|
padding: 10px 12px;
|
|
margin-bottom: 14px;
|
|
}}
|
|
.results {{ display: grid; gap: 14px; min-width: 0; }}
|
|
.file-head {{
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 14px;
|
|
margin-bottom: 14px;
|
|
}}
|
|
h2 {{ margin: 0; font-size: 18px; letter-spacing: 0; }}
|
|
.file-head p {{
|
|
margin: 5px 0 0;
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
}}
|
|
.sheet {{
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
margin-top: 10px;
|
|
overflow: hidden;
|
|
min-width: 0;
|
|
}}
|
|
summary {{
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
padding: 11px 14px;
|
|
cursor: pointer;
|
|
background: var(--soft);
|
|
font-weight: 700;
|
|
}}
|
|
summary small {{ color: var(--muted); font-weight: 500; }}
|
|
.table-wrap {{
|
|
width: 100%;
|
|
max-width: 100%;
|
|
max-height: 380px;
|
|
overflow-x: auto;
|
|
overflow-y: scroll;
|
|
background: #fff;
|
|
}}
|
|
table {{
|
|
width: max-content;
|
|
min-width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 13px;
|
|
}}
|
|
th, td {{
|
|
border-top: 1px solid var(--line);
|
|
border-right: 1px solid var(--line);
|
|
padding: 7px 10px;
|
|
text-align: left;
|
|
vertical-align: top;
|
|
white-space: nowrap;
|
|
}}
|
|
th {{ background: #fbfcfd; font-weight: 700; }}
|
|
tr.group-header td {{ font-weight: 700; background: #fbfcfd; }}
|
|
th.extra-col, td.extra-col {{ background: var(--extra); }}
|
|
.empty {{
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
padding: 12px 14px;
|
|
}}
|
|
@media (max-width: 760px) {{
|
|
header {{ flex-direction: column; }}
|
|
.grid, .summary {{ grid-template-columns: 1fr; }}
|
|
.actions, .file-head, .row-control, .export-options {{ align-items: stretch; flex-direction: column; }}
|
|
.row-control input {{ width: 100%; }}
|
|
button, .button, .small-button, .ghost {{ width: 100%; }}
|
|
.top-action {{ width: 100%; }}
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="shell">
|
|
<header>
|
|
<div>
|
|
<h1>{APP_TITLE}</h1>
|
|
<div class="sub">{html.escape(subtitle)}</div>
|
|
</div>
|
|
{action_html}
|
|
</header>
|
|
{body}
|
|
</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();
|
|
}});
|
|
}}
|
|
function refreshResultWithOptions() {{
|
|
const resultForm = document.querySelector('.row-control');
|
|
if (!resultForm) return;
|
|
const url = new URL(resultForm.action, window.location.origin);
|
|
const previewRows = document.querySelector('#preview_rows');
|
|
if (previewRows) url.searchParams.set('preview_rows', previewRows.value || '20');
|
|
document.querySelectorAll('.export-option').forEach((option) => {{
|
|
url.searchParams.set(option.dataset.param, option.checked ? 'true' : 'false');
|
|
}});
|
|
window.location.href = url.toString();
|
|
}}
|
|
const resultForm = document.querySelector('.row-control');
|
|
if (resultForm) {{
|
|
resultForm.addEventListener('submit', (event) => {{
|
|
event.preventDefault();
|
|
refreshResultWithOptions();
|
|
}});
|
|
}}
|
|
document.querySelectorAll('.export-option').forEach((option) => option.addEventListener('change', () => {{
|
|
updateExportLinks();
|
|
refreshResultWithOptions();
|
|
}}));
|
|
updateExportLinks();
|
|
</script>
|
|
</body>
|
|
</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")
|