((resolve) => setTimeout(resolve, 0));
+ const contents = Array.from(packReports(result.files));
+ const file = await store.backend.call("save_report_archive", { contents });
+ const message =
+ file === null
+ ? t("history.reportsCancelled")
+ : t("history.reportsDone", { ok: result.succeeded, failed: result.failed });
+ setProgress(message);
+ toast(message, file === null ? "info" : "ok");
+ } catch (error) {
+ setProgress(errorMessage(error));
+ toast(errorMessage(error), "danger");
+ } finally {
+ exportingRef.current = false;
+ if (mounted.current) {
+ setExporting(false);
+ setReport(null);
+ }
+ }
+ };
+
+ useEffect(() => {
+ if (loaded.more || loaded.stale) return;
+ const ids = new Set(loaded.games.map((game) => game.gameId));
+ setChecked((previous) => {
+ const next = new Set([...previous].filter((id) => ids.has(id)));
+ return next.size === previous.size ? previous : next;
+ });
+ }, [loaded.games, loaded.more, loaded.stale]);
+
// Kept for the next visit, the page and the filter with it.
useEffect(() => {
if (loaded.games.length === 0 && loaded.more) return;
@@ -551,6 +702,7 @@ function GameList({
{ value: "ranked", label: t("history.ranked") },
{ value: "normal", label: t("history.normal") },
{ value: "aram", label: t("history.aram") },
+ { value: "custom", label: t("history.custom") },
{ value: "other", label: t("history.other") },
];
const from = (current - 1) * perPage + 1;
@@ -577,7 +729,12 @@ function GameList({
size="sm"
/>
-
+
@@ -627,6 +784,83 @@ function GameList({
+ {view === "games" && (
+
+
+ {t("history.selectedCount", { n: checked.size })}
+
+
+
+ )}
+ {view === "games" && (
+
+
+
+ {reportDirectory ?? t("history.noReportFolder")}
+
+
+
+ )}
+ {progress && (
+
+ {progress}
+
+ )}
+ {report && (
+
+ )}
{view === "champions" ? (
<>
{loaded.more ? (
@@ -697,13 +931,22 @@ function GameList({
{visible.map((game) => (
-
-
- setSelected((open) => (open === game.gameId ? null : game.gameId))
- }
- />
+
+ toggleGame(game.gameId)}
+ />
+
+ setSelected((open) => (open === game.gameId ? null : game.gameId))
+ }
+ />
+
{selected === game.gameId && (
{
+ it("keeps Unicode names safe on Windows and preserves unique game IDs", () => {
+ const game = { startedAt: new Date(2026, 10, 5).getTime(), gameId: 123 } as MatchSummary;
+ expect(reportName(game, '玩家:/\\*?"<>|\n', "阿狸", "胜利")).toBe(
+ "2026-11-05_玩家___________阿狸_胜利_123.png",
+ );
+ expect(reportName(game, "x".repeat(200), "阿狸", "胜利").length).toBeLessThan(100);
+ });
+
+ it("continues after a failed game and packs the successful PNG bytes with Unicode filenames", async () => {
+ const png = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
+ const visited: number[] = [];
+ const result = await collectReports([1, 2, 3], async (id) => {
+ visited.push(id);
+ if (id === 2) throw new Error("detail unavailable");
+ return { name: `玩家_${id}.png`, png };
+ });
+ expect(visited).toEqual([1, 2, 3]);
+ expect(result).toMatchObject({ succeeded: 2, failed: 1 });
+ const files = unzipSync(packReports(result.files));
+ expect(Object.keys(files)).toEqual(["玩家_1.png", "玩家_3.png"]);
+ expect(files["玩家_3.png"]).toEqual(png);
+ });
+
+ it("saves each PNG immediately, counts write failures and continues without keeping PNGs", async () => {
+ const saved: string[] = [];
+ const result = await collectReports(
+ [1, 2, 3],
+ async (id) => ({ name: id + ".png", png: new Uint8Array([id]) }),
+ async (name, png) => {
+ if (png[0] === 2) throw new Error("disk full");
+ saved.push(name);
+ },
+ );
+ expect(saved).toEqual(["1.png", "3.png"]);
+ expect(result).toEqual({ files: {}, succeeded: 2, failed: 1 });
+ });
+
+ it("counts all failures and times out stalled work", async () => {
+ const result = await collectReports([1], () => withDeadline(new Promise(() => {}), 5));
+ expect(result).toEqual({ files: {}, succeeded: 0, failed: 1 });
+ });
+});
diff --git a/app/frontend/src/pages/history/reports.ts b/app/frontend/src/pages/history/reports.ts
new file mode 100644
index 0000000..d5cd859
--- /dev/null
+++ b/app/frontend/src/pages/history/reports.ts
@@ -0,0 +1,84 @@
+import { zipSync } from "fflate";
+import { toBlob } from "html-to-image";
+import { flushSync } from "react-dom";
+import type { MatchSummary } from "@winer/shared";
+
+export function reportName(game: MatchSummary, player: string, champion: string, result: string) {
+ const at = new Date(game.startedAt);
+ const day = [
+ at.getFullYear(),
+ String(at.getMonth() + 1).padStart(2, "0"),
+ String(at.getDate()).padStart(2, "0"),
+ ].join("-");
+ const safe = (text: string) =>
+ Array.from(text.replace(/[<>:"/\\|?*\p{Cc}]/gu, "_"))
+ .slice(0, 48)
+ .join("")
+ .replace(/[. ]+$/u, "");
+ const prefix = `${day}_${safe(player)}_${safe(champion)}_${safe(result)}`;
+ const id = String(game.gameId);
+ return `${Array.from(prefix)
+ .slice(0, 79 - id.length)
+ .join("")}_${id}.png`;
+}
+
+/** A deadline also covers a stalled asset or webfont, so one game cannot stall the batch. */
+export async function withDeadline(work: Promise, milliseconds = 30000): Promise {
+ let timer: ReturnType | undefined;
+ try {
+ return await Promise.race([
+ work,
+ new Promise((_, reject) => {
+ timer = setTimeout(() => reject(new Error("Battle report timed out")), milliseconds);
+ }),
+ ]);
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+export async function captureReport(node: HTMLElement): Promise {
+ // The existing icons are lazy-loaded. Offscreen reports must explicitly start their requests.
+ for (const img of node.querySelectorAll("img")) img.loading = "eager";
+ await document.fonts.ready;
+ await Promise.all(
+ Array.from(node.querySelectorAll("img"), (img) =>
+ img.decode().catch(() => {
+ // Use GameImage's existing fallback, including when decode rejects before onerror fires.
+ flushSync(() => img.dispatchEvent(new Event("error")));
+ }),
+ ),
+ );
+ // Failed client assets have React fallbacks; let those state updates commit before cloning.
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ const blob = await toBlob(node, { pixelRatio: 2, preferredFontFormat: "woff2" });
+ if (!blob) throw new Error("PNG generation failed");
+ return new Uint8Array(await blob.arrayBuffer());
+}
+
+export async function collectReports(
+ games: readonly T[],
+ render: (game: T, index: number) => Promise<{ name: string; png: Uint8Array }>,
+ save?: (name: string, png: Uint8Array) => Promise,
+) {
+ const files: Record = {};
+ let failed = 0;
+ let succeeded = 0;
+ for (const [index, game] of games.entries()) {
+ try {
+ const { name, png } = await render(game, index);
+ if (save) await save(name, png);
+ else files[name] = png;
+ succeeded += 1;
+ } catch (error) {
+ console.warn("Battle report failed", error);
+ failed += 1;
+ }
+ }
+ return { files, failed, succeeded };
+}
+
+// PNG already compresses its pixels. Store entries without recompressing them.
+export function packReports(files: Record): Uint8Array {
+ return zipSync(files, { level: 0 });
+}
diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml
index 08c0315..173d575 100644
--- a/app/src-tauri/Cargo.toml
+++ b/app/src-tauri/Cargo.toml
@@ -41,6 +41,7 @@ tauri-runtime-wry = "=2.11.4"
tauri-utils = "=2.9.3"
[dependencies]
+rfd = "0.17.2"
serde.workspace = true
serde_json.workspace = true
tauri.workspace = true
diff --git a/app/src-tauri/src/assets.rs b/app/src-tauri/src/assets.rs
index 325dd33..ce50b22 100644
--- a/app/src-tauri/src/assets.rs
+++ b/app/src-tauri/src/assets.rs
@@ -27,6 +27,7 @@ pub(crate) fn protocol(
let response = match service.asset(&path).await {
Ok(asset) => Response::builder()
.header(header::CONTENT_TYPE, asset.content_type.as_str())
+ .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
.header(header::CACHE_CONTROL, "max-age=86400, immutable")
.body(asset.bytes.clone())
.unwrap_or_else(|_| status(StatusCode::INTERNAL_SERVER_ERROR)),
diff --git a/app/src-tauri/src/commands.rs b/app/src-tauri/src/commands.rs
index 07dc1a1..6f049bc 100644
--- a/app/src-tauri/src/commands.rs
+++ b/app/src-tauri/src/commands.rs
@@ -116,6 +116,10 @@ pub(crate) fn handler() -> impl Fn(Invoke) -> bool + Send + Sync
delete_player_note,
// History: the games as a file.
save_export,
+ save_report_archive,
+ get_report_directory,
+ choose_report_directory,
+ save_report_image,
// A player's record that failed to load, asked for again.
retry_player,
]
@@ -949,3 +953,54 @@ async fn save_export(
async fn retry_player(app: AppHandle, puuid: String) -> Result<()> {
Ok(service(&app).retry_player(&puuid)?)
}
+
+/// The destination is chosen natively; the frontend cannot supply an arbitrary file path.
+#[tauri::command]
+async fn save_report_archive(contents: Vec) -> Result