@@ -69,7 +72,7 @@ export function LibraryToolbar({
- {shown.toLocaleString()} / {total.toLocaleString()}
+ {loading ? t("正在加载…") : `${shown.toLocaleString()} / ${total.toLocaleString()}`}
))}
{visibleTags.length === 0 && (
-
{t("没有正在使用的标签")}
+
+ {t(tagsLoading ? "正在加载…" : "没有正在使用的标签")}
+
)}
diff --git a/src/GameLibrary.Tauri/src/components/SkeletonGrid.tsx b/src/GameLibrary.Tauri/src/components/SkeletonGrid.tsx
index 1a016a2..cbba616 100644
--- a/src/GameLibrary.Tauri/src/components/SkeletonGrid.tsx
+++ b/src/GameLibrary.Tauri/src/components/SkeletonGrid.tsx
@@ -1,13 +1,17 @@
+import { t } from "../lib/i18n";
import { Skeleton } from "./ui/skeleton";
const GRID_CLASS = "grid grid-cols-[repeat(auto-fill,minmax(var(--grid-min-card),1fr))] gap-4";
export function SkeletonGrid({ count = 12 }: { count?: number }) {
return (
-
- {Array.from({ length: count }, (_, index) => (
-
- ))}
+
+
{t("正在加载游戏库…")}
+
+ {Array.from({ length: count }, (_, index) => (
+
+ ))}
+
);
}
diff --git a/src/GameLibrary.Tauri/src/lib/uiPrefs.test.ts b/src/GameLibrary.Tauri/src/lib/uiPrefs.test.ts
new file mode 100644
index 0000000..21f647b
--- /dev/null
+++ b/src/GameLibrary.Tauri/src/lib/uiPrefs.test.ts
@@ -0,0 +1,46 @@
+import { act, renderHook } from "@testing-library/react";
+import { afterEach, expect, it, vi } from "vitest";
+import { parseUiViewPrefs, useStoredViewPrefs } from "./uiPrefs";
+
+const invoke = vi.hoisted(() => vi.fn());
+vi.mock("@tauri-apps/api/core", () => ({ invoke }));
+
+afterEach(() => { invoke.mockReset(); });
+
+it("keeps only the toolbar sort and layout from ui.json", () => {
+ expect(parseUiViewPrefs({ leftWidth: 290, sort: "title-asc", layout: "compact" })).toEqual({
+ sort: "title-asc",
+ layout: "compact",
+ });
+ expect(parseUiViewPrefs({ sort: "title", layout: "list", hasCover: false })).toEqual({});
+ expect(parseUiViewPrefs(null)).toEqual({});
+ expect(parseUiViewPrefs("title-asc")).toEqual({});
+});
+
+it("applies a stored sort once, and does not let a late read erase a newer choice", async () => {
+ let finishLoad!: (value: unknown) => void;
+ invoke.mockImplementation((command: string) => {
+ if (command === "load_ui_prefs") return new Promise((resolve) => { finishLoad = resolve; });
+ return Promise.resolve();
+ });
+ const hydrated = vi.fn();
+ const { result } = renderHook(() => useStoredViewPrefs(hydrated));
+ expect(result.current.ready).toBe(false);
+
+ act(() => result.current.save({ sort: "title-desc" }));
+ expect(invoke).toHaveBeenCalledWith("save_ui_prefs", { patch: { sort: "title-desc" } });
+
+ await act(async () => { finishLoad({ sort: "accepted-desc", leftWidth: 290, layout: "compact" }); });
+ expect(hydrated).not.toHaveBeenCalled();
+ expect(result.current.ready).toBe(true);
+});
+
+it("hydrates sort and layout from ui.json before the library query starts", async () => {
+ invoke.mockResolvedValue({ sort: "title-asc", layout: "compact", leftWidth: 320 });
+ const hydrated = vi.fn();
+ const { result } = renderHook(() => useStoredViewPrefs(hydrated));
+ expect(result.current.ready).toBe(false);
+ await act(async () => { await Promise.resolve(); });
+ expect(hydrated).toHaveBeenCalledWith({ sort: "title-asc", layout: "compact" });
+ expect(result.current.ready).toBe(true);
+});
diff --git a/src/GameLibrary.Tauri/src/lib/uiPrefs.ts b/src/GameLibrary.Tauri/src/lib/uiPrefs.ts
new file mode 100644
index 0000000..3655131
--- /dev/null
+++ b/src/GameLibrary.Tauri/src/lib/uiPrefs.ts
@@ -0,0 +1,59 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { invoke } from "@tauri-apps/api/core";
+
+/**
+ * 视图偏好写在 `%APPDATA%\GameLibrary\ui.json`,与 WPF 的 leftWidth 同一份文件。
+ * Rust 命令做合并写入,避免盖掉另一端已经记下的字段。
+ *
+ * 只持久化「怎么看库」:排序、网格/紧凑。搜索、封面筛选、标签、收藏、已游玩、
+ * 自定义视图都是这一次会话的筛选;重启后把它们恢复回来,会像游戏凭空少了一批。
+ */
+export const PERSISTED_SORTS = ["accepted-desc", "updated-desc", "title-asc", "title-desc"] as const;
+export type PersistedSort = (typeof PERSISTED_SORTS)[number];
+export type PersistedLayout = "grid" | "compact";
+
+export interface ParsedUiViewPrefs {
+ sort?: PersistedSort;
+ layout?: PersistedLayout;
+}
+
+export function parseUiViewPrefs(value: unknown): ParsedUiViewPrefs {
+ if (!value || typeof value !== "object") return {};
+ const record = value as Record
;
+ const sort = typeof record.sort === "string" && (PERSISTED_SORTS as readonly string[]).includes(record.sort)
+ ? record.sort as PersistedSort
+ : undefined;
+ const layout = record.layout === "grid" || record.layout === "compact" ? record.layout : undefined;
+ return { sort, layout };
+}
+
+export function useStoredViewPrefs(onHydrate: (prefs: ParsedUiViewPrefs) => void) {
+ const onHydrateRef = useRef(onHydrate);
+ onHydrateRef.current = onHydrate;
+ const [ready, setReady] = useState(false);
+ const dirty = useRef(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ invoke("load_ui_prefs")
+ .then((value) => {
+ if (cancelled) return;
+ // 用户已经改过排序时,迟到的读盘不能把选择盖回去。
+ if (!dirty.current) onHydrateRef.current(parseUiViewPrefs(value));
+ setReady(true);
+ })
+ .catch(() => {
+ if (!cancelled) setReady(true);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const save = useCallback((patch: ParsedUiViewPrefs) => {
+ dirty.current = true;
+ void invoke("save_ui_prefs", { patch }).catch(() => undefined);
+ }, []);
+
+ return { ready, save };
+}