From 50e3b5e604f589d82585a0b8f5c866202fcfb565 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 5 Nov 2026 00:35:26 +0000 Subject: [PATCH] fix(desktop): persist library view prefs and clarify cold start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sort lived only in Tauri memory, so a restart always fell back to 最近入库. Save sort and layout into the existing %APPDATA%\GameLibrary\ui.json, and make the WPF writer merge instead of replacing that file. Cold start shows a loading state instead of an empty 0/0 library. Tray left-click restores the window, and game cards expose their titles to UI Automation. Co-authored-by: SlippyChimpMG --- .../Localization/ui.json | 13 +- src/GameLibrary.Desktop/MainWindow.Library.cs | 2 + .../MainWindow.Lifetime.cs | 58 ++++- src/GameLibrary.Desktop/MainWindow.xaml.cs | 3 + src/GameLibrary.Tauri/src-tauri/src/lib.rs | 224 +++++++++++++++++- src/GameLibrary.Tauri/src/App.tsx | 22 +- .../src/components/GameCard.test.tsx | 9 + .../src/components/GameCard.tsx | 6 +- .../src/components/GameGrid.tsx | 3 + .../src/components/LibraryToolbar.test.tsx | 12 + .../src/components/LibraryToolbar.tsx | 5 +- .../src/components/SettingsDialog.tsx | 2 +- .../src/components/Sidebar.test.tsx | 13 + .../src/components/Sidebar.tsx | 7 +- .../src/components/SkeletonGrid.tsx | 12 +- src/GameLibrary.Tauri/src/lib/uiPrefs.test.ts | 46 ++++ src/GameLibrary.Tauri/src/lib/uiPrefs.ts | 59 +++++ 17 files changed, 470 insertions(+), 26 deletions(-) create mode 100644 src/GameLibrary.Tauri/src/lib/uiPrefs.test.ts create mode 100644 src/GameLibrary.Tauri/src/lib/uiPrefs.ts diff --git a/src/GameLibrary.Contracts/Localization/ui.json b/src/GameLibrary.Contracts/Localization/ui.json index 5abd5c6..59bcd14 100644 --- a/src/GameLibrary.Contracts/Localization/ui.json +++ b/src/GameLibrary.Contracts/Localization/ui.json @@ -277,10 +277,10 @@ "Minimize to tray when closing", "閉じるときにトレイへ最小化" ], - "关闭后程序继续在后台运行,可从托盘图标重新打开。": [ - "關閉后程式繼續在后臺運行,可從系統匣圖標重新打開。", - "Keep running in the background. Reopen from the tray icon.", - "バックグラウンドで動作を続けます。トレイから再表示できます。" + "关闭后程序继续在后台运行。单击通知区域的 GameLibrary 图标即可重新打开;右键可选择「显示 GameLibrary」或「退出」。再次启动本程序也会打开正在运行的窗口。": [ + "關閉后程式繼續在后臺運行。單擊通知區域的 GameLibrary 圖標即可重新打開;右鍵可選擇「顯示 GameLibrary」或「結束」。再次啟動本程式也會打開正在運行的窗口。", + "Keep running in the background. Click the GameLibrary icon in the notification area to reopen it; right-click and choose Show GameLibrary or Quit. Starting the app again also opens the running window.", + "閉じたあともバックグラウンドで動作します。通知領域の GameLibrary アイコンをクリックすると再表示できます。右クリックで「GameLibrary を表示」または「終了」。もう一度起動しても、実行中のウィンドウが開きます。" ], "后台核对周期(分钟)": [ "后臺核對周期(分鐘)", @@ -377,6 +377,11 @@ "Loading…", "読み込み中…" ], + "正在加载游戏库…": [ + "正在載入游戲庫…", + "Loading the library…", + "ライブラリを読み込み中…" + ], "正在读取设置…": [ "正在讀取設定…", "Loading settings…", diff --git a/src/GameLibrary.Desktop/MainWindow.Library.cs b/src/GameLibrary.Desktop/MainWindow.Library.cs index c522647..6df7d7c 100644 --- a/src/GameLibrary.Desktop/MainWindow.Library.cs +++ b/src/GameLibrary.Desktop/MainWindow.Library.cs @@ -993,7 +993,9 @@ private async void OnSortSelectorChanged(object sender, SelectionChangedEventArg } _sortMode = newSort; + _persistSort = true; _selectedGameIds.Clear(); + SaveUiPrefs(); await RefreshAsync(); } diff --git a/src/GameLibrary.Desktop/MainWindow.Lifetime.cs b/src/GameLibrary.Desktop/MainWindow.Lifetime.cs index 5a80953..6e4597b 100644 --- a/src/GameLibrary.Desktop/MainWindow.Lifetime.cs +++ b/src/GameLibrary.Desktop/MainWindow.Lifetime.cs @@ -1,6 +1,8 @@ using System.Diagnostics; using System.IO; +using System.Text.Encodings.Web; using System.Text.Json; +using System.Text.Json.Nodes; using System.Windows; using System.Windows.Controls; using System.Windows.Input; @@ -42,7 +44,21 @@ private void SaveUiPrefs() { var path = UiPrefsPath; Directory.CreateDirectory(Path.GetDirectoryName(path)!); - File.WriteAllText(path, JsonSerializer.Serialize(new { leftWidth = LeftColumn.Width.Value })); + // 合并进已有对象。整文件重写会抹掉 Tauri 记下的 sort / layout。 + var root = ReadUiPrefsObject(path); + root["leftWidth"] = JsonValue.Create(LeftColumn.Width.Value); + if (_persistSort && IsPersistedSort(_sortMode)) + { + root["sort"] = JsonValue.Create(_sortMode); + } + + var json = root.ToJsonString(new JsonSerializerOptions + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }); + var temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + File.WriteAllText(temporary, json); + File.Move(temporary, path, overwrite: true); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { @@ -67,13 +83,51 @@ private void LoadUiPrefs() { LeftColumn.Width = new GridLength(parsed); } + + // 文件里没有 sort 时保持构造时的默认值,关窗也不补写,避免把 Tauri 的默认排序改掉。 + if (document.RootElement.TryGetProperty("sort", out var sortElement) + && sortElement.ValueKind == JsonValueKind.String + && sortElement.GetString() is string sort + && IsPersistedSort(sort)) + { + _sortMode = sort; + _persistSort = true; + for (var index = 0; index < SortSelector.Items.Count; index++) + { + if (SortSelector.Items[index] is ComboBoxItem item && item.Tag is string tag && tag == sort) + { + SortSelector.SelectedIndex = index; + break; + } + } + } } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) { - // 记忆缺失走默认 290。 + // 记忆缺失走默认 290 / 名称 A–Z。 } } + private static JsonObject ReadUiPrefsObject(string path) + { + if (!File.Exists(path)) + { + return new JsonObject(); + } + + try + { + return JsonNode.Parse(File.ReadAllText(path)) as JsonObject ?? new JsonObject(); + } + catch (JsonException) + { + return new JsonObject(); + } + } + + private static bool IsPersistedSort(string? sort) => + sort is "title-asc" or "title-desc" or "updated-desc" or "accepted-desc"; + private static string UiPrefsPath => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "GameLibrary", "ui.json"); diff --git a/src/GameLibrary.Desktop/MainWindow.xaml.cs b/src/GameLibrary.Desktop/MainWindow.xaml.cs index 0bd3f4b..89107c2 100644 --- a/src/GameLibrary.Desktop/MainWindow.xaml.cs +++ b/src/GameLibrary.Desktop/MainWindow.xaml.cs @@ -74,6 +74,8 @@ private bool TryGetSetting(string name, out JsonElement value) private string? _preferredGameId; private bool _renderingSidebar; private string _sortMode = "title-asc"; + /// 文件里已经有 sort,或用户改过排序。否则关窗不写 sort,免得覆盖 Tauri 的默认值。 + private bool _persistSort; private bool _updatingTagFilter; private void RefreshLocalizedSelectors() @@ -163,6 +165,7 @@ public MainWindow() Loaded += (_, _) => EnsureTrayIconVisible(); Loaded += async (_, _) => await ConnectAsync(); Closing += SaveSplitterOnClose; + LoadUiPrefs(); } } diff --git a/src/GameLibrary.Tauri/src-tauri/src/lib.rs b/src/GameLibrary.Tauri/src-tauri/src/lib.rs index 047c010..be20e6f 100644 --- a/src/GameLibrary.Tauri/src-tauri/src/lib.rs +++ b/src/GameLibrary.Tauri/src-tauri/src/lib.rs @@ -96,6 +96,107 @@ struct UiPrefsInner { #[derive(Default)] struct UiPrefs(Arc>); +/// 与 WPF `MainWindow.Lifetime` 共用的界面偏好文件:`%APPDATA%\GameLibrary\ui.json`。 +/// 只合并 sort / layout,留下 leftWidth 等已有字段。不另开第二份存储。 +fn ui_prefs_path_from(appdata: Option<&str>, config_home: Option<&str>) -> PathBuf { + let base = appdata + .filter(|value| !value.trim().is_empty()) + .map(PathBuf::from) + .or_else(|| { + config_home + .filter(|value| !value.trim().is_empty()) + .map(PathBuf::from) + }) + .unwrap_or_else(std::env::temp_dir); + base.join("GameLibrary").join("ui.json") +} + +fn ui_prefs_path() -> PathBuf { + let appdata = std::env::var("APPDATA").ok(); + let config_home = std::env::var("XDG_CONFIG_HOME").ok().or_else(|| { + std::env::var("HOME").ok().map(|home| { + Path::new(&home) + .join(".config") + .to_string_lossy() + .into_owned() + }) + }); + ui_prefs_path_from(appdata.as_deref(), config_home.as_deref()) +} + +fn is_persisted_sort(sort: &str) -> bool { + matches!( + sort, + "accepted-desc" | "updated-desc" | "title-asc" | "title-desc" + ) +} + +fn read_ui_prefs_file(path: &Path) -> serde_json::Map { + let Ok(text) = std::fs::read_to_string(path) else { + return serde_json::Map::new(); + }; + let text = text.trim_start_matches('\u{feff}').trim(); + if text.is_empty() { + return serde_json::Map::new(); + } + serde_json::from_str::(text) + .ok() + .and_then(|value| value.as_object().cloned()) + .unwrap_or_default() +} + +fn merge_ui_prefs( + existing: &serde_json::Map, + patch: &serde_json::Map, +) -> Result, String> { + if patch.is_empty() { + return Err("界面偏好没有要写入的字段".into()); + } + let mut next = existing.clone(); + for (key, value) in patch { + match key.as_str() { + "sort" => { + let sort = value.as_str().ok_or("sort 必须是字符串")?; + if !is_persisted_sort(sort) { + return Err("sort 不在允许的取值内".into()); + } + next.insert(key.clone(), Value::String(sort.to_string())); + } + "layout" => { + let layout = value.as_str().ok_or("layout 必须是字符串")?; + if layout != "grid" && layout != "compact" { + return Err("layout 不在允许的取值内".into()); + } + next.insert(key.clone(), Value::String(layout.to_string())); + } + _ => return Err(format!("不支持写入的界面偏好:{key}")), + } + } + Ok(next) +} + +fn replace_file(from: &Path, to: &Path) -> Result<(), String> { + match std::fs::rename(from, to) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + std::fs::remove_file(to).map_err(|err| format!("替换界面偏好失败:{err}"))?; + std::fs::rename(from, to).map_err(|err| format!("替换界面偏好失败:{err}")) + } + Err(error) => Err(format!("写入界面偏好失败:{error}")), + } +} + +fn write_ui_prefs(path: &Path, value: &Value) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("创建界面偏好目录失败:{error}"))?; + } + let body = serde_json::to_vec(value).map_err(|error| format!("序列化界面偏好失败:{error}"))?; + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, body).map_err(|error| format!("写入界面偏好失败:{error}"))?; + replace_file(&tmp, path) +} + enum RequestFailure { /// Host 已明确返回 transportError:只拒绝本次 invoke,进程仍可继续服务。 Transport(String), @@ -511,6 +612,19 @@ fn resolve_data_directory(requested: Option) -> String { }) } +#[tauri::command] +fn load_ui_prefs() -> Value { + Value::Object(read_ui_prefs_file(&ui_prefs_path())) +} + +#[tauri::command] +fn save_ui_prefs(patch: Value) -> Result<(), String> { + let patch = patch.as_object().ok_or("界面偏好必须是对象")?.clone(); + let path = ui_prefs_path(); + let merged = merge_ui_prefs(&read_ui_prefs_file(&path), &patch)?; + write_ui_prefs(&path, &Value::Object(merged)) +} + #[tauri::command] fn set_close_to_tray(prefs: State<'_, UiPrefs>, enabled: bool) -> Result<(), String> { let mut guard = prefs.0.lock().map_err(|_| "UI 偏好状态锁异常")?; @@ -518,14 +632,20 @@ fn set_close_to_tray(prefs: State<'_, UiPrefs>, enabled: bool) -> Result<(), Str Ok(()) } +fn tray_copy(language: &str) -> Option<(&'static str, &'static str, &'static str)> { + Some(match language { + "zh-CN" => ("显示 GameLibrary", "退出", "GameLibrary — 单击打开窗口"), + "zh-TW" => ("顯示 GameLibrary", "結束", "GameLibrary — 單擊打開窗口"), + "en" => ("Show GameLibrary", "Quit", "GameLibrary — Click to open"), + "ja" => ("GameLibrary を表示", "終了", "GameLibrary — クリックで開く"), + _ => return None, + }) +} + #[tauri::command] fn set_ui_language(app: AppHandle, language: &str) -> Result<(), String> { - let (show_text, quit_text) = match language { - "zh-CN" => ("显示 GameLibrary", "退出"), - "zh-TW" => ("顯示 GameLibrary", "結束"), - "en" => ("Show GameLibrary", "Quit"), - "ja" => ("GameLibrary を表示", "終了"), - _ => return Err("Unsupported UI language".into()), + let Some((show_text, quit_text, tooltip)) = tray_copy(language) else { + return Err("Unsupported UI language".into()); }; let show = MenuItem::with_id(&app, "show", show_text, true, None::<&str>) .map_err(|e| e.to_string())?; @@ -534,6 +654,7 @@ fn set_ui_language(app: AppHandle, language: &str) -> Result<(), String> { let menu = Menu::with_items(&app, &[&show, &quit]).map_err(|e| e.to_string())?; if let Some(tray) = app.tray_by_id("main-tray") { tray.set_menu(Some(menu)).map_err(|e| e.to_string())?; + tray.set_tooltip(Some(tooltip)).map_err(|e| e.to_string())?; } Ok(()) } @@ -559,12 +680,16 @@ fn close_to_tray_enabled(prefs: &UiPrefs) -> bool { } fn build_tray(app: &AppHandle) -> tauri::Result<()> { - let show = MenuItem::with_id(app, "show", "显示 GameLibrary", true, None::<&str>)?; - let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?; + let (show_text, quit_text, tooltip) = tray_copy("zh-CN").expect("zh-CN tray copy"); + let show = MenuItem::with_id(app, "show", show_text, true, None::<&str>)?; + let quit = MenuItem::with_id(app, "quit", quit_text, true, None::<&str>)?; let menu = Menu::with_items(app, &[&show, &quit])?; + // 左键单击直接恢复主窗口,右键仍弹出菜单。 + // Tauri 默认左键也弹出菜单,会和下面的 Click 处理抢同一次点击,托盘恢复很难被发现。 let mut builder = TrayIconBuilder::with_id("main-tray") - .tooltip("GameLibrary") + .tooltip(tooltip) + .show_menu_on_left_click(false) .menu(&menu) .on_menu_event(|app, event| match event.id.as_ref() { "show" => show_main_window(app.clone()), @@ -609,6 +734,11 @@ pub fn run() { builder .setup(|app| { + // GL-005:无边框窗口的系统菜单(UIA 里的 MenuBar「系统」)由 Windows / WebView2 提供。 + // 本进程的 Win32 调用都走 *W 宽字符接口,可见标题是 ASCII 的 "GameLibrary", + // 代码里没有把「系统」送进 ANSI 代码页的路径。实机偶发把该菜单读成「ϵͳ」, + // 同一次测试稍后的设置页又能读到正确的「系统」,随附的 tree 本身也是合法 UTF-8。 + // 这是 UIA 对系统菜单名的偶发误读,不改窗口创建,避免牵动无边框拖拽。 #[cfg(target_os = "windows")] if let Some(window) = app.get_webview_window("main") { taskbar_icon::set_taskbar_icon(window.hwnd()?.0) @@ -646,6 +776,8 @@ pub fn run() { bridge_status, resolve_data_directory, open_game_directory, + load_ui_prefs, + save_ui_prefs, set_close_to_tray, set_ui_language, show_main_window @@ -788,3 +920,77 @@ mod bridge_transport_tests { .is_err()); } } + +#[cfg(test)] +mod ui_prefs_tests { + use super::{ + is_persisted_sort, merge_ui_prefs, read_ui_prefs_file, ui_prefs_path_from, write_ui_prefs, + }; + use serde_json::{json, Value}; + use std::fs; + + fn patch(value: Value) -> serde_json::Map { + value.as_object().unwrap().clone() + } + + #[test] + fn windows_appdata_wins_over_xdg_config_home() { + let path = ui_prefs_path_from( + Some(r"C:\Users\su\AppData\Roaming"), + Some("/tmp/should-not-win"), + ); + assert_eq!( + path, + std::path::PathBuf::from(r"C:\Users\su\AppData\Roaming") + .join("GameLibrary") + .join("ui.json") + ); + let fallback = ui_prefs_path_from(Some(" "), Some("/tmp/gamelibrary-config")); + assert_eq!( + fallback, + std::path::PathBuf::from("/tmp/gamelibrary-config/GameLibrary/ui.json") + ); + } + + #[test] + fn sort_whitelist_matches_the_toolbar() { + assert!(is_persisted_sort("title-asc")); + assert!(is_persisted_sort("accepted-desc")); + assert!(!is_persisted_sort("title")); + assert!(!is_persisted_sort("recent")); + } + + #[test] + fn save_merges_sort_and_layout_without_dropping_left_width() { + let dir = std::env::temp_dir().join(format!("gl-ui-prefs-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("ui.json"); + fs::write(&path, b"\xEF\xBB\xBF{\"leftWidth\":290.0}").unwrap(); + + let merged = merge_ui_prefs( + &read_ui_prefs_file(&path), + &patch(json!({ "sort": "title-asc" })), + ) + .unwrap(); + write_ui_prefs(&path, &Value::Object(merged)).unwrap(); + let saved = read_ui_prefs_file(&path); + assert_eq!(saved.get("leftWidth"), Some(&json!(290.0))); + assert_eq!(saved.get("sort"), Some(&json!("title-asc"))); + + let with_layout = merge_ui_prefs(&saved, &patch(json!({ "layout": "compact" }))).unwrap(); + write_ui_prefs(&path, &Value::Object(with_layout)).unwrap(); + let saved = read_ui_prefs_file(&path); + assert_eq!(saved.get("leftWidth"), Some(&json!(290.0))); + assert_eq!(saved.get("sort"), Some(&json!("title-asc"))); + assert_eq!(saved.get("layout"), Some(&json!("compact"))); + + let before = fs::read(&path).unwrap(); + assert!(merge_ui_prefs(&saved, &patch(json!({ "sort": "nope" }))).is_err()); + assert!(merge_ui_prefs(&saved, &patch(json!({ "theme": "dark" }))).is_err()); + assert!(merge_ui_prefs(&saved, &patch(json!({}))).is_err()); + assert_eq!(fs::read(&path).unwrap(), before); + + fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/src/GameLibrary.Tauri/src/App.tsx b/src/GameLibrary.Tauri/src/App.tsx index 08d7de5..8c64bfd 100644 --- a/src/GameLibrary.Tauri/src/App.tsx +++ b/src/GameLibrary.Tauri/src/App.tsx @@ -6,6 +6,7 @@ import { open } from "@tauri-apps/plugin-dialog"; import { describeFailure, operation } from "./lib/api"; import { LaunchProfileSelectionRequired } from "./lib/launchProfiles"; import { emptyFilters, useGamesQuery, useLibrary, type GameFilters } from "./lib/state"; +import { PERSISTED_SORTS, useStoredViewPrefs, type PersistedSort } from "./lib/uiPrefs"; import { resolveSelectedGame } from "./lib/selection"; import type { CandidateItem, GameItem, ViewItem } from "./lib/types"; import { AppShell } from "./components/AppShell"; @@ -38,6 +39,13 @@ function App() { const [section, setSection] = useState
("library"); const [filters, setFilters] = useState(emptyFilters); const [layout, setLayout] = useState<"grid" | "compact">("grid"); + // 排序/布局从 ui.json 回来之前不发 games.list,避免先按默认「最近入库」画一屏再跳走。 + const viewPrefs = useStoredViewPrefs((prefs) => { + const sort = prefs.sort; + if (sort) setFilters((current) => current.sort === sort ? current : { ...current, sort }); + const nextLayout = prefs.layout; + if (nextLayout) setLayout((current) => current === nextLayout ? current : nextLayout); + }); // 详情只持有 gameId(外加列表外游戏的回退快照),渲染时从当前列表取最新对象: // 后台 reload / titleTranslation patch 之后标题、收藏、封面不会停留在打开瞬间的旧值。 const [selectedGameId, setSelectedGameId] = useState(null); @@ -63,7 +71,7 @@ function App() { ); const [translationBusy, setTranslationBusy] = useState(false); - const games = useGamesQuery(filters, library.changeToken, translationBusy, library.gameChanges); + const games = useGamesQuery(filters, library.changeToken, translationBusy || !viewPrefs.ready, library.gameChanges); const selected = useMemo( () => resolveSelectedGame(games.games, selectedGameId, selectedFallback), [games.games, selectedGameId, selectedFallback], @@ -249,6 +257,7 @@ function App() { tags={library.tags} candidateTotal={library.candidateTotal} gameTotal={library.gameTotal} + tagsLoading={library.metaLoading} onCreateTag={() => setPrompt("tag")} /> ); @@ -320,7 +329,10 @@ function App() { search={filters.search} onSearch={(search) => patchFilters({ search, viewId: "" })} sort={filters.sort} - onSort={(sort) => patchFilters({ sort, viewId: "" })} + onSort={(sort) => { + patchFilters({ sort, viewId: "" }); + if ((PERSISTED_SORTS as readonly string[]).includes(sort)) viewPrefs.save({ sort: sort as PersistedSort }); + }} hasCover={filters.hasCover} onHasCover={(hasCover) => patchFilters({ hasCover, viewId: "" })} onRefresh={() => { @@ -328,7 +340,11 @@ function App() { void library.refreshMeta(); }} layout={layout} - onLayout={setLayout} + onLayout={(next) => { + setLayout(next); + viewPrefs.save({ layout: next }); + }} + loading={games.loading} total={games.total} shown={games.games.length} /> diff --git a/src/GameLibrary.Tauri/src/components/GameCard.test.tsx b/src/GameLibrary.Tauri/src/components/GameCard.test.tsx index d0725f7..4ddbee9 100644 --- a/src/GameLibrary.Tauri/src/components/GameCard.test.tsx +++ b/src/GameLibrary.Tauri/src/components/GameCard.test.tsx @@ -91,6 +91,7 @@ it.each(["grid", "compact"] as const)( onLoadMore: vi.fn(), onSelect: vi.fn(), onPlay: vi.fn(), tags: [], onChanged: vi.fn() }; const { rerender } = render(); const titleArea = () => screen.getByText(game.title).parentElement!; + expect(screen.getByRole("listitem", { name: game.title })).toBeInTheDocument(); expect(within(titleArea()).getByText("0 分钟")).toHaveAttribute("title", "累计游玩 0 分钟"); expect(screen.queryByText(game.engine!)).not.toBeInTheDocument(); expect(screen.queryByText(game.kind)).not.toBeInTheDocument(); @@ -105,6 +106,13 @@ it.each(["grid", "compact"] as const)( }, ); +it("shows a loading status instead of an empty library while the first page is in flight", () => { + render(); + expect(screen.getByRole("status")).toHaveTextContent("正在加载游戏库…"); + expect(screen.queryByText("这里还没有游戏")).not.toBeInTheDocument(); +}); + it("uses one stretched detail button while checkbox and launch stay independent", () => { vi.stubGlobal("IntersectionObserver", class { observe() {} disconnect() {} }); const game: GameItem = { @@ -119,6 +127,7 @@ it("uses one stretched detail button while checkbox and launch stay independent" // article 不再是交互控件:没有 role=button / tabIndex / aria-pressed / onClick。 const article = screen.getByRole("article"); + expect(article).toHaveAccessibleName("测试游戏"); expect(article).not.toHaveAttribute("role"); expect(article).not.toHaveAttribute("tabindex"); expect(article).not.toHaveAttribute("aria-pressed"); diff --git a/src/GameLibrary.Tauri/src/components/GameCard.tsx b/src/GameLibrary.Tauri/src/components/GameCard.tsx index 6b1bdcf..464590e 100644 --- a/src/GameLibrary.Tauri/src/components/GameCard.tsx +++ b/src/GameLibrary.Tauri/src/components/GameCard.tsx @@ -57,6 +57,7 @@ function GameCardImpl({ game, selected, checked, selectionDisabled, onToggle, on return (
onSelect(game.gameId)} className="absolute inset-0 z-10 cursor-pointer rounded-lg after:absolute after:inset-0 after:content-[''] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset" - /> + > + {/* 空按钮在 WebView2 的 UIA 里有时丢掉 aria-label。放一份同名文本,名称至少能按标题命中。 */} + {t("{0},查看详情", game.title)} +
{ if (event.target === event.currentTarget && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); onSelect(game.gameId); } }} onClick={() => onSelect(game.gameId)} aria-current={game.gameId === selectedId} @@ -159,6 +161,7 @@ export function GameGrid({
{rows.flatMap(row => games.slice(row * viewport.columns, (row + 1) * viewport.columns).map((game, column) => (
{ + const { rerender } = render( {}} sort="accepted-desc" onSort={() => {}} + hasCover={null} onHasCover={() => {}} onRefresh={() => {}} layout="grid" onLayout={() => {}} + loading total={0} shown={0} />); + expect(screen.getByText("正在加载…")).toBeInTheDocument(); + expect(screen.queryByText("0 / 0")).not.toBeInTheDocument(); + rerender( {}} sort="title-asc" onSort={() => {}} + hasCover={null} onHasCover={() => {}} onRefresh={() => {}} layout="grid" onLayout={() => {}} + total={506} shown={60} />); + expect(screen.getByText("60 / 506")).toBeInTheDocument(); +}); + it("changes the cover filter through all three choices", () => { const changed = vi.fn(); const scroll = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollIntoView"); diff --git a/src/GameLibrary.Tauri/src/components/LibraryToolbar.tsx b/src/GameLibrary.Tauri/src/components/LibraryToolbar.tsx index 3cf8e13..37ff2b4 100644 --- a/src/GameLibrary.Tauri/src/components/LibraryToolbar.tsx +++ b/src/GameLibrary.Tauri/src/components/LibraryToolbar.tsx @@ -36,6 +36,7 @@ export function LibraryToolbar({ onLayout, total, shown, + loading = false, }: { search: string; onSearch: (value: string) => void; @@ -48,6 +49,8 @@ export function LibraryToolbar({ onLayout: (value: "grid" | "compact") => void; total: number; shown: number; + /** 首屏或整库切换尚未返回时为 true。此时 total 仍是 0,不能把「0 / 0」画成空库。 */ + loading?: boolean; }) { return (
@@ -69,7 +72,7 @@ export function LibraryToolbar({
- {shown.toLocaleString()} / {total.toLocaleString()} + {loading ? t("正在加载…") : `${shown.toLocaleString()} / ${total.toLocaleString()}`}