diff --git a/sbackup/auto_save.py b/sbackup/auto_save.py index 13da405..b859e43 100644 --- a/sbackup/auto_save.py +++ b/sbackup/auto_save.py @@ -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""" + {source} + {entry.target} + {fmt} + {skip_patterns} + \n""" + + # 历史行(最近 50 条) + rows_html = "" + for h in reversed(history[-50:]): + tag = h.get("tag", "") + tag_html = f'{tag}' if tag else "" + ratio = h.get("ratio", 0) + ratio_str = f"{ratio:.1f}%" if isinstance(ratio, (int, float)) else "-" + rows_html += f""" + {h.get("time", "")} + {h.get("source", "")} + {h.get("size_mb", 0):.2f} + {h.get("files_count", 0)} + {ratio_str} + {tag_html} + \n""" + + html = f""" + + + +sbackup {t("cmd.report.title", default="备份报告")} + + + +

sbackup {t("cmd.report.title", default="备份报告")}

+

{t("cmd.report.generated", default="生成时间")}: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}

+
+
{len(strategies)}
{t("cmd.report.strategies", default="备份策略")}
+
{len(history)}
{t("cmd.report.history", default="历史记录")}
+
{total_size:.1f}
{t("cmd.report.total_size", default="总大小 (MB)")}
+
{total_files}
{t("cmd.report.total_files", default="总文件数")}
+
+

{t("cmd.report.strategy_overview", default="策略概览")}

+ + +{strategy_rows} +
{t("cmd.report.source", default="源目录")}{t("cmd.report.target", default="目标目录")}{t("cmd.report.format", default="格式")}{t("cmd.report.skip_patterns", default="忽略模式")}
+

{t("cmd.report.history_header", default="备份历史 (最近50条)")}

+ + +{rows_html} +
{t("cmd.report.time", default="时间")}{t("cmd.report.source", default="源目录")}{t("cmd.report.size", default="大小(MB)")}{t("cmd.report.files", default="文件数")}{t("cmd.report.ratio", default="压缩率")}{t("cmd.report.tag", default="标签")}
+ + +""" + + 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 diff --git a/sbackup/cli.py b/sbackup/cli.py index ea99e2e..7e387f5 100644 --- a/sbackup/cli.py +++ b/sbackup/cli.py @@ -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) diff --git a/sbackup/locales/en_US.json b/sbackup/locales/en_US.json index 2b88108..a6bdc8d 100644 --- a/sbackup/locales/en_US.json +++ b/sbackup/locales/en_US.json @@ -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" } diff --git a/sbackup/locales/zh_CN.json b/sbackup/locales/zh_CN.json index 75e425a..e9bbd2a 100644 --- a/sbackup/locales/zh_CN.json +++ b/sbackup/locales/zh_CN.json @@ -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(网页)" }