feat: HTML报告 — report --format html

This commit is contained in:
xiatianxuan committed 2026-06-06 18:59:17 +08:00
1 parent db761d3537
commit 2c7ff07c12
4 files changed
+119 -6

No files matched your search

+103
View File
@@ -2023,3 +2023,106 @@ class BackupManager:
print(t("cmd.report.saved", path=output_file))
return report
def export_html_report(self, output_file: str = "") -> str:
"""生成 HTML 格式备份报告
:param output_file: 输出文件路径,空字符串则返回 HTML 文本
:return: HTML 报告文本
"""
from datetime import datetime
history = self.get_history()
strategies = {
k: v
for k, v in self.data.items()
if k not in (_HISTORY_KEY, "_file_meta", "_chunk_meta")
}
# 计算统计
total_size = sum(h.get("size_mb", 0) for h in history)
total_files = sum(h.get("files_count", 0) for h in history)
# 策略行
strategy_rows = ""
for source, raw in strategies.items():
entry = BackupEntry.from_list(raw)
fmt = entry.compression_format or t("table.cell.default")
skip_patterns = ", ".join(entry.skip_patterns) if entry.skip_patterns else "-"
strategy_rows += f"""<tr>
<td>{source}</td>
<td>{entry.target}</td>
<td>{fmt}</td>
<td>{skip_patterns}</td>
</tr>\n"""
# 历史行(最近 50 条)
rows_html = ""
for h in reversed(history[-50:]):
tag = h.get("tag", "")
tag_html = f'<span class="tag">{tag}</span>' if tag else ""
ratio = h.get("ratio", 0)
ratio_str = f"{ratio:.1f}%" if isinstance(ratio, (int, float)) else "-"
rows_html += f"""<tr>
<td>{h.get("time", "")}</td>
<td>{h.get("source", "")}</td>
<td class="num">{h.get("size_mb", 0):.2f}</td>
<td class="num">{h.get("files_count", 0)}</td>
<td class="num">{ratio_str}</td>
<td>{tag_html}</td>
</tr>\n"""
html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>sbackup {t("cmd.report.title", default="备份报告")}</title>
<style>
body {{ font-family: -apple-system, 'Segoe UI', sans-serif; max-width: 1200px; margin: 0 auto; padding: 20px; background: #f5f5f5; color: #333; }}
h1 {{ color: #2c3e50; border-bottom: 2px solid #3498db; padding-bottom: 10px; }}
h2 {{ color: #2c3e50; margin-top: 30px; }}
.stats {{ display: flex; gap: 20px; flex-wrap: wrap; margin: 20px 0; }}
.stat-box {{ background: #fff; border-radius: 8px; padding: 20px; flex: 1; min-width: 150px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); text-align: center; }}
.stat-box .num {{ font-size: 28px; font-weight: bold; color: #3498db; }}
.stat-box .label {{ font-size: 13px; color: #7f8c8d; margin-top: 5px; }}
table {{ width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }}
th {{ background: #2c3e50; color: #fff; padding: 12px; text-align: left; }}
td {{ padding: 10px 12px; border-bottom: 1px solid #ecf0f1; }}
tr:nth-child(even) {{ background: #f8f9fa; }}
tr:hover {{ background: #e8f4fd; }}
.num {{ text-align: right; font-family: 'Consolas', monospace; }}
.tag {{ display: inline-block; background: #3498db; color: #fff; padding: 2px 8px; border-radius: 12px; font-size: 12px; }}
.footer {{ text-align: center; color: #95a5a6; margin-top: 30px; font-size: 13px; }}
</style>
</head>
<body>
<h1>sbackup {t("cmd.report.title", default="备份报告")}</h1>
<p>{t("cmd.report.generated", default="生成时间")}: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}</p>
<div class="stats">
<div class="stat-box"><div class="num">{len(strategies)}</div><div class="label">{t("cmd.report.strategies", default="备份策略")}</div></div>
<div class="stat-box"><div class="num">{len(history)}</div><div class="label">{t("cmd.report.history", default="历史记录")}</div></div>
<div class="stat-box"><div class="num">{total_size:.1f}</div><div class="label">{t("cmd.report.total_size", default="总大小 (MB)")}</div></div>
<div class="stat-box"><div class="num">{total_files}</div><div class="label">{t("cmd.report.total_files", default="总文件数")}</div></div>
</div>
<h2>{t("cmd.report.strategy_overview", default="策略概览")}</h2>
<table>
<tr><th>{t("cmd.report.source", default="源目录")}</th><th>{t("cmd.report.target", default="目标目录")}</th><th>{t("cmd.report.format", default="格式")}</th><th>{t("cmd.report.skip_patterns", default="忽略模式")}</th></tr>
{strategy_rows}
</table>
<h2>{t("cmd.report.history_header", default="备份历史 (最近50条)")}</h2>
<table>
<tr><th>{t("cmd.report.time", default="时间")}</th><th>{t("cmd.report.source", default="源目录")}</th><th>{t("cmd.report.size", default="大小(MB)")}</th><th>{t("cmd.report.files", default="文件数")}</th><th>{t("cmd.report.ratio", default="压缩率")}</th><th>{t("cmd.report.tag", default="标签")}</th></tr>
{rows_html}
</table>
<div class="footer">{t("cmd.report.generated_by", default="Generated by sbackup")}</div>
</body>
</html>"""
if output_file:
output_dir = os.path.dirname(output_file)
if output_dir:
os.makedirs(output_dir, exist_ok=True)
with open(output_file, "w", encoding="utf-8") as f:
f.write(html)
print(t("cmd.report.saved", path=output_file))
return html
+8
View File
@@ -466,6 +466,10 @@ def get_parser() -> argparse.ArgumentParser:
report_parser.add_argument(
"-o", "--output", default="", help=t("cli.help.report.output")
)
report_parser.add_argument(
"--format", choices=["text", "html"], default="text",
help=t("cli.help.report.format"),
)
search_parser = subparsers.add_parser("search", help=t("cli.help.search"))
search_parser.add_argument("pattern", help=t("cli.help.search.pattern"))
@@ -1039,6 +1043,10 @@ def _handle_remote(args, config, manager) -> int:
def _handle_report(args, config, manager) -> int:
if args.format == "html":
output = args.output or ""
manager.export_html_report(output)
return 0
report = manager.export_report(args.output)
if not args.output:
print(report)
+4 -3
View File
@@ -363,8 +363,8 @@
"err.webdav.not_found": "Remote file not found: {path}",
"err.webdav.delete": "Failed to delete remote file {path}: {error}",
"err.webdav.list": "Failed to list remote files {path}: {error}",
"restore.no_match": "No files matching \"{pattern}\"",
"cli.help.restore.select": "Selective restore: extract only files matching pattern (supports glob wildcards, e.g. \"*.py\" or \"src/main.py\")",
"restore.no_match": 'No files matching "{pattern}"',
"cli.help.restore.select": 'Selective restore: extract only files matching pattern (supports glob wildcards, e.g. "*.py" or "src/main.py")',
"cli.help.schedule.type.schtasks": "Windows Task Scheduler",
"cmd.schedule.schtasks.generated": "Windows Task Scheduler command generated",
"cmd.hook.pre_running": "Running pre-backup hook: {command}",
@@ -399,5 +399,6 @@
"cmd.list.tags.header": "Backup Tags:",
"cmd.list.tags.empty": "No tags found",
"cli.help.dedup": "Enable content-based deduplication across backup strategies (SHA256)",
"cmd.dedup.saved": "Deduplication: skipped {count} duplicate file(s), saved {saved:.2f} MB"
"cmd.dedup.saved": "Deduplication: skipped {count} duplicate file(s), saved {saved:.2f} MB",
"cli.help.report.format": "Report format: text (default) or html"
}
+4 -3
View File
@@ -366,8 +366,8 @@
"err.webdav.not_found": "远程文件不存在: {path}",
"err.webdav.delete": "删除远程文件失败 {path}: {error}",
"err.webdav.list": "列出远程文件失败 {path}: {error}",
"restore.no_match": "没有匹配 \"{pattern}\" 的文件",
"cli.help.restore.select": "选择性还原:仅提取匹配模式的文件(支持 glob 通配符,如 \"*.py\" 或 \"src/main.py\")",
"restore.no_match": '没有匹配 "{pattern}" 的文件',
"cli.help.restore.select": '选择性还原:仅提取匹配模式的文件(支持 glob 通配符,如 "*.py" 或 "src/main.py")',
"cli.help.schedule.type.schtasks": "Windows 计划任务",
"cmd.schedule.schtasks.generated": "Windows 计划任务命令已生成",
"cmd.hook.pre_running": "正在执行前置钩子: {command}",
@@ -402,5 +402,6 @@
"cmd.list.tags.header": "备份标签:",
"cmd.list.tags.empty": "暂无标签",
"cli.help.dedup": "启用跨策略的基于内容哈希(SHA256)文件级去重",
"cmd.dedup.saved": "去重: 跳过 {count} 个重复文件,节省 {saved:.2f} MB"
"cmd.dedup.saved": "去重: 跳过 {count} 个重复文件,节省 {saved:.2f} MB",
"cli.help.report.format": "报告格式: text(文本) 或 html(网页)"
}