提交 7b0e7dcd authored 作者: 陈泽健's avatar 陈泽健

feat(service-monitor): 导出 PDF/Excel 格式

阶段九 P2 功能增强:

Excel 导出(openpyxl):
- Sheet 1:汇总信息(目标、套件、时间、统计)
- Sheet 2:检测明细(全量表格)
- 状态列条件格式(正常=绿、警告=黄、严重=红)
- 表头冻结

PDF 导出(weasyprint):
- 使用 weasyprint 将 HTML 转 PDF
- 降级策略:不可用时返回可打印 HTML
- @page 分页规则 + 中文字体支持
- 打印按钮提示(降级模式)

路由改进:
- api_export_report() 重构避免重复代码
- 支持 format=excel/pdf 参数

前端:
- report.html 导出菜单新增两个选项

文档:
- PRD_需求文档_导出PDF_Excel格式.md
- PRD_计划执行_导出PDF_Excel格式.md

测试:167 个测试全绿 Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 069f9fa2
# PRD 计划执行:导出 PDF/Excel 格式
> 模块:服务监测(service_monitor)
> 优先级:P2
> 创建日期:2026-07-23
> 需求文档:`PRD_需求文档_导出PDF_Excel格式.md`
---
## 执行计划
### 阶段一:Excel 导出实现(预估 1h)
**任务清单**
- [ ] 1.1 新增 export_excel() 函数
- 文件:`skill/code/web/service_monitor/services/report_service.py`
- 使用 `openpyxl`(已在依赖中)
- Sheet 1:汇总信息
- Sheet 2:检测明细(全量表格 + 条件格式)
- [ ] 1.2 集成到导出 API
- 文件:`skill/code/web/service_monitor/routes.py`
- 修改:`api_export_report()` 增加 `format=excel` 支持
**验收标准**
- Excel 导出成功
- 两个 Sheet 正确
- 状态颜色标记生效
---
### 阶段二:PDF 导出实现(预估 1.5h)
**任务清单**
- [ ] 2.1 新增 export_pdf() 函数
- 文件:`skill/code/web/service_monitor/services/report_service.py`
- 生成带打印样式的 HTML
- 使用 weasyprint 转 PDF(如可用),否则降级返回 HTML
- [ ] 2.2 PDF HTML 模板
- 内联 CSS 样式
- @page 分页规则
- 中文字体支持
- 页眉页脚
- [ ] 2.3 集成到导出 API
- 修改:`api_export_report()` 增加 `format=pdf` 支持
**验收标准**
- PDF 导出成功
- 中文显示正常
- 样式清晰
---
### 阶段三:前端集成 + 测试(预估 0.5h)
**任务清单**
- [ ] 3.1 修改导出菜单
- 文件:`skill/code/web/templates/service_monitor/report.html`
- 新增:"导出 PDF"、"导出 Excel" 选项
- [ ] 3.2 更新文档 + Git commit
**验收标准**
- 导出菜单显示新选项
- 各格式导出正常
---
## 关键文件清单
### 修改文件
| 文件 | 改动 |
|------|------|
| `requirements.txt` | 新增 weasyprint 依赖 |
| `service_monitor/services/report_service.py` | 新增 export_pdf()、export_excel() |
| `service_monitor/routes.py` | api_export_report() 增加 pdf/excel |
| `templates/service_monitor/report.html` | 导出菜单新增选项 |
---
## 风险与注意事项
### 风险 1:weasyprint 依赖 GTK
**决策**:使用 try/except 降级策略,weasyprint 可用时生成 PDF,不可用时返回可打印 HTML
### 风险 2:大报告性能
**缓解**:Excel 使用 openpyxl 的 write_only 模式(如需),PDF 限制单页内容量
\ No newline at end of file
# PRD 需求文档:导出 PDF/Excel 格式
> 模块:服务监测(service_monitor)
> 优先级:P2
> 创建日期:2026-07-23
> 状态:待开发
---
## 1. 背景与目标
### 1.1 背景
服务监测模块已支持导出巡检报告为 Markdown 和 JSON 格式。运维人员需要更多导出选项:
- PDF 格式:保留样式,适合打印归档、邮件附件
- Excel 格式:检测项明细表格,适合数据分析和二次处理
### 1.2 目标
实现 PDF 和 Excel 格式导出:
- 复用现有报告数据结构
- 集成到现有导出菜单
- 保持与 Markdown 导出一致的 API 设计
---
## 2. 功能需求
### 2.1 导出入口
**位置**:报告详情页(`report.html`)导出菜单
**现有选项**
- 导出 Markdown
- 导出 JSON
**新增选项**
- 导出 PDF
- 导出 Excel
### 2.2 PDF 导出
**内容结构**
1. 报告头:目标名称、套件类型、时间范围
2. 汇总统计:总项数、正常/警告/严重数量
3. 异常项列表(置顶):模块、检测项、当前值、阈值、状态
4. 各模块检测明细(可折叠)
**样式要求**
- 保留颜色标记(正常=绿色、警告=黄色、严重=红色)
- 表格样式清晰,适合打印
- 中文支持(使用系统字体)
- 页眉:报告标题 + 目标名称
- 页脚:页码 + 生成时间
**技术选型**
- 使用 `weasyprint` 库(纯 Python,无需外部依赖)
- 复用 `compare_service.py` 的 HTML 导出模板样式
### 2.3 Excel 导出
**内容结构**
- Sheet 1:汇总(目标名称、套件、时间、统计)
- Sheet 2:检测明细(全量检测项表格)
**表格列**
| 列名 | 说明 |
|------|------|
| 模块 | 检测模块名称 |
| 检测项 KEY | 唯一标识 |
| 检测项名称 | 显示名称 |
| 当前值 | 实际检测值 |
| 阈值 | 阈值标准 |
| 状态 | 正常/警告/严重 |
| 类别 | system/service |
**样式要求**
- 表头加粗、冻结首行
- 状态列使用条件格式(正常=绿色背景、警告=黄色、严重=红色)
- 列宽自适应
**技术选型**
- 使用 `openpyxl` 库(已在依赖中)
---
## 3. API 设计
### 3.1 扩展现有导出接口
```
GET /api/service-monitor/reports/{report_id}/export?format={md|json|pdf|excel}
```
**新增 format 参数值**
- `pdf`:返回 PDF 文件
- `excel`:返回 Excel 文件
**响应头**
- PDF:`Content-Type: application/pdf`
- Excel:`Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`
**文件名**
- PDF:`report_{report_id}.pdf`
- Excel:`report_{report_id}.xlsx`
---
## 4. 技术方案
### 4.1 后端实现
**新增函数**
```python
# report_service.py
def export_pdf(report_id: str) -> Optional[bytes]:
"""导出 PDF 格式报告。
Returns:
PDF 二进制数据,报告不存在返回 None
"""
pass
def export_excel(report_id: str) -> Optional[bytes]:
"""导出 Excel 格式报告。
Returns:
Excel 二进制数据,报告不存在返回 None
"""
pass
```
**修改文件**
- `skill/code/web/service_monitor/services/report_service.py`:新增 `export_pdf()``export_excel()` 函数
- `skill/code/web/service_monitor/routes.py``api_export_report()` 增加 `pdf``excel` 格式支持
**新增依赖**
- `weasyprint>=60.0`:PDF 生成(需添加到 requirements.txt)
### 4.2 前端修改
**修改文件**
- `skill/code/web/templates/service_monitor/report.html`:导出菜单新增两个选项
---
## 5. 验收标准
### 5.1 功能验收
- [ ] 报告详情页导出菜单显示"导出 PDF"、"导出 Excel"
- [ ] PDF 导出成功,内容完整
- [ ] PDF 样式清晰,中文显示正常
- [ ] Excel 导出成功,包含两个 Sheet
- [ ] Excel 表格格式正确,状态列有颜色标记
- [ ] Token 免登访问时支持导出 PDF/Excel
### 5.2 边界情况
- [ ] 报告不存在时返回 404 错误
- [ ] 大报告(500+ 检测项)导出不超时
- [ ] 无异常项时 PDF 正常渲染
---
## 6. 时间规划
| 阶段 | 任务 | 预估时间 |
|------|------|---------|
| 阶段一 | PDF 导出实现 | 1.5h |
| 阶段二 | Excel 导出实现 | 1h |
| 阶段三 | 前端集成 + 测试 | 0.5h |
| **合计** | | **3h** |
---
## 7. 相关文档
- `HANDOFF_服务监测.md`:模块交接文档
- `PRD_需求文档_报告对比功能.md`:参考 HTML 导出样式
\ No newline at end of file
...@@ -658,6 +658,7 @@ ...@@ -658,6 +658,7 @@
- **移动端响应式深度优化完成**(新增 480px 断点 + 修正溢出问题)✅ - **移动端响应式深度优化完成**(新增 480px 断点 + 修正溢出问题)✅
- **报告对比功能完成**(对比服务 + API + 页面 + 导出)✅ - **报告对比功能完成**(对比服务 + API + 页面 + 导出)✅
- **异常项趋势告警完成**(连续异常检测 + 告警通知 + 配置页)✅ - **异常项趋势告警完成**(连续异常检测 + 告警通知 + 配置页)✅
- **导出 PDF/Excel 格式完成**(Excel openpyxl + PDF weasyprint/HTML降级)✅
--- ---
...@@ -677,7 +678,7 @@ ...@@ -677,7 +678,7 @@
| ~~P1~~ | ~~移动端响应式深度优化~~ | ✅ 已完成(新增 480px 断点 + 修正溢出) | | ~~P1~~ | ~~移动端响应式深度优化~~ | ✅ 已完成(新增 480px 断点 + 修正溢出) |
| ~~P1~~ | ~~报告对比功能~~ | ✅ 已完成(阶段九) | | ~~P1~~ | ~~报告对比功能~~ | ✅ 已完成(阶段九) |
| ~~P1~~ | ~~异常项趋势告警~~ | ✅ 已完成(阶段九) | | ~~P1~~ | ~~异常项趋势告警~~ | ✅ 已完成(阶段九) |
| P2 | 导出 PDF/Excel 格式 | 待开发 | | ~~P2~~ | ~~导出 PDF/Excel 格式~~ | ✅ 已完成(阶段九) |
| 持续 | 更新 HANDOFF_服务监测文档 | 每次会话结束前更新进度 | | 持续 | 更新 HANDOFF_服务监测文档 | 每次会话结束前更新进度 |
--- ---
...@@ -707,19 +708,22 @@ ...@@ -707,19 +708,22 @@
- `notification.html`:新增"连续异常告警"配置区 - `notification.html`:新增"连续异常告警"配置区
- 配置字段:`alert_on_consecutive.enabled/threshold/alert_level/channels` - 配置字段:`alert_on_consecutive.enabled/threshold/alert_level/channels`
### P2:导出 PDF/Excel 格式 ### ✅ P2:导出 PDF/Excel 格式(已完成)
**需求**:支持导出巡检报告为 PDF 和 Excel 格式 **实现内容**
- `report_service.py``export_excel()` 函数(使用 openpyxl)
**功能点** - Sheet 1:汇总信息(目标、套件、时间、统计)
- 报告详情页导出菜单新增"导出 PDF"、"导出 Excel" - Sheet 2:检测明细(全量表格 + 条件格式)
- PDF:保留样式,适合打印归档 - 状态列颜色标记(正常=绿、警告=黄、严重=红)
- Excel:检测项明细,适合数据分析 - 表头冻结
- `report_service.py``export_pdf()` 函数
**技术方案** - 使用 weasyprint 将 HTML 转 PDF
- PDF:`weasyprint``pdfkit`(wkhtmltopdf) - 降级策略:weasyprint 不可用时返回可打印 HTML
- Excel:`openpyxl``pandas` - `_build_report_html()`:打印友好 HTML 模板
- 复用现有 `report_service.py` 的数据获取逻辑 - @page 分页规则 + 中文字体支持
- `routes.py``api_export_report()` 增加 `pdf``excel` 格式
- `report.html`:导出菜单新增"导出 PDF"、"导出 Excel"
- PDF 检测:通过 `%PDF` 魔数判断,降级时添加打印按钮
--- ---
...@@ -948,6 +952,14 @@ feat(service-monitor): 新增安全合规/中间件检测模块并修复状态 ...@@ -948,6 +952,14 @@ feat(service-monitor): 新增安全合规/中间件检测模块并修复状态
| `templates/service_monitor/report.html` | 新增"对比历史报告"按钮 + 报告选择弹窗 | | `templates/service_monitor/report.html` | 新增"对比历史报告"按钮 + 报告选择弹窗 |
| `templates/service_monitor/notification.html` | 新增"连续异常告警"配置区 | | `templates/service_monitor/notification.html` | 新增"连续异常告警"配置区 |
### 本次修改文件(阶段九-P2:导出 PDF/Excel)
| 文件 | 改动 |
|------|------|
| `service_monitor/services/report_service.py` | 新增 export_excel()、export_pdf()、_build_report_html() |
| `service_monitor/routes.py` | api_export_report() 增加 pdf/excel 格式,重构避免重复代码 |
| `templates/service_monitor/report.html` | 导出菜单新增"导出 PDF"、"导出 Excel" |
### 新增文档 ### 新增文档
| 文件 | 说明 | | 文件 | 说明 |
......
...@@ -410,8 +410,15 @@ def api_batch_delete_reports(): ...@@ -410,8 +410,15 @@ def api_batch_delete_reports():
def api_export_report(report_id): def api_export_report(report_id):
# token 免登导出(只读操作,允许访客) # token 免登导出(只读操作,允许访客)
token = request.args.get('token', '').strip() token = request.args.get('token', '').strip()
if token and report_service.validate_access_token(report_id, token): if not (token and report_service.validate_access_token(report_id, token)):
# 常规登录访问
guard = _require_login_json()
if guard:
return guard
fmt = request.args.get('format', 'md') fmt = request.args.get('format', 'md')
# JSON 格式
if fmt == 'json': if fmt == 'json':
data = report_service.export_json(report_id) data = report_service.export_json(report_id)
if not data: if not data:
...@@ -420,28 +427,42 @@ def api_export_report(report_id): ...@@ -420,28 +427,42 @@ def api_export_report(report_id):
mimetype='application/json') mimetype='application/json')
resp.headers['Content-Disposition'] = f'attachment; filename=report_{report_id}.json' resp.headers['Content-Disposition'] = f'attachment; filename=report_{report_id}.json'
return resp return resp
else:
md = report_service.export_markdown(report_id) # Excel 格式
if md is None: if fmt == 'excel':
data = report_service.export_excel(report_id)
if not data:
return jsonify({"success": False, "error": {"code": 404, "message": "报告不存在"}}), 404 return jsonify({"success": False, "error": {"code": 404, "message": "报告不存在"}}), 404
resp = Response(md, mimetype='text/markdown') resp = Response(data,
resp.headers['Content-Disposition'] = f'attachment; filename=report_{report_id}.md' mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
resp.headers['Content-Disposition'] = f'attachment; filename=report_{report_id}.xlsx'
return resp return resp
# 常规登录访问 # PDF 格式
guard = _require_login_json() if fmt == 'pdf':
if guard: data = report_service.export_pdf(report_id)
return guard
fmt = request.args.get('format', 'md')
if fmt == 'json':
data = report_service.export_json(report_id)
if not data: if not data:
return jsonify({"success": False, "error": {"code": 404, "message": "报告不存在"}}), 404 return jsonify({"success": False, "error": {"code": 404, "message": "报告不存在"}}), 404
resp = Response(json.dumps(data, ensure_ascii=False, indent=2), # weasyprint 可用:返回 PDF;降级:返回可打印 HTML
mimetype='application/json') if data[:4] == b'%PDF':
resp.headers['Content-Disposition'] = f'attachment; filename=report_{report_id}.json' resp = Response(data, mimetype='application/pdf')
return resp resp.headers['Content-Disposition'] = f'attachment; filename=report_{report_id}.pdf'
else: else:
# 降级 HTML(带打印按钮提示)
html_with_hint = (
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
"<style>body{margin:0}</style></head><body>"
"<div style='position:fixed;top:10px;right:10px;z-index:9999;"
"background:#1a73e8;color:#fff;padding:8px 16px;border-radius:6px;"
"font-size:13px;cursor:pointer;font-family:sans-serif'"
" onclick='window.print()'>🖨 打印 / 保存为 PDF</div>"
+ data.decode('utf-8')
+ "</body></html>"
)
resp = Response(html_with_hint, mimetype='text/html')
return resp
# Markdown 格式(默认)
md = report_service.export_markdown(report_id) md = report_service.export_markdown(report_id)
if md is None: if md is None:
return jsonify({"success": False, "error": {"code": 404, "message": "报告不存在"}}), 404 return jsonify({"success": False, "error": {"code": 404, "message": "报告不存在"}}), 404
......
...@@ -221,6 +221,277 @@ def export_markdown(report_id: str) -> Optional[str]: ...@@ -221,6 +221,277 @@ def export_markdown(report_id: str) -> Optional[str]:
return "\n".join(lines) return "\n".join(lines)
def export_excel(report_id: str) -> Optional[bytes]:
"""导出 Excel (XLSX) 格式报告。
Returns:
Excel 二进制数据,报告不存在返回 None
"""
from io import BytesIO
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
r = get_report(report_id)
if not r:
return None
wb = Workbook()
# ---- Sheet 1:汇总 ----
ws_summary = wb.active
ws_summary.title = "汇总"
# 标题样式
title_font = Font(name="Microsoft YaHei", size=14, bold=True, color="1a73e8")
header_font = Font(name="Microsoft YaHei", size=11, bold=True, color="333333")
normal_font = Font(name="Microsoft YaHei", size=11)
header_fill = PatternFill(start_color="f5f5f5", end_color="f5f5f5", fill_type="solid")
thin_border = Border(
left=Side(style="thin", color="d9d9d9"),
right=Side(style="thin", color="d9d9d9"),
top=Side(style="thin", color="d9d9d9"),
bottom=Side(style="thin", color="d9d9d9"),
)
s = r.get("summary", {})
# 标题行
ws_summary.merge_cells("A1:B1")
ws_summary["A1"] = "服务巡检报告"
ws_summary["A1"].font = title_font
ws_summary.row_dimensions[1].height = 30
# 信息行
info_rows = [
("目标", f"{r.get('target_name', '-')}({r.get('target_type', '-')})"),
("套件", "快速巡检" if r.get("suite") == "quick" else "全量巡检"),
("开始时间", (r.get("started_at") or "").replace("T", " ")),
("完成时间", (r.get("finished_at") or "").replace("T", " ")),
("报告ID", r.get("id", "-")),
("总检测项", s.get("total", 0)),
("正常", s.get(NORMAL, 0)),
("警告", s.get(WARNING, 0)),
("严重", s.get(CRITICAL, 0)),
]
for idx, (label, value) in enumerate(info_rows, start=2):
cell_a = ws_summary.cell(row=idx, column=1, value=label)
cell_a.font = header_font
cell_a.fill = header_fill
cell_a.border = thin_border
cell_a.alignment = Alignment(horizontal="right")
cell_b = ws_summary.cell(row=idx, column=2, value=value)
cell_b.font = normal_font
cell_b.border = thin_border
# 状态数字着色
if label == "正常" and isinstance(value, (int, float)):
cell_b.font = Font(name="Microsoft YaHei", size=11, color="389e0d", bold=True)
elif label == "警告" and isinstance(value, (int, float)):
cell_b.font = Font(name="Microsoft YaHei", size=11, color="d48806", bold=True)
elif label == "严重" and isinstance(value, (int, float)):
cell_b.font = Font(name="Microsoft YaHei", size=11, color="cf1322", bold=True)
ws_summary.column_dimensions["A"].width = 14
ws_summary.column_dimensions["B"].width = 40
# ---- Sheet 2:检测明细 ----
ws_detail = wb.create_sheet(title="检测明细")
# 表头
headers = ["模块", "检测项KEY", "检测项名称", "当前值", "阈值", "状态", "类别"]
col_widths = [18, 24, 22, 20, 16, 10, 10]
for col_idx, (header, width) in enumerate(zip(headers, col_widths), start=1):
cell = ws_detail.cell(row=1, column=col_idx, value=header)
cell.font = header_font
cell.fill = header_fill
cell.border = thin_border
cell.alignment = Alignment(horizontal="center")
ws_detail.column_dimensions[cell.column_letter].width = width
# 冻结首行
ws_detail.freeze_panes = "A2"
# 条件格式填充
fill_normal = PatternFill(start_color="f0fff0", end_color="f0fff0", fill_type="solid")
fill_warning = PatternFill(start_color="fffbe6", end_color="fffbe6", fill_type="solid")
fill_critical = PatternFill(start_color="fff0f0", end_color="fff0f0", fill_type="solid")
font_normal = Font(name="Microsoft YaHei", size=10, color="389e0d")
font_warning = Font(name="Microsoft YaHei", size=10, color="d48806", bold=True)
font_critical = Font(name="Microsoft YaHei", size=10, color="cf1322", bold=True)
# 数据行
row_idx = 2
for m in r.get("modules", []):
mod_name = m.get("name", "")
category = m.get("category", "")
for it in m.get("items", []):
status = it.get("status", NORMAL)
row_data = [
mod_name,
it.get("key", ""),
it.get("name", ""),
it.get("value", ""),
it.get("threshold") or "-",
status,
category,
]
for col_idx, val in enumerate(row_data, start=1):
cell = ws_detail.cell(row=row_idx, column=col_idx, value=val)
cell.font = normal_font
cell.border = thin_border
cell.alignment = Alignment(vertical="center", wrap_text=(col_idx == 4))
# 状态列条件格式
status_cell = ws_detail.cell(row=row_idx, column=6)
if status == CRITICAL:
status_cell.font = font_critical
status_cell.fill = fill_critical
elif status == WARNING:
status_cell.font = font_warning
status_cell.fill = fill_warning
elif status == NORMAL:
status_cell.font = font_normal
status_cell.fill = fill_normal
row_idx += 1
# 写入缓冲区
buf = BytesIO()
wb.save(buf)
buf.seek(0)
return buf.read()
def export_pdf(report_id: str) -> Optional[bytes]:
"""导出 PDF 格式报告。
使用 weasyprint 将 HTML 转为 PDF。如 weasyprint 不可用,
则降级返回可打印的 HTML(浏览器打开后可手动打印为 PDF)。
Returns:
PDF 二进制数据,报告不存在返回 None
"""
r = get_report(report_id)
if not r:
return None
html_content = _build_report_html(r)
try:
from weasyprint import HTML as WeasyHTML
from io import BytesIO
buf = BytesIO()
WeasyHTML(string=html_content).write_pdf(buf)
buf.seek(0)
return buf.read()
except ImportError:
logger.info("weasyprint 不可用,降级返回可打印 HTML")
# 降级:返回 HTML(Content-Type 由路由层判断)
return html_content.encode("utf-8")
except Exception as e:
logger.error("PDF 生成失败: %s,降级返回 HTML", e)
return html_content.encode("utf-8")
def _build_report_html(r: dict) -> str:
"""构建报告的打印友好 HTML(用于 PDF 生成或浏览器打印)。"""
s = r.get("summary", {})
# 异常项收集
abnormal = []
for m in r.get("modules", []):
for it in m.get("items", []):
if it.get("status") in (WARNING, CRITICAL):
abnormal.append((m.get("name"), it))
html_parts = [
"<!DOCTYPE html><html><head><meta charset='utf-8'>",
f"<title>巡检报告 - {r.get('target_name', '-')}</title>",
"<style>",
"body{font-family:'Microsoft YaHei','SimHei',sans-serif;max-width:960px;margin:0 auto;padding:20px;color:#333;font-size:12px}",
"h1{color:#1a73e8;border-bottom:2px solid #1a73e8;padding-bottom:6px;font-size:18px}",
"h2{color:#333;margin-top:20px;font-size:14px}",
"h3{color:#555;font-size:13px}",
"table{border-collapse:collapse;width:100%;margin:8px 0;font-size:11px}",
"th,td{border:1px solid #ddd;padding:6px 10px;text-align:left}",
"th{background:#f5f5f5;font-weight:600}",
".stt-normal{color:#389e0d;font-weight:600}",
".stt-warning{color:#d48806;font-weight:600}",
".stt-critical{color:#cf1322;font-weight:600}",
".summary-cards{display:flex;gap:10px;margin:12px 0}",
".card{flex:1;padding:10px;border-radius:6px;text-align:center;border:1px solid #ddd}",
".card .num{font-size:20px;font-weight:700}",
".card .label{font-size:11px;color:#666}",
".card-normal{background:#f0fff0;border-color:#b7eb8f}",
".card-normal .num{color:#389e0d}",
".card-warning{background:#fffbe6;border-color:#ffe58f}",
".card-warning .num{color:#d48806}",
".card-critical{background:#fff0f0;border-color:#ffccc7}",
".card-critical .num{color:#cf1322}",
"@page{size:A4;margin:15mm 12mm;@top-center{content:'服务巡检报告';font-size:9px;color:#999}",
" @bottom-center{content:'第 ' counter(page) ' 页';font-size:9px;color:#999}}",
"@media print{body{padding:0}h1{page-break-after:avoid}table{page-break-inside:auto}tr{page-break-inside:avoid}}",
"</style></head><body>",
# 标题
f"<h1>巡检报告</h1>",
f"<p><b>目标:</b>{r.get('target_name', '-')}({r.get('target_type', '-')})"
f"&nbsp;&nbsp;<b>套件:</b>{'快速巡检' if r.get('suite') == 'quick' else '全量巡检'}</p>",
f"<p><b>时间:</b>{(r.get('started_at') or '').replace('T', ' ')} ~ "
f"{(r.get('finished_at') or '').replace('T', ' ')}</p>",
# 汇总卡片
'<div class="summary-cards">',
f'<div class="card"><div class="num">{s.get("total", 0)}</div><div class="label">总检测项</div></div>',
f'<div class="card card-normal"><div class="num">{s.get(NORMAL, 0)}</div><div class="label">正常</div></div>',
f'<div class="card card-warning"><div class="num">{s.get(WARNING, 0)}</div><div class="label">警告</div></div>',
f'<div class="card card-critical"><div class="num">{s.get(CRITICAL, 0)}</div><div class="label">严重</div></div>',
'</div>',
]
# 异常项
if abnormal:
html_parts.append("<h2>⚠️ 异常项</h2>")
html_parts.append("<table><tr><th>模块</th><th>检测项</th><th>当前值</th><th>阈值</th><th>状态</th></tr>")
for mod_name, it in abnormal:
status = it.get("status", "")
css_cls = "stt-critical" if status == CRITICAL else "stt-warning"
html_parts.append(
f"<tr><td>{mod_name}</td><td>{it.get('name', '-')}</td>"
f"<td>{it.get('value', '-')}</td><td>{it.get('threshold') or '-'}</td>"
f"<td class='{css_cls}'>{status}</td></tr>"
)
html_parts.append("</table>")
# 各模块明细
html_parts.append("<h2>检测明细</h2>")
for m in r.get("modules", []):
html_parts.append(f"<h3>{m.get('name', '')}</h3>")
html_parts.append("<table><tr><th>检测项</th><th>当前值</th><th>阈值</th><th>状态</th></tr>")
for it in m.get("items", []):
status = it.get("status", NORMAL)
if status == CRITICAL:
css_cls = "stt-critical"
elif status == WARNING:
css_cls = "stt-warning"
else:
css_cls = "stt-normal"
html_parts.append(
f"<tr><td>{it.get('name', '-')}</td><td>{it.get('value', '-')}</td>"
f"<td>{it.get('threshold') or '-'}</td><td class='{css_cls}'>{status}</td></tr>"
)
html_parts.append("</table>")
html_parts.append("</body></html>")
return "\n".join(html_parts)
# ============================================================ # ============================================================
# Token 验证 # Token 验证
# ============================================================ # ============================================================
......
...@@ -175,6 +175,8 @@ ...@@ -175,6 +175,8 @@
<div class="export-menu" id="export-menu"> <div class="export-menu" id="export-menu">
<a href="/api/service-monitor/reports/{{ report.id }}/export?format=md{% if token_auth %}&token={{ access_token }}{% endif %}">导出 Markdown</a> <a href="/api/service-monitor/reports/{{ report.id }}/export?format=md{% if token_auth %}&token={{ access_token }}{% endif %}">导出 Markdown</a>
<a href="/api/service-monitor/reports/{{ report.id }}/export?format=json{% if token_auth %}&token={{ access_token }}{% endif %}">导出 JSON</a> <a href="/api/service-monitor/reports/{{ report.id }}/export?format=json{% if token_auth %}&token={{ access_token }}{% endif %}">导出 JSON</a>
<a href="/api/service-monitor/reports/{{ report.id }}/export?format=excel{% if token_auth %}&token={{ access_token }}{% endif %}">导出 Excel</a>
<a href="/api/service-monitor/reports/{{ report.id }}/export?format=pdf{% if token_auth %}&token={{ access_token }}{% endif %}">导出 PDF</a>
</div> </div>
</div> </div>
</div> </div>
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论