{loadingMore &&
{t("正在加载更多…")}}
diff --git a/src/GameLibrary.Tauri/src/components/Sidebar.test.tsx b/src/GameLibrary.Tauri/src/components/Sidebar.test.tsx
index e5a8cdf..0a6e709 100644
--- a/src/GameLibrary.Tauri/src/components/Sidebar.test.tsx
+++ b/src/GameLibrary.Tauri/src/components/Sidebar.test.tsx
@@ -4,12 +4,26 @@ import { Sidebar, type SidebarProps } from "./Sidebar";
afterEach(cleanup);
+it("uses the candidate total for the pending badge, independent of notification records and loaded page size", () => {
+ const props: SidebarProps = {
+ section: "library", onSectionChange: vi.fn(), viewId: "", onViewChange: vi.fn(),
+ favoriteOnly: false, onFavoriteChange: vi.fn(), playedOnly: false, onPlayedChange: vi.fn(),
+ tagId: "", onTagChange: vi.fn(), tags: [], candidateTotal: 1, gameTotal: 10, onCreateTag: vi.fn(),
+ };
+ const view = render(
);
+ expect(screen.getByRole("button", { name: /^待确认\s*1$/ })).toBeInTheDocument();
+ view.rerender(
);
+ expect(screen.getByRole("button", { name: /^待确认\s*101$/ })).toBeInTheDocument();
+ view.rerender(
);
+ expect(screen.getByRole("button", { name: "待确认" })).toBeInTheDocument();
+});
+
it("gives the saved-folder area to tags and keeps the favorites filter", () => {
render(
);
+ candidateTotal={0} gameTotal={10} onCreateTag={vi.fn()} />);
expect(screen.getByRole("button", { name: "收藏" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /unity/ })).toBeInTheDocument();
expect(screen.queryByText("收藏夹")).not.toBeInTheDocument();
@@ -21,7 +35,7 @@ it("switches played and main libraries, clears filters, and keeps tags within th
section: "library", onSectionChange: vi.fn(), viewId: "", onViewChange: vi.fn(),
favoriteOnly: false, onFavoriteChange: vi.fn(), playedOnly: false, onPlayedChange: vi.fn(),
tagId: "", onTagChange: vi.fn(), tags: [{ tagId: "unity", name: "unity", kind: "engine", revision: 1, gameCount: 2 }],
- candidateTotal: 0, gameTotal: 1, notificationTotal: 0, onCreateTag: vi.fn(),
+ candidateTotal: 0, gameTotal: 1, onCreateTag: vi.fn(),
};
const view = render(
);
expect(screen.getByRole("button", { name: /全部游戏/ })).toHaveAttribute("aria-current", "page");
diff --git a/src/GameLibrary.Tauri/src/components/Sidebar.tsx b/src/GameLibrary.Tauri/src/components/Sidebar.tsx
index b555c86..b52e59b 100644
--- a/src/GameLibrary.Tauri/src/components/Sidebar.tsx
+++ b/src/GameLibrary.Tauri/src/components/Sidebar.tsx
@@ -30,7 +30,6 @@ export interface SidebarProps {
tags: TagItem[];
candidateTotal: number;
gameTotal: number;
- notificationTotal: number;
onCreateTag: () => void;
}
@@ -49,11 +48,9 @@ export function Sidebar(props: SidebarProps) {
tags,
candidateTotal,
gameTotal,
- notificationTotal,
onCreateTag,
} = props;
- const pendingBadge = candidateTotal + notificationTotal;
// 标签定义仍保留在“管理标签”中;侧栏只展示当前库内至少有一个有效游戏的标签,
// 并与管理页同口径按四分类分组(v1.5.5),空分组不占位。
const visibleTags = tags.filter((tag) => (tag.gameCount ?? 0) > 0);
@@ -98,7 +95,7 @@ export function Sidebar(props: SidebarProps) {
}
label={t("待确认")}
- count={pendingBadge}
+ count={candidateTotal}
active={section === "pending"}
onClick={() => onSectionChange("pending")}
/>
diff --git a/src/GameLibrary.Tauri/src/components/UnityTranslationDialog.test.tsx b/src/GameLibrary.Tauri/src/components/UnityTranslationDialog.test.tsx
index 221a1ab..91566ea 100644
--- a/src/GameLibrary.Tauri/src/components/UnityTranslationDialog.test.tsx
+++ b/src/GameLibrary.Tauri/src/components/UnityTranslationDialog.test.tsx
@@ -3,9 +3,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useState } from "react";
import { UnityTranslationDialog } from "./UnityTranslationDialog";
-const api = vi.hoisted(() => ({ operation: vi.fn() }));
+const api = vi.hoisted(() => ({ operation: vi.fn(), visibility: undefined as ((visible: boolean) => void) | undefined }));
vi.mock("../lib/api", () => ({ operation: api.operation, describeFailure: (error: Error) => error.message }));
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn() }));
+vi.mock("../lib/windowVisibility", () => ({ watchWindowVisibility: (changed: (visible: boolean) => void) => {
+ api.visibility = changed;
+ return () => { api.visibility = undefined; };
+} }));
let state = "configured";
const item = { gameId: "game", title: "示例游戏", attemptId: "attempt", state: "configured", provider: "deepseek", profileId: "profile", tagged: true };
@@ -26,7 +30,46 @@ beforeEach(() => {
return { libraryInstanceId: "library", dataEpoch: "epoch", data: { items: [{ ...item, state }], needsSettings: false } };
});
});
-afterEach(cleanup);
+afterEach(() => { cleanup(); vi.useRealTimers(); });
+
+it("reduces idle status polling and resumes once when a hidden window is shown", async () => {
+ vi.useFakeTimers();
+ render(
);
+ await act(async () => {});
+ api.operation.mockClear();
+ await act(async () => { await vi.advanceTimersByTimeAsync(3000); });
+ expect(api.operation).not.toHaveBeenCalled();
+ act(() => api.visibility?.(false));
+ await act(async () => { await vi.advanceTimersByTimeAsync(30000); });
+ expect(api.operation).not.toHaveBeenCalled();
+ await act(async () => api.visibility?.(true));
+ expect(api.operation.mock.calls.filter(([id]) => id === "unity_translation.status")).toHaveLength(1);
+ await act(async () => { await vi.advanceTimersByTimeAsync(2500); });
+ expect(api.operation.mock.calls.filter(([id]) => id === "unity_translation.status")).toHaveLength(1);
+ await act(async () => { await vi.advanceTimersByTimeAsync(27500); });
+ expect(api.operation.mock.calls.filter(([id]) => id === "unity_translation.status")).toHaveLength(2);
+});
+
+it("rechecks immediately when an event arrives during an in-flight idle status read", async () => {
+ vi.useFakeTimers();
+ let finish!: (value: unknown) => void;
+ let waiting = true;
+ const implementation = api.operation.getMockImplementation()!;
+ api.operation.mockImplementation(async (id: string, ...args: unknown[]) => {
+ if (id === "unity_translation.status" && waiting) {
+ waiting = false;
+ return new Promise(resolve => { finish = resolve; });
+ }
+ return implementation(id, ...args);
+ });
+ render(
);
+ await act(async () => {});
+ act(() => { window.dispatchEvent(new Event("gamelibrary-unity-changed")); });
+ await act(async () => finish({ libraryInstanceId: "library", dataEpoch: "epoch",
+ data: { items: [{ ...item, state: "configured" }], needsSettings: false } }));
+ await act(async () => { await vi.advanceTimersByTimeAsync(0); });
+ expect(api.operation.mock.calls.filter(([id]) => id === "unity_translation.status")).toHaveLength(2);
+});
describe("Unity translation launch confirmation", () => {
it("reports manual submission immediately and forwards the backend block without launching", async () => {
diff --git a/src/GameLibrary.Tauri/src/components/UnityTranslationDialog.tsx b/src/GameLibrary.Tauri/src/components/UnityTranslationDialog.tsx
index 08fdfe2..0579cf6 100644
--- a/src/GameLibrary.Tauri/src/components/UnityTranslationDialog.tsx
+++ b/src/GameLibrary.Tauri/src/components/UnityTranslationDialog.tsx
@@ -1,11 +1,15 @@
-import { useCallback, useEffect, useRef, useState } from "react";
+import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { t } from "../lib/i18n";
import { describeFailure, operation } from "../lib/api";
+import { watchWindowVisibility } from "../lib/windowVisibility";
import { Button } from "./ui/button";
import { ConfirmDialog } from "./ui/confirm-dialog";
import { Sheet } from "./ui/sheet";
-import { UnityTranslationSettingsForm, type UnityProviderInput, type UnityProviderSettings } from "./UnityTranslationSettingsForm";
+import type { UnityProviderInput, UnityProviderSettings } from "./UnityTranslationSettingsForm";
+
+const UnityTranslationSettingsForm = lazy(() => import("./UnityTranslationSettingsForm")
+ .then(module => ({ default: module.UnityTranslationSettingsForm })));
export interface UnityTranslationRequest { gameId: string; action: "configure" | "restore" }
export interface UnityTranslationItem {
@@ -40,6 +44,7 @@ export function UnityTranslationDialog({ enabled, settingsOpen, onCloseSettings,
// 按 gameId 记录“已发出 configure、尚未返回”的游戏。同时配置多个游戏时各自独立,
// 不能再用单个 gameId:后一个会覆盖前一个,前一个的乐观行就被服务端旧状态冲掉。
const pending = useRef
>(new Set());
+ const activeJobs = useRef(false);
const callbacks = useRef({ onCloseSettings, onReviewHandled, onRequestHandled, onPlay, onChanged, onNavigate, onStatusChange });
callbacks.current = { onCloseSettings, onReviewHandled, onRequestHandled, onPlay, onChanged, onNavigate, onStatusChange };
@@ -56,6 +61,7 @@ export function UnityTranslationDialog({ enabled, settingsOpen, onCloseSettings,
if (!Array.isArray(result.data?.items) || result.data.items.some(item =>
!item || [item.gameId, item.title, item.attemptId, item.state, item.provider].some(value => typeof value !== "string")))
throw new Error(t("翻译插件状态不完整,请刷新后重试"));
+ activeJobs.current = result.data.items.some(item => ACTIVE.has(item.state));
setStatus(previous => {
// 每个仍在途的 configure 游戏都保留本地乐观行(服务端此刻可能还没有它的条目,
// 或者返回的是配置前的旧状态);其余条目一律采用服务端最新结果。
@@ -84,18 +90,40 @@ export function UnityTranslationDialog({ enabled, settingsOpen, onCloseSettings,
alive.current = true;
let timer: ReturnType | undefined;
let polling = false;
+ let visible = true;
+ let disposed = false;
+ let refreshPending = false;
const poll = async () => {
- if (polling || !alive.current) return;
+ if (polling || disposed || !alive.current || !visible) return;
polling = true;
+ refreshPending = false;
+ let failed = false;
try { await refresh(); }
- catch (cause) { if (alive.current) setError(describeFailure(cause)); }
- finally { polling = false; if (alive.current) timer = setTimeout(() => void poll(), 2500); }
+ catch (cause) { failed = true; if (!disposed && alive.current) setError(describeFailure(cause)); }
+ finally {
+ polling = false;
+ if (!disposed && alive.current && visible)
+ timer = setTimeout(() => void poll(), refreshPending ? 0 : failed || activeJobs.current || pending.current.size ? 2500 : 30000);
+ }
+ };
+ const changed = () => {
+ clearTimeout(timer);
+ if (!visible) return;
+ refreshPending = true;
+ void poll(); void readSettings().catch(() => undefined);
};
- const changed = () => { clearTimeout(timer); void poll(); void readSettings().catch(() => undefined); };
+ const stopVisibility = watchWindowVisibility(value => {
+ visible = value;
+ clearTimeout(timer);
+ if (visible) changed();
+ });
window.addEventListener("gamelibrary-unity-changed", changed);
void readSettings().catch(cause => { if (alive.current) setError(describeFailure(cause)); });
void poll();
- return () => { alive.current = false; clearTimeout(timer); window.removeEventListener("gamelibrary-unity-changed", changed); };
+ return () => {
+ disposed = true; alive.current = false; clearTimeout(timer); stopVisibility();
+ window.removeEventListener("gamelibrary-unity-changed", changed);
+ };
}, [enabled, refresh, readSettings]);
useEffect(() => {
@@ -209,7 +237,7 @@ export function UnityTranslationDialog({ enabled, settingsOpen, onCloseSettings,
{review.map(item =>
{item.title}
)}
-