From 46ffca4fc437735e69028ddd647d0ca3ea0d0d84 Mon Sep 17 00:00:00 2001
From: wren <“porlong@qq.com”>
Date: Wed, 13 May 2026 15:13:39 +0800
Subject: [PATCH 1/5] fix: correct OCR API path from /ocr to /chandra/ocr for
hub endpoint
---
.../fastapi_leaudit/leaudit_bridge/resilient_clients.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/fastapi_modules/fastapi_leaudit/leaudit_bridge/resilient_clients.py b/fastapi_modules/fastapi_leaudit/leaudit_bridge/resilient_clients.py
index e1992e4..369b271 100644
--- a/fastapi_modules/fastapi_leaudit/leaudit_bridge/resilient_clients.py
+++ b/fastapi_modules/fastapi_leaudit/leaudit_bridge/resilient_clients.py
@@ -277,7 +277,7 @@ class ResilientChandraOCRClient(ChandraOCRClient):
files = {"file": (path.name, file_obj, _guess_mime(path))}
data = {"include_images": str(self.include_images).lower()}
response = await client.post(
- f"{self.base_url}/ocr",
+ f"{self.base_url}/chandra/ocr",
files=files,
data=data,
)
From 2e0e23fc6a7d78a760155a9d8033e9e006281718 Mon Sep 17 00:00:00 2001
From: wren <“porlong@qq.com”>
Date: Wed, 13 May 2026 15:21:09 +0800
Subject: [PATCH 2/5] fix: correct LLM/VLM base URL to /qwen/v1 path for hub
endpoint
---
app.toml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/app.toml b/app.toml
index 07e8d3b..1cfca48 100644
--- a/app.toml
+++ b/app.toml
@@ -33,17 +33,17 @@ BUCKET = "leaudit"
REGION = ""
[LLM]
-BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
+BASE_URL = "https://hub.leke.run/qwen/v1"
MODEL = "qwen3.5-35b-a3b"
API_KEY = "sk-6c7466b543b947ffadc50a5d79135712"
[VLM]
-BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
+BASE_URL = "https://hub.leke.run/qwen/v1"
MODEL = "qwen3.5-35b-a3b"
API_KEY = "sk-6c7466b543b947ffadc50a5d79135712"
[OCR]
-BASE_URL = "http://i-2.gpushare.com:44112/"
+BASE_URL = "https://hub.leke.run/"
TIMEOUT = 300
[LEAUDIT]
From 1bacfe41b7ce9ada6a61d02e87f852e7f5b1e6a7 Mon Sep 17 00:00:00 2001
From: wren <“porlong@qq.com”>
Date: Mon, 18 May 2026 14:35:25 +0800
Subject: [PATCH 3/5] feat: integrate govdoc platform updates
---
docs/内部公文模块/内部公文前端拆分实施清单.md | 513 ++++++++++++++
docs/内部公文模块/报告UI样例.html | 654 ++++++++++++++++++
.../fastapi_leaudit/domian/vo/ragChatVo.py | 1 +
.../govdoc_engine/reporter/html_renderer.py | 640 +++++++++++++++--
.../services/impl/govdocServiceImpl.py | 33 +-
.../services/impl/ragChatServiceImpl.py | 76 +-
.../services/impl/ragDatasetServiceImpl.py | 2 +-
leaudit.sh | 24 +-
legal-platform-frontend | 2 +-
scripts/regenerate_govdoc_html_report.py | 298 ++++++++
10 files changed, 2151 insertions(+), 92 deletions(-)
create mode 100644 docs/内部公文模块/内部公文前端拆分实施清单.md
create mode 100644 docs/内部公文模块/报告UI样例.html
create mode 100644 scripts/regenerate_govdoc_html_report.py
diff --git a/docs/内部公文模块/内部公文前端拆分实施清单.md b/docs/内部公文模块/内部公文前端拆分实施清单.md
new file mode 100644
index 0000000..b71ba2a
--- /dev/null
+++ b/docs/内部公文模块/内部公文前端拆分实施清单.md
@@ -0,0 +1,513 @@
+# 内部公文前端拆分实施清单
+
+## 1. 文档目的
+
+本文档只解决一个问题:
+
+- 在不改变“内部公文”业务语义的前提下,如何把当前前端实现拆成一套与“交叉评查”同级的独立页面架构
+
+本文档关注的是:
+
+- 页面编排边界
+- 组件职责边界
+- `govdoc` 与 `reviews / cross-checking` 的复用边界
+- 分阶段实施顺序
+
+本文档不做以下事情:
+
+- 不改后端业务语义
+- 不要求照搬旧项目代码
+- 不把 `Collabora` 当成整个中栏预览架构
+
+---
+
+## 2. 结论先行
+
+内部公文前端应按以下原则重构:
+
+> **像交叉评查一样独立成页,但复用 reviews 的定位型预览能力。**
+
+准确解释如下:
+
+- 内部公文应有自己独立的页面 orchestrator
+- 内部公文应有自己独立的业务组件层
+- 内部公文应有自己独立的 TS service / adapter 层
+- 中栏 PDF / DOCX 预览不应重新发明一套,而应优先复用 `reviews` 已有能力
+- `Collabora` 只应作为 DOCX viewer,不应承担“问题定位主架构”
+
+因此,目标不是:
+
+- 把当前 `govdoc-audit` 页面继续补丁式扩写
+
+而是:
+
+- 把内部公文前端收敛为“独立页面编排 + 统一预览协议 + 独立业务壳”的平台化实现
+
+---
+
+## 3. 当前实现现状
+
+## 3.1 当前内部公文前端入口
+
+当前内部公文详情页主入口为:
+
+- [components/govdoc-audit/audit.tsx](/home/wren-dev/Porject/leaudit-platform/legal-platform-frontend/components/govdoc-audit/audit.tsx:1)
+
+当前内部公文列表页主入口为:
+
+- [components/govdoc-audit/audits.tsx](/home/wren-dev/Porject/leaudit-platform/legal-platform-frontend/components/govdoc-audit/audits.tsx:1)
+
+当前路由入口为:
+
+- [app/(audit)/govdoc/audits/page.tsx](/home/wren-dev/Porject/leaudit-platform/legal-platform-frontend/app/(audit)/govdoc/audits/page.tsx:1)
+- [app/(audit)/govdoc/detail/[documentId]/page.tsx](/home/wren-dev/Porject/leaudit-platform/legal-platform-frontend/app/(audit)/govdoc/detail/[documentId]/page.tsx:1)
+
+当前详情页已经具备:
+
+- 顶部摘要与报告下载操作
+- 评查 / 结构 / 大纲 / 实体 tab
+- 中栏文档视图
+- 右栏 findings / checked rules 展示
+
+问题不在于“没有功能”,而在于“页面职责混装”。
+
+---
+
+## 3.2 当前详情页耦合点
+
+当前 [audit.tsx](/home/wren-dev/Porject/leaudit-platform/legal-platform-frontend/components/govdoc-audit/audit.tsx:1) 同时承担了以下职责:
+
+- 页面数据加载
+- 顶部操作区渲染
+- tab 状态切换
+- 结果统计条渲染
+- 中栏文档视图调度
+- 右栏问题面板调度
+- 规则弹窗调度
+
+这会带来三个问题:
+
+- 页面 orchestrator 和业务组件未分层
+- 中栏预览协议没有向平台现有 `reviews` 能力对齐
+- 右栏问题区与 `reviews / cross-checking` 的定位交互无法复用
+
+---
+
+## 3.3 当前中栏预览为什么不应继续沿现状扩写
+
+当前内部公文中栏主要使用:
+
+- [components/govdoc-audit/doc-view.tsx](/home/wren-dev/Porject/leaudit-platform/legal-platform-frontend/components/govdoc-audit/doc-view.tsx:1)
+
+而平台现有成熟的“定位型预览”能力在:
+
+- [components/reviews/previewComponents/PdfPreviewTest.tsx](/home/wren-dev/Porject/leaudit-platform/legal-platform-frontend/components/reviews/previewComponents/PdfPreviewTest.tsx:1)
+- [components/reviews/previewComponents/DocxPreviewTest.tsx](/home/wren-dev/Porject/leaudit-platform/legal-platform-frontend/components/reviews/previewComponents/DocxPreviewTest.tsx:1)
+- [app/(audit)/reviews-test/ReviewsTestClient.tsx](/home/wren-dev/Porject/leaudit-platform/legal-platform-frontend/app/(audit)/reviews-test/ReviewsTestClient.tsx:1)
+- [app/(audit)/cross-checking/result/CrossCheckingResultClient.tsx](/home/wren-dev/Porject/leaudit-platform/legal-platform-frontend/app/(audit)/cross-checking/result/CrossCheckingResultClient.tsx:1)
+
+必须明确:
+
+- PDF 中栏定位主能力不是 `Collabora`
+- DOCX 中栏当前虽然使用 `CollaboraViewer`,但它承担的是文档渲染,不是完整的问题定位架构
+
+如果内部公文要做到:
+
+- 点击问题点后定位到对应页
+- 对问题字段/段落做高亮
+- 为后续“问题行定位”保留升级空间
+
+则中栏必须对齐现有平台预览输入协议,而不是继续把 `DocView` 做成一套孤岛实现。
+
+---
+
+## 4. 目标架构
+
+## 4.1 总体原则
+
+前端目标架构应满足以下四条:
+
+- 内部公文页面独立编排
+- 中栏预览能力平台复用
+- 业务面板 govdoc 自治
+- 数据适配集中在 adapter 层
+
+可以概括为:
+
+> **govdoc 自己负责业务壳,platform 负责通用预览能力。**
+
+---
+
+## 4.2 目标目录结构
+
+建议拆分为以下结构:
+
+```text
+legal-platform-frontend/
+ app/(audit)/govdoc/
+ audits/page.tsx
+ detail/[documentId]/page.tsx
+
+ components/govdoc-audit/
+ GovdocAuditListPage.tsx
+ GovdocAuditResultPage.tsx
+ GovdocSummaryHeader.tsx
+ GovdocFindingPanel.tsx
+ GovdocStructurePanel.tsx
+ GovdocOutlinePanel.tsx
+ GovdocEntityPanel.tsx
+ GovdocReportActions.tsx
+
+ lib/api/govdoc-audit/
+ api.ts
+ types.ts
+ adapters.ts
+ govdoc-routes.ts
+```
+
+说明如下:
+
+- `page.tsx` 只保留路由入口职责
+- `GovdocAuditResultPage.tsx` 负责详情页 orchestrator
+- `GovdocAuditListPage.tsx` 负责列表页 orchestrator
+- `Govdoc*Panel` 负责内部公文独有业务视图
+- `adapters.ts` 负责把 govdoc 后端返回结果转成前端视图模型
+
+---
+
+## 4.3 页面编排职责
+
+### 详情页 orchestrator
+
+建议新增:
+
+- `components/govdoc-audit/GovdocAuditResultPage.tsx`
+
+该组件只负责:
+
+- 读取 `documentId / runId`
+- 调用 govdoc API
+- 维护 tab 状态
+- 维护当前激活问题点
+- 维护当前预览定位目标
+- 组装中栏与右栏
+
+它不应承担:
+
+- 具体 finding 卡片渲染细节
+- 实体/结构/大纲具体 UI 细节
+- 预览底层渲染逻辑
+
+这部分应当参照:
+
+- [CrossCheckingResultClient.tsx](/home/wren-dev/Porject/leaudit-platform/legal-platform-frontend/app/(audit)/cross-checking/result/CrossCheckingResultClient.tsx:1)
+
+---
+
+### 列表页 orchestrator
+
+建议新增:
+
+- `components/govdoc-audit/GovdocAuditListPage.tsx`
+
+该组件负责:
+
+- 列表数据加载
+- 筛选状态
+- 批量操作状态
+- 导出与删除
+- 跳转详情页
+
+它应继续保持内部公文自己的筛选语义,但 UI 节奏应向平台文档列表页靠拢。
+
+列表页设计参照:
+
+- [DocumentsListClient.tsx](/home/wren-dev/Porject/leaudit-platform/legal-platform-frontend/app/(audit)/documents/list/DocumentsListClient.tsx:1)
+
+---
+
+## 5. 与 reviews / cross-checking 的复用边界
+
+## 5.1 应复用的能力
+
+内部公文应复用以下能力:
+
+- PDF 中栏预览组件
+- DOCX 中栏预览组件
+- 问题点点击后的预览定位协议
+- 页码跳转、高亮、bbox / charPositions 定位能力
+
+优先复用对象:
+
+- [PdfPreviewTest.tsx](/home/wren-dev/Porject/leaudit-platform/legal-platform-frontend/components/reviews/previewComponents/PdfPreviewTest.tsx:1)
+- [DocxPreviewTest.tsx](/home/wren-dev/Porject/leaudit-platform/legal-platform-frontend/components/reviews/previewComponents/DocxPreviewTest.tsx:1)
+
+复用的是:
+
+- 预览能力
+- 定位协议
+- 用户交互模型
+
+不是:
+
+- 合同业务语义
+- 卷宗业务命名
+- 旧页面外壳
+
+---
+
+## 5.2 不应复用的部分
+
+以下部分不应直接复用:
+
+- `reviews-test` 自身的业务标题、业务字段命名
+- 合同/卷宗专有的右栏业务解释
+- `cross-checking` 的评分协同、提议投票、交叉意见面板
+
+原因是:
+
+- 这些属于业务壳,而不是平台通用能力
+
+内部公文应保留自己的:
+
+- findings 口径
+- checked rules 口径
+- 结构 / 大纲 / 实体口径
+- 报告下载口径
+
+---
+
+## 5.3 Collabora 的正确定位
+
+`CollaboraViewer` 的边界必须明确:
+
+- 它是 DOCX viewer
+- 它可以承担跳页、文本高亮、编辑/只读查看
+- 它不是内部公文详情页的业务 orchestrator
+- 它也不是“问题行精确定位”的完整方案
+
+因此:
+
+- `Collabora` 只能留在 `DocxPreviewTest` 这一层
+- 不应让 govdoc 页面继续直接围绕 `Collabora` 自己长出一套完整详情页体系
+
+---
+
+## 6. 必须新增的 adapter 层
+
+## 6.1 为什么必须有 adapters.ts
+
+当前 `lib/api/govdoc-audit` 下已有:
+
+- `api.ts`
+- `types.ts`
+- `govdoc-routes.ts`
+
+但还缺一层:
+
+- `adapters.ts`
+
+这层必须存在,因为它承担的是“业务结果语义 -> 预览与页面视图语义”的转换。
+
+如果没有这层,后果会是:
+
+- govdoc 页面自己维护一套 findings 展示模型
+- reviews 页面自己维护一套 preview target 模型
+- 同类定位交互会出现两套不兼容实现
+
+---
+
+## 6.2 adapters.ts 建议职责
+
+`adapters.ts` 建议至少提供以下能力:
+
+- 将 `govdoc` 结果对象转换为结果页 view model
+- 将 `finding / checked_rule` 转换为右栏展示项
+- 将 `finding / paragraph / entity` 转换为中栏跳转目标
+- 根据文件类型产出统一 preview target
+- 将后端报告产物状态转换为按钮展示状态
+
+建议输出的数据语义包括:
+
+- `previewKind`
+- `previewPath`
+- `activeTarget`
+- `findingItems`
+- `summaryCards`
+- `reportActions`
+- `structureItems`
+- `outlineItems`
+- `entityItems`
+
+这样后续页面层只编排,不解释后端字段细节。
+
+---
+
+## 7. 分阶段实施顺序
+
+## 7.1 第一阶段:补 adapter,不改页面语义
+
+目标:
+
+- 先把数据适配层补齐
+
+动作:
+
+- 新增 `lib/api/govdoc-audit/adapters.ts`
+- 收敛 `audit.tsx` 里对原始接口字段的直接解释
+- 把 preview target 语义统一为:
+ - `page`
+ - `highlightValue`
+ - `bboxHighlight`
+ - `charPositions`
+
+本阶段收益:
+
+- 不改用户可见业务逻辑
+- 为后续替换中栏和右栏做稳定基础
+
+---
+
+## 7.2 第二阶段:拆详情页 orchestrator
+
+目标:
+
+- 让 govdoc 详情页像 `cross-checking` 一样拥有独立 orchestrator
+
+动作:
+
+- 新增 `GovdocAuditResultPage.tsx`
+- 将现有 `audit.tsx` 逻辑迁入新组件
+- 路由入口改为挂载新组件
+- 顶部摘要、下载操作、tab 切换拆成子组件
+
+本阶段收益:
+
+- 页面职责清晰
+- 后续中栏和右栏可以独立演进
+
+---
+
+## 7.3 第三阶段:切换中栏到定位型预览
+
+目标:
+
+- 内部公文详情页中栏不再以 `DocView` 为核心
+
+动作:
+
+- 根据文件类型切换到 `PdfPreviewTest / DocxPreviewTest`
+- 从 govdoc adapter 输出统一 preview target
+- 让右栏点击直接驱动中栏定位
+
+本阶段注意:
+
+- PDF 定位优先支持 `bboxHighlight / charPositions`
+- DOCX 优先支持 `targetPage + highlightValue`
+- 不承诺此阶段立即做到“DOCX 行级精确定位”
+
+---
+
+## 7.4 第四阶段:重构右栏与 tab 业务壳
+
+目标:
+
+- 保留 govdoc 自己的业务面板,但交互模型对齐平台
+
+动作:
+
+- 将当前 `RightPanel` 重构为 `GovdocFindingPanel`
+- 将结构、大纲、实体分面板组件化
+- 收敛旧的孤立交互状态
+
+本阶段收益:
+
+- govdoc 保持业务独立
+- 同时具备平台统一的交互体验
+
+---
+
+## 7.5 第五阶段:样式与布局收口
+
+目标:
+
+- govdoc 页面在视觉上向平台现有绿色主题和通用 panel 节奏靠齐
+
+动作:
+
+- 减少 `.govdoc-audit-scope` 中重复定义
+- 优先复用 `layout-primitives.css`
+- 保留必要的 govdoc 业务样式命名空间
+
+本阶段原则:
+
+- 先统一布局和交互节奏
+- 再减少样式重复
+- 不先做“大改视觉”
+
+---
+
+## 8. 风险点与前置条件
+
+## 8.1 最大风险不在前端组件,而在定位数据颗粒度
+
+内部公文要实现“定位到哪一行有问题”,前端只是承载层,真正决定上限的是后端给的数据。
+
+前端能稳定消费的数据类型分为两类:
+
+- PDF:
+ - `page`
+ - `bbox`
+ - `page_box`
+ - `char_positions`
+- DOCX:
+ - `targetPage`
+ - `highlightValue`
+ - 未来如果需要更高精度,还需要更细粒度锚点
+
+如果后端只给:
+
+- 问题描述
+- 规则结果
+
+而不给定位数据,那么前端最多只能做到:
+
+- 页级定位
+- 文本关键字高亮
+
+不能承诺做到稳定的“行级定位”。
+
+---
+
+## 8.2 不应在这一阶段做的事情
+
+以下动作不建议和本次拆分同时进行:
+
+- 重写 govdoc 全部视觉设计
+- 把 govdoc 规则语义改造成合同/卷宗语义
+- 试图把所有 `reviews` 业务组件直接搬进 govdoc
+- 在没有 adapter 的情况下直接大规模替换页面
+
+原因很简单:
+
+- 这些动作会把“前端分层重构”和“业务改动”混在一起,增加回归风险
+
+---
+
+## 9. 最终边界结论
+
+内部公文前端的正确实现边界应锁定为:
+
+- **像交叉评查一样,独立成页**
+- **像 reviews 一样,复用定位型预览能力**
+- **像平台模块一样,数据解释集中在 adapter 层**
+- **像内部公文自己一样,保留 findings / checked rules / structure / outline / entities 的业务语义**
+
+更直白地说:
+
+- `Govdoc 页面` 负责业务编排
+- `reviews 预览组件` 负责中栏定位能力
+- `Collabora` 只负责 DOCX 渲染
+- `adapters.ts` 负责把 govdoc 后端结果翻译成前端可复用语义
+
+这就是内部公文前端后续实施的固定边界。
diff --git a/docs/内部公文模块/报告UI样例.html b/docs/内部公文模块/报告UI样例.html
new file mode 100644
index 0000000..3e148d9
--- /dev/null
+++ b/docs/内部公文模块/报告UI样例.html
@@ -0,0 +1,654 @@
+
+
+
+
+
+ 内部公文报告 UI 样例
+
+
+
+
+
+
+
+
当前样例沿用你提供的实际报告数据,不改业务语义
+
+
+
+
+
统一报告样式锚点
+
公文格式审核报告
+
买卖合同 (1).docx · 共 123 项问题 · 样例用于确认 UI / 配色方向
+
+
+
+
+
+
+
+
问题类别
+
4标题 / 发文 / 格式 / 其他
+
+
+
+
+ 错误 31
+ 警告 92
+ 提示 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 编号 |
+ 规则 |
+ 严重度 |
+ 类别 |
+ 位置 |
+ 说明 |
+
+
+
+
+ | F-c0dfd361 |
+
+ GW-T-001
+ 标题文种合规性
+ |
+ error |
+ 标题 |
+ P-1 () |
+
+ 目标实体「title」未识别到
+ 原文:未识别到标题内容,无法继续执行标题文种合规校验。
+ 建议:补全标题并确保标题文种符合规则要求。
+ |
+
+
+ | F-a896eaa4 |
+
+ GW-N-001
+ 发文字号必须用六角括号
+ |
+ error |
+ 发文 |
+ P-1 () |
+
+ 目标实体「doc_number」未识别到
+ 原文:未识别到发文字号,年份括号规则无法匹配。
+ 建议:发文字号年份应用六角括号〔〕,不得使用方括号或圆括号。
+ |
+
+
+ | F-087a4841 |
+
+ GW-F-003
+ 二级标题用楷体三号
+ |
+ error |
+ 格式 |
+ P35 (heading_2) |
+
+ 字体或字号不符合(实际 仿宋 Nonept,期望 楷体 16pt)
+ 原文:(一)甲方从乙方处购买:
+ 建议:二级标题应使用楷体三号,保持同级标题样式一致。
+ |
+
+
+ | F-37b4bb81 |
+
+ GW-F-003
+ 二级标题用楷体三号
+ |
+ error |
+ 格式 |
+ P39 (heading_2) |
+
+ 字体或字号不符合(实际 仿宋 Nonept,期望 楷体 16pt)
+ 原文:(二)质量要求:
+ 建议:这一类同级标题可在正式版中支持折叠聚合同规则项。
+ |
+
+
+ | F-b2140a78 |
+
+ GW-F-003
+ 二级标题用楷体三号
+ |
+ warning |
+ 格式 |
+ P62 (heading_2) |
+
+ 格式接近但未完全满足规则要求
+ 原文:(一)交付时间: 。
+ 建议:保留原有规则说明内容,只把告警与错误的视觉层级拉开。
+ |
+
+
+
+
+
+
+
+
diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/ragChatVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/ragChatVo.py
index ddf8022..e020a16 100644
--- a/fastapi_modules/fastapi_leaudit/domian/vo/ragChatVo.py
+++ b/fastapi_modules/fastapi_leaudit/domian/vo/ragChatVo.py
@@ -34,6 +34,7 @@ class RagMessageItemVO(BaseModel):
answer: str = Field(...)
feedback: dict | None = Field(None)
retrieverResources: list[dict] | None = Field(None)
+ suggestedQuestions: list[str] = Field(default_factory=list)
createdAt: int = Field(0)
diff --git a/fastapi_modules/fastapi_leaudit/govdoc_engine/reporter/html_renderer.py b/fastapi_modules/fastapi_leaudit/govdoc_engine/reporter/html_renderer.py
index 809442b..e591029 100644
--- a/fastapi_modules/fastapi_leaudit/govdoc_engine/reporter/html_renderer.py
+++ b/fastapi_modules/fastapi_leaudit/govdoc_engine/reporter/html_renderer.py
@@ -1,76 +1,594 @@
"""把 AuditResult 渲染成单文件 HTML 报告。"""
from __future__ import annotations
+
+from collections import Counter
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; }
+* { box-sizing: border-box; }
+html, body { margin: 0; padding: 0; }
+body {
+ font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
+ background: #f3f6f5;
+ color: #0f172a;
+}
+a { color: inherit; }
+.page {
+ width: 100%;
+ padding: 20px 24px 32px;
+}
+.stack {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+.card {
+ background: #ffffff;
+ border: 1px solid #e2e8f0;
+ border-radius: 12px;
+ box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
+ overflow: hidden;
+}
+.card-head {
+ height: 48px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 0 20px;
+ border-bottom: 1px solid #e2e8f0;
+ background: #fcfdfd;
+}
+.card-title {
+ font-size: 14px;
+ font-weight: 600;
+ color: #1e293b;
+}
+.card-subtitle {
+ font-size: 12px;
+ color: #64748b;
+}
+.summary-grid {
+ display: grid;
+ grid-template-columns: 220px minmax(0, 1fr);
+ gap: 20px;
+ padding: 20px;
+}
+.score-box {
+ border: 1px solid #cfe4dc;
+ background: #f7fbf9;
+ border-radius: 10px;
+ padding: 20px;
+}
+.score-label {
+ font-size: 12px;
+ font-weight: 500;
+ color: #475569;
+}
+.score-value {
+ margin-top: 12px;
+ font-size: 42px;
+ line-height: 1;
+ font-weight: 600;
+ letter-spacing: -0.05em;
+ color: #0f172a;
+}
+.score-track {
+ margin-top: 16px;
+ height: 8px;
+ background: #dbe8e3;
+ border-radius: 999px;
+ overflow: hidden;
+}
+.score-fill {
+ height: 100%;
+ background: #00684a;
+}
+.score-note {
+ margin-top: 16px;
+ font-size: 12px;
+ line-height: 1.75;
+ color: #475569;
+}
+.summary-main {
+ min-width: 0;
+}
+.eyebrow {
+ display: inline-flex;
+ align-items: center;
+ height: 28px;
+ padding: 0 12px;
+ border: 1px solid #cfe4dc;
+ border-radius: 6px;
+ background: #e8f3ef;
+ color: #00684a;
+ font-size: 12px;
+ font-weight: 500;
+}
+.report-title {
+ margin: 12px 0 0;
+ font-size: 32px;
+ line-height: 1.25;
+ letter-spacing: -0.03em;
+ font-weight: 600;
+ color: #0f172a;
+}
+.report-meta {
+ margin-top: 8px;
+ font-size: 15px;
+ color: #475569;
+}
+.metrics {
+ margin-top: 20px;
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 16px;
+}
+.metric {
+ border: 1px solid #e2e8f0;
+ border-radius: 10px;
+ background: #fcfdfd;
+ padding: 16px 20px;
+}
+.metric-label {
+ font-size: 13px;
+ font-weight: 500;
+ color: #64748b;
+}
+.metric-value {
+ margin-top: 12px;
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+}
+.metric-value strong {
+ font-size: 30px;
+ line-height: 1;
+ letter-spacing: -0.04em;
+ font-weight: 600;
+ color: #0f172a;
+}
+.metric-value span {
+ font-size: 13px;
+ color: #64748b;
+}
+.chips {
+ margin-top: 20px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+}
+.chip,
+.severity-tag {
+ display: inline-flex;
+ align-items: center;
+ border: 1px solid transparent;
+ border-radius: 6px;
+ font-weight: 600;
+}
+.chip {
+ height: 32px;
+ padding: 0 12px;
+ font-size: 12px;
+}
+.severity-tag {
+ height: 32px;
+ padding: 0 12px;
+ font-size: 12px;
+ text-transform: uppercase;
+}
+.error {
+ border-color: #fecaca;
+ background: #fef2f2;
+ color: #b91c1c;
+}
+.warning {
+ border-color: #fde68a;
+ background: #fffbeb;
+ color: #b45309;
+}
+.info {
+ border-color: #bfdbfe;
+ background: #eff6ff;
+ color: #1d4ed8;
+}
+.content-grid {
+ display: grid;
+ grid-template-columns: 340px minmax(0, 1fr);
+ gap: 20px;
+}
+.sidebar-body {
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+.summary-row {
+ border: 1px solid #e2e8f0;
+ border-radius: 10px;
+ background: #fcfdfd;
+ padding: 16px;
+}
+.summary-row-label {
+ font-size: 12px;
+ font-weight: 500;
+ color: #64748b;
+}
+.summary-row-value {
+ margin-top: 8px;
+ font-size: 22px;
+ line-height: 1;
+ letter-spacing: -0.03em;
+ font-weight: 600;
+ color: #0f172a;
+}
+.summary-row-desc {
+ margin-top: 12px;
+ font-size: 13px;
+ line-height: 1.75;
+ color: #475569;
+}
+.table-toolbar {
+ height: 48px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 0 20px;
+ border-bottom: 1px solid #e2e8f0;
+ background: #fcfdfd;
+}
+.toolbar-left {
+ min-width: 0;
+}
+.toolbar-title {
+ font-size: 14px;
+ font-weight: 600;
+ color: #1e293b;
+}
+.toolbar-desc {
+ margin-top: 2px;
+ font-size: 12px;
+ color: #64748b;
+}
+.toolbar-filters {
+ display: flex;
+ gap: 8px;
+}
+.filter {
+ display: inline-flex;
+ align-items: center;
+ height: 32px;
+ padding: 0 12px;
+ border: 1px solid #e2e8f0;
+ border-radius: 6px;
+ background: #ffffff;
+ color: #64748b;
+ font-size: 12px;
+ font-weight: 500;
+}
+.filter.active {
+ border-color: rgba(0, 104, 74, 0.2);
+ background: #e8f3ef;
+ color: #00684a;
+}
+.table-wrap {
+ overflow-x: auto;
+}
+table {
+ width: 100%;
+ min-width: 1320px;
+ border-collapse: collapse;
+}
+thead tr {
+ background: #f8fafc;
+ color: #475569;
+ font-size: 13px;
+ font-weight: 500;
+}
+th {
+ padding: 16px 20px;
+ text-align: left;
+ border-bottom: 1px solid #e2e8f0;
+ white-space: nowrap;
+}
+td {
+ padding: 20px;
+ vertical-align: top;
+ border-bottom: 1px solid #f1f5f9;
+}
+tbody tr:hover {
+ background: #f8fafc;
+}
+.mono {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+.id-cell {
+ font-size: 13px;
+ color: #64748b;
+}
+.rule-id {
+ font-size: 15px;
+ font-weight: 600;
+ color: #1e293b;
+}
+.rule-name {
+ margin-top: 4px;
+ font-size: 13px;
+ color: #64748b;
+}
+.category-cell {
+ font-size: 14px;
+ color: #334155;
+}
+.location-cell {
+ font-size: 13px;
+ color: #334155;
+}
+.message-cell {
+ min-width: 560px;
+}
+.message-main {
+ font-size: 15px;
+ line-height: 1.8;
+ color: #0f172a;
+}
+.context-box,
+.suggestion-box {
+ margin-top: 12px;
+ border-radius: 6px;
+ padding: 12px 16px;
+ font-size: 13px;
+ line-height: 1.8;
+}
+.context-box {
+ border: 1px solid #e2e8f0;
+ background: #f8fafc;
+ color: #475569;
+}
+.suggestion-box {
+ border: 1px solid #cfe4dc;
+ background: #f4faf7;
+ color: #0d6b4d;
+}
+.empty {
+ padding: 24px 20px;
+ text-align: center;
+ color: #64748b;
+ font-size: 14px;
+}
+@media (max-width: 1200px) {
+ .summary-grid,
+ .content-grid {
+ grid-template-columns: 1fr;
+ }
+ .metrics {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+@media (max-width: 720px) {
+ .page {
+ padding: 16px;
+ }
+ .metrics {
+ grid-template-columns: 1fr;
+ }
+ .table-toolbar,
+ .card-head {
+ height: auto;
+ min-height: 48px;
+ padding-top: 12px;
+ padding-bottom: 12px;
+ align-items: flex-start;
+ flex-direction: column;
+ }
+}
"""
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'建议: {escape(f.suggestion)}
'
- if f.suggestion else ""
- )
- rows.append(f"""
-
- | {escape(f.finding_id)} |
- {escape(f.rule_id)} {escape(f.rule_name)} |
- {f.severity} |
- {escape(f.category)} |
- P{loc.paragraph_index} ({escape(loc.role or '')}) |
- {escape(f.message)}
- 原文: {escape((loc.context or '')[:80])}
- {suggest}
- |
-
""")
+ summary = result.summary
+ score = int(summary.score or 0)
+ score_pct = max(0, min(score, 100))
+ severity_counts = _severity_counts(result)
+ category_count = len([key for key, value in (summary.by_category or {}).items() if key and value])
+ filename = escape(str(result.document.get("filename", "")))
+ top_rule_id, top_rule_count = _top_rule(result)
+ line_range = _line_range(result)
+ entity_summary = _entity_summary(result)
- body = f"""
-公文审核报告
-
-