feat(govdoc): 新增内部公文模块全链路(后端58+前端11文件)
This commit is contained in:
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user