feat(govdoc): 新增内部公文模块全链路(后端58+前端11文件)

This commit is contained in:
wren
2026-05-13 14:37:12 +08:00
parent 99699e20e1
commit 5d777599bf
63 changed files with 7608 additions and 0 deletions
@@ -0,0 +1,105 @@
"""docx 标注:在原文加高亮 + 文末追加审核报告附页。"""
from __future__ import annotations
from pathlib import Path
from docx import Document as DocxDocument
from docx.enum.text import WD_BREAK
from docx.shared import Pt
from docx.oxml.ns import qn
from lxml import etree
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.result import AuditResult
_HIGHLIGHT_NAME = {
"error": "red",
"warning": "yellow",
"info": "cyan",
}
def _highlight_run(run, color_name: str) -> None:
rpr = run._element.get_or_add_rPr()
hl = rpr.find(qn("w:highlight"))
if hl is None:
hl = etree.SubElement(rpr, qn("w:highlight"))
hl.set(qn("w:val"), color_name)
def _highlight_paragraph_range(paragraph, start: int, end: int, color_name: str) -> None:
"""简化策略:高亮整段(精准 char range 留 v0.2 实现)。"""
for run in paragraph.runs:
_highlight_run(run, color_name)
def _add_heading_with_fallback(doc, text: str, level: int = 1):
try:
return doc.add_heading(text, level=level)
except KeyError:
# Some uploaded documents don't include Word's built-in heading styles.
p = doc.add_paragraph()
run = p.add_run(text)
run.bold = True
if level == 1:
run.font.size = Pt(16)
elif level == 2:
run.font.size = Pt(13)
else:
run.font.size = Pt(12)
return p
def _append_appendix(doc, result: AuditResult) -> None:
p = doc.add_paragraph()
p.add_run().add_break(WD_BREAK.PAGE)
_add_heading_with_fallback(doc, "审核报告附页", level=1)
s = result.summary
doc.add_paragraph(
f"得分: {s.score}/100 错误: {s.by_severity.get('error', 0)} "
f"警告: {s.by_severity.get('warning', 0)} 提示: {s.by_severity.get('info', 0)}"
)
if not result.findings:
doc.add_paragraph("未发现问题。")
return
table = doc.add_table(rows=1, cols=5)
try:
table.style = "Light Grid"
except KeyError:
# Some source documents don't ship with the built-in table style set.
pass
hdr = table.rows[0].cells
for i, h in enumerate(["编号", "规则", "严重度", "类别", "位置 / 说明"]):
hdr[i].text = h
for f in result.findings:
row = table.add_row().cells
row[0].text = f.finding_id
row[1].text = f.rule_id
row[2].text = f.severity
row[3].text = f.category
loc = f.location
ctx = (loc.context or "")[:30]
row[4].text = f"P{loc.paragraph_index} ({loc.role}): {f.message}\n 原文: {ctx}"
def annotate_docx(src: str | Path, dst: str | Path, result: AuditResult) -> None:
src = Path(src)
dst = Path(dst)
doc = DocxDocument(src)
for f in result.findings:
idx = f.location.paragraph_index
if 0 <= idx < len(doc.paragraphs):
color = _HIGHLIGHT_NAME.get(f.severity, "yellow")
_highlight_paragraph_range(
doc.paragraphs[idx], f.location.char_start, f.location.char_end, color
)
_append_appendix(doc, result)
dst.parent.mkdir(parents=True, exist_ok=True)
doc.save(dst)
@@ -0,0 +1,42 @@
"""把 Document 渲染为带 inline style 的 HTML 段落,给前端用。"""
from __future__ import annotations
from html import escape
from fastapi_modules.fastapi_leaudit.govdoc_engine.models import Document
def _style(p) -> str:
s = p.style
parts = []
if s.font_size_pt:
sz = s.font_size_pt
sz_str = str(int(sz)) if sz == int(sz) else str(sz)
parts.append(f"font-size:{sz_str}pt")
if s.font_eastasia:
parts.append(f"font-family:'{s.font_eastasia}',serif")
if s.alignment and s.alignment != "left":
parts.append(f"text-align:{s.alignment}")
if s.bold:
parts.append("font-weight:700")
if s.first_line_indent_pt:
parts.append(f"text-indent:{s.first_line_indent_pt}pt")
return ";".join(parts)
def paragraphs_to_html(doc: Document, finding_map: dict[int, list[str]]) -> str:
"""把 doc 每个段落渲染成 <p> 带 data-pi / data-role / data-finding-ids。"""
out = ['<div class="doc-view">']
for p in doc.paragraphs:
style = _style(p)
finding_ids = finding_map.get(p.index, [])
attrs = [
f'data-pi="{p.index}"',
f'data-role="{escape(p.role or "")}"',
]
if finding_ids:
attrs.append(f'data-finding-ids="{escape(",".join(finding_ids))}"')
if style:
attrs.append(f'style="{escape(style)}"')
out.append(f"<p {' '.join(attrs)}>{escape(p.text)}</p>")
out.append("</div>")
return "\n".join(out)
@@ -0,0 +1,76 @@
"""把 AuditResult 渲染成单文件 HTML 报告。"""
from __future__ import annotations
from html import escape
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.result import AuditResult
_CSS = """
body { font-family: -apple-system, "PingFang SC", sans-serif; margin: 0; padding: 24px;
background: #f7f7f9; color: #1a1a1a; }
.header { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
.score { width: 96px; height: 96px; border-radius: 50%;
background: conic-gradient(#22c55e var(--p), #e5e7eb var(--p));
display: grid; place-items: center; font-weight: 700; font-size: 22px; color: #111; }
.score-inner { background: white; width: 76px; height: 76px; border-radius: 50%;
display: grid; place-items: center; }
.tag { padding: 2px 8px; border-radius: 999px; font-size: 12px; }
.error { background: #fee2e2; color: #b91c1c; }
.warning { background: #fef9c3; color: #a16207; }
.info { background: #dbeafe; color: #1d4ed8; }
table { width: 100%; border-collapse: collapse; background: white; border-radius: 8px;
overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #f1f5f9; vertical-align: top; }
th { background: #f8fafc; font-size: 13px; }
td.msg { max-width: 480px; }
.context { color: #64748b; font-size: 12px; margin-top: 4px; }
"""
def render_html(result: AuditResult) -> str:
s = result.summary
score = s.score
pct = f"{score}%"
rows = []
for f in result.findings:
loc = f.location
suggest = (
f'<div style="color:#0369a1">建议: {escape(f.suggestion)}</div>'
if f.suggestion else ""
)
rows.append(f"""
<tr>
<td>{escape(f.finding_id)}</td>
<td>{escape(f.rule_id)}<br><span style="color:#64748b;font-size:12px">{escape(f.rule_name)}</span></td>
<td><span class="tag {f.severity}">{f.severity}</span></td>
<td>{escape(f.category)}</td>
<td>P{loc.paragraph_index} ({escape(loc.role or '')})</td>
<td class="msg">{escape(f.message)}
<div class="context">原文: {escape((loc.context or '')[:80])}</div>
{suggest}
</td>
</tr>""")
body = f"""<!doctype html>
<html lang="zh"><head><meta charset="utf-8"><title>公文审核报告</title>
<style>{_CSS}</style></head><body>
<div class="header">
<div class="score" style="--p:{pct}"><div class="score-inner">{score}</div></div>
<div>
<h1 style="margin:0">公文格式审核报告</h1>
<div style="color:#64748b">{escape(result.document.get('filename', ''))} · 共 {s.total_findings} 项</div>
<div style="margin-top:6px">
<span class="tag error">错误 {s.by_severity.get('error', 0)}</span>
<span class="tag warning">警告 {s.by_severity.get('warning', 0)}</span>
<span class="tag info">提示 {s.by_severity.get('info', 0)}</span>
</div>
</div>
</div>
<table>
<thead><tr>
<th>编号</th><th>规则</th><th>严重度</th><th>类别</th><th>位置</th><th>说明</th>
</tr></thead>
<tbody>{''.join(rows) or '<tr><td colspan=6>未发现问题</td></tr>'}</tbody>
</table>
</body></html>"""
return body
@@ -0,0 +1,12 @@
"""把 AuditResult 序列化为 JSON 字符串。"""
import json
from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.result import AuditResult
def to_json(result: AuditResult, indent: int = 2) -> str:
return json.dumps(
result.model_dump(mode="json"),
ensure_ascii=False,
indent=indent,
)