From 8d80a02316c9c9f8d1aa0d48cf33674e908ef4bc Mon Sep 17 00:00:00 2001 From: momen Date: Tue, 19 May 2026 23:48:41 +0800 Subject: [PATCH] fix(account): polish sidebar favorites and sync feedback --- src/App.vue | 33 +++++++++- .../unit/App.account-placeholders.test.ts | 54 +++++++++++++++- .../unit/AppListRestoreModal.test.ts | 38 ++++++++++++ src/__tests__/unit/AppSidebar.account.test.ts | 35 +++++++++++ src/__tests__/unit/CategoryBar.test.ts | 19 ++++++ .../unit/FavoriteFolderManager.test.ts | 40 ++++++++++++ .../unit/FavoriteFolderSelector.test.ts | 37 ++++++++++- src/__tests__/unit/LoginModal.test.ts | 8 +++ src/__tests__/unit/UserManagementView.test.ts | 22 +++++++ src/__tests__/unit/appListSync.test.ts | 5 ++ src/components/AppListRestoreModal.vue | 15 +++-- src/components/AppSidebar.vue | 32 +++++++--- src/components/CategoryBar.vue | 16 +++-- src/components/FavoriteFolderManager.vue | 61 ++++++++++++------- src/components/FavoriteFolderSelector.vue | 16 ++++- src/components/LoginModal.vue | 5 -- src/components/UserManagementView.vue | 19 ++++-- src/modules/appListSync.ts | 4 ++ 18 files changed, 403 insertions(+), 56 deletions(-) create mode 100644 src/__tests__/unit/CategoryBar.test.ts diff --git a/src/App.vue b/src/App.vue index ae925dbf..4e8b4612 100644 --- a/src/App.vue +++ b/src/App.vue @@ -69,6 +69,8 @@ :sync-enabled="installedSyncEnabled ?? false" :loading="downloadedLoading" :error="downloadedError" + :syncing="syncLoading" + :sync-message="syncStatusMessage" @open-forum="openExternalUrl(FLARUM_BASE_URL)" @edit-profile="openExternalUrl(FLARUM_SETTINGS_URL)" @toggle-sync="setInstalledSyncEnabled" @@ -86,6 +88,7 @@ @create-folder="createFavoriteFolderFromPrompt" @remove-selected="removeSelectedFavorites" @install-selected="installResolvedFavorites" + @open-detail="openDetail" /> @@ -338,6 +343,7 @@ import { resolveFavoriteItems } from "./modules/favoriteAvailability"; import { buildSyncItems, cloudItemKey, + cloudPackageKey, mergeInstalledApps, } from "./modules/appListSync"; import type { @@ -444,6 +450,7 @@ const downloadedLoading = ref(false); const downloadedError = ref(""); const downloadedRequestGeneration = ref(0); const syncLoading = ref(false); +const syncStatusMessage = ref(""); const syncRequestGeneration = ref(0); const syncCandidateApps = ref([]); const restoreLoading = ref(false); @@ -576,6 +583,10 @@ const installedCloudKeys = computed( () => new Set(installedApps.value.map((app) => cloudItemKey(app))), ); +const installedCloudPackageKeys = computed( + () => new Set(syncCandidateApps.value.map((app) => cloudPackageKey(app))), +); + // 方法 const syncThemePreference = () => { document.documentElement.classList.toggle("dark", isDarkTheme.value); @@ -1459,6 +1470,13 @@ const clearRestoreState = () => { showRestoreModal.value = false; }; +const clearInstalledSyncState = () => { + syncRequestGeneration.value += 1; + syncLoading.value = false; + syncStatusMessage.value = ""; + syncCandidateApps.value = []; +}; + const nextFavoriteRequestGeneration = (): number => { favoriteRequestGeneration.value += 1; return favoriteRequestGeneration.value; @@ -1489,9 +1507,7 @@ const handleLogout = () => { clearFavoriteState(); clearDownloadedState(); clearRestoreState(); - syncRequestGeneration.value += 1; - syncLoading.value = false; - syncCandidateApps.value = []; + clearInstalledSyncState(); loadInstalledSyncPreference(null); showLoginModal.value = false; showLoginPrompt.value = false; @@ -1514,6 +1530,7 @@ const handleFlarumLogin = async (payload: FlarumLoginPayload) => { flarumToken: flarumToken.token, }); setAuthSession(session); + clearInstalledSyncState(); loadInstalledSyncPreference(session.user.id); showLoginModal.value = false; } catch (error: unknown) { @@ -1599,6 +1616,7 @@ const syncInstalledAppsToAccount = async (): Promise => { const generation = syncRequestGeneration.value + 1; syncRequestGeneration.value = generation; syncLoading.value = true; + syncStatusMessage.value = ""; try { const refreshed = await refreshInstalledSyncCandidates( () => @@ -1619,6 +1637,7 @@ const syncInstalledAppsToAccount = async (): Promise => { return; } downloadedError.value = ""; + syncStatusMessage.value = "同步完成"; } catch (error: unknown) { if ( syncRequestGeneration.value !== generation || @@ -1627,6 +1646,7 @@ const syncInstalledAppsToAccount = async (): Promise => { return; } downloadedError.value = (error as Error)?.message || "同步已安装应用失败"; + syncStatusMessage.value = downloadedError.value; } finally { if ( syncRequestGeneration.value === generation && @@ -1801,6 +1821,13 @@ const addCurrentFavoriteToFolder = async (folderId: number | "default") => { } }; +const createFavoriteFolderFromSelector = async () => { + await createFavoriteFolderFromPrompt(); + const app = favoriteTargetApp.value; + if (!app) return; + showFavoriteSelector.value = true; +}; + const openFavoriteManagement = async () => { if (!requireLogin("请登录后查看我的收藏。")) return; currentView.value = "favorites"; diff --git a/src/__tests__/unit/App.account-placeholders.test.ts b/src/__tests__/unit/App.account-placeholders.test.ts index caadeb39..addc4e7a 100644 --- a/src/__tests__/unit/App.account-placeholders.test.ts +++ b/src/__tests__/unit/App.account-placeholders.test.ts @@ -678,12 +678,64 @@ describe("App account placeholders", () => { await fireEvent.click( await screen.findByRole("button", { name: "立即同步" }), ); - await fireEvent.click(screen.getByRole("button", { name: "立即同步" })); + expect(screen.getByRole("button", { name: "同步中..." })).toBeDisabled(); await waitFor(() => { expect(uploadSyncedAppList).toHaveBeenCalledTimes(1); }); syncUpload.resolve(syncedList([])); + expect(await screen.findByText("同步完成")).toBeTruthy(); + }); + + it("clears manual sync feedback before another user opens account management", async () => { + const syncUpload = createDeferred(); + vi.mocked(uploadSyncedAppList).mockReturnValue(syncUpload.promise); + invoke.mockImplementation(async (channel: string) => { + if (channel === "get-store-filter") return "apm"; + if (channel === "check-spark-available") return false; + if (channel === "check-apm-available") return true; + if (channel === "get-app-version") return "5.0.0"; + if (channel === "get-system-info") return { distro: "deepin 25" }; + if (channel === "list-installed") return { success: true, apps: [] }; + return []; + }); + render(App); + + await fireEvent.click(await screen.findByRole("button", { name: /Momen/ })); + await fireEvent.click(screen.getByText("用户管理")); + await fireEvent.click( + await screen.findByRole("button", { name: "立即同步" }), + ); + + await waitFor(() => { + expect(uploadSyncedAppList).toHaveBeenCalledTimes(1); + }); + syncUpload.resolve(syncedList([])); + expect(await screen.findByText("同步完成")).toBeTruthy(); + + await fireEvent.click( + await screen.findByRole("button", { name: /^Momen$/ }), + ); + if (!screen.queryByText("退出登录")) { + await fireEvent.click( + await screen.findByRole("button", { name: /^Momen$/ }), + ); + } + await fireEvent.click(screen.getByText("退出登录")); + await waitFor(() => { + expect(screen.getByRole("button", { name: "登录 / 注册" })).toBeTruthy(); + }); + + setSecondUserSession(); + const secondUserButton = await screen.findByRole("button", { + name: /^Second User$/, + }); + if (!screen.queryByText("用户管理")) { + await fireEvent.click(secondUserButton); + } + await fireEvent.click(await screen.findByText("用户管理")); + + expect(screen.queryByText("同步完成")).toBeNull(); }); it("does not upload stale sync candidates after logout", async () => { diff --git a/src/__tests__/unit/AppListRestoreModal.test.ts b/src/__tests__/unit/AppListRestoreModal.test.ts index b65098b2..ea2a1c0d 100644 --- a/src/__tests__/unit/AppListRestoreModal.test.ts +++ b/src/__tests__/unit/AppListRestoreModal.test.ts @@ -55,6 +55,22 @@ describe("AppListRestoreModal", () => { expect(screen.getByText("已安装")).toBeTruthy(); }); + it("treats the same package installed from another source as installed", () => { + render(AppListRestoreModal, { + props: { + show: true, + loading: false, + error: "", + items: [createItem({ origin: "spark" })], + installedKeys: new Set(["apm:spark-notes"]), + installedPackageKeys: new Set(["spark-notes"]), + }, + }); + + expect(screen.getByLabelText("Spark Notes")).toBeDisabled(); + expect(screen.getByText("已安装")).toBeTruthy(); + }); + it("removes selected items when they become installed", async () => { const rendered = render(AppListRestoreModal, { props: { @@ -72,4 +88,26 @@ describe("AppListRestoreModal", () => { expect(screen.getByLabelText("Spark Notes")).toBeDisabled(); expect(screen.getByRole("button", { name: "加入安装队列" })).toBeDisabled(); }); + + it("removes selected items when the same package becomes installed from another source", async () => { + const rendered = render(AppListRestoreModal, { + props: { + show: true, + loading: false, + error: "", + items: [createItem({ origin: "spark" })], + installedKeys: new Set(), + installedPackageKeys: new Set(), + }, + }); + + await fireEvent.click(screen.getByLabelText("Spark Notes")); + await rendered.rerender({ + installedPackageKeys: new Set(["spark-notes"]), + }); + + expect(screen.getByLabelText("Spark Notes")).toBeDisabled(); + expect(screen.getByLabelText("Spark Notes")).not.toBeChecked(); + expect(screen.getByRole("button", { name: "加入安装队列" })).toBeDisabled(); + }); }); diff --git a/src/__tests__/unit/AppSidebar.account.test.ts b/src/__tests__/unit/AppSidebar.account.test.ts index a1d79f34..4f25057a 100644 --- a/src/__tests__/unit/AppSidebar.account.test.ts +++ b/src/__tests__/unit/AppSidebar.account.test.ts @@ -45,4 +45,39 @@ describe("AppSidebar account entry", () => { expect(screen.getByText("我的收藏")).toBeTruthy(); expect(screen.getByText("退出登录")).toBeTruthy(); }); + + it("keeps long account names inside the sidebar account entry", () => { + const longUser: SparkUser = { + ...user, + displayName: "SuperEndermanSMSuperEndermanSMSuperEndermanSM", + }; + + const { container } = render(AppSidebar, { + props: { ...baseProps, currentUser: longUser }, + }); + + const accountButton = screen.getByRole("button", { + name: /SuperEndermanSM/, + }); + const textWrapper = accountButton.querySelector( + "[data-testid='account-text']", + ); + const accountName = screen.getByText(longUser.displayName); + + expect(textWrapper?.className).toContain("min-w-0"); + expect(accountName.className).toContain("truncate"); + expect(container.textContent).toContain(longUser.displayName); + }); + + it("closes the quick menu after selecting an account action", async () => { + const rendered = render(AppSidebar, { + props: { ...baseProps, currentUser: user }, + }); + + await fireEvent.click(screen.getByRole("button", { name: /Momen/ })); + await fireEvent.click(screen.getByRole("button", { name: "用户管理" })); + + expect(rendered.emitted("open-user-management")).toHaveLength(1); + expect(screen.queryByRole("button", { name: "用户管理" })).toBeNull(); + }); }); diff --git a/src/__tests__/unit/CategoryBar.test.ts b/src/__tests__/unit/CategoryBar.test.ts new file mode 100644 index 00000000..978a54ca --- /dev/null +++ b/src/__tests__/unit/CategoryBar.test.ts @@ -0,0 +1,19 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +const categoryBarSource = readFileSync( + resolve( + dirname(fileURLToPath(import.meta.url)), + "../../components/CategoryBar.vue", + ), + "utf-8", +); + +describe("CategoryBar", () => { + it("uses the requested blue for the selected category pill", () => { + expect(categoryBarSource.toLowerCase()).toContain("background: #2b7fff;"); + }); +}); diff --git a/src/__tests__/unit/FavoriteFolderManager.test.ts b/src/__tests__/unit/FavoriteFolderManager.test.ts index 2e588ece..63c1c7ab 100644 --- a/src/__tests__/unit/FavoriteFolderManager.test.ts +++ b/src/__tests__/unit/FavoriteFolderManager.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import FavoriteFolderManager from "@/components/FavoriteFolderManager.vue"; import type { + App, FavoriteFolder, ResolvedFavoriteItem, } from "@/global/typedefinition"; @@ -30,6 +31,26 @@ const item: ResolvedFavoriteItem = { selectedApp: null, }; +const selectedApp: App = { + name: "WPS", + pkgname: "wps", + version: "1.0.0", + filename: "wps_1.0.0_amd64.deb", + torrent_address: "", + author: "", + contributor: "", + website: "", + update: "", + size: "110M", + more: "Office suite", + tags: "office", + img_urls: [], + icons: "", + category: "office", + origin: "apm", + currentStatus: "not-installed", +}; + describe("FavoriteFolderManager", () => { it("shows downlisted favorites and emits bulk delete", async () => { const rendered = render(FavoriteFolderManager, { @@ -48,4 +69,23 @@ describe("FavoriteFolderManager", () => { expect(rendered.emitted("remove-selected")?.[0]?.[0]).toEqual([2]); }); + + it("opens a favorite item's app detail from the row content", async () => { + const rendered = render(FavoriteFolderManager, { + props: { + folders: [folder], + activeFolderId: 1, + items: [{ ...item, status: "installable", selectedApp }], + loading: false, + error: "", + }, + }); + + await fireEvent.click( + screen.getByRole("button", { name: "打开 WPS 详情" }), + ); + + expect(rendered.emitted("open-detail")?.[0]?.[0]).toEqual(selectedApp); + expect(rendered.emitted("remove-selected")).toBeUndefined(); + }); }); diff --git a/src/__tests__/unit/FavoriteFolderSelector.test.ts b/src/__tests__/unit/FavoriteFolderSelector.test.ts index 09b4e3ff..663b6075 100644 --- a/src/__tests__/unit/FavoriteFolderSelector.test.ts +++ b/src/__tests__/unit/FavoriteFolderSelector.test.ts @@ -1,7 +1,16 @@ -import { render } from "@testing-library/vue"; +import { fireEvent, render, screen } from "@testing-library/vue"; import { describe, expect, it } from "vitest"; import FavoriteFolderSelector from "@/components/FavoriteFolderSelector.vue"; +import type { FavoriteFolder } from "@/global/typedefinition"; + +const defaultFolder: FavoriteFolder = { + id: 1, + name: "默认收藏夹", + itemCount: 1, + createdAt: "2026-05-18T00:00:00Z", + updatedAt: "2026-05-18T00:00:00Z", +}; describe("FavoriteFolderSelector", () => { it("renders above the app detail modal and its child popups", () => { @@ -16,4 +25,30 @@ describe("FavoriteFolderSelector", () => { expect(overlay?.className).toContain("z-[90]"); }); + + it("does not duplicate the default folder returned by the backend", () => { + render(FavoriteFolderSelector, { + props: { + show: true, + folders: [defaultFolder], + }, + }); + + expect(screen.getAllByRole("button", { name: "默认收藏夹" })).toHaveLength( + 1, + ); + }); + + it("offers creating a folder while selecting favorites", async () => { + const rendered = render(FavoriteFolderSelector, { + props: { + show: true, + folders: [defaultFolder], + }, + }); + + await fireEvent.click(screen.getByRole("button", { name: "新建收藏夹" })); + + expect(rendered.emitted("create-folder")).toHaveLength(1); + }); }); diff --git a/src/__tests__/unit/LoginModal.test.ts b/src/__tests__/unit/LoginModal.test.ts index aa4cb985..4b5ad66d 100644 --- a/src/__tests__/unit/LoginModal.test.ts +++ b/src/__tests__/unit/LoginModal.test.ts @@ -4,6 +4,14 @@ import { describe, expect, it } from "vitest"; import LoginModal from "@/components/LoginModal.vue"; describe("LoginModal", () => { + it("does not show the old password forwarding note", () => { + render(LoginModal, { + props: { show: true, loading: false, error: "" }, + }); + + expect(screen.queryByText(/密码仅直接/)).toBeNull(); + }); + it("emits login credentials and register request", async () => { const rendered = render(LoginModal, { props: { show: true, loading: false, error: "" }, diff --git a/src/__tests__/unit/UserManagementView.test.ts b/src/__tests__/unit/UserManagementView.test.ts index d9811cb9..e0afbade 100644 --- a/src/__tests__/unit/UserManagementView.test.ts +++ b/src/__tests__/unit/UserManagementView.test.ts @@ -35,6 +35,8 @@ describe("UserManagementView", () => { syncEnabled: true, loading: false, error: "", + syncing: false, + syncMessage: "", }, }); @@ -45,4 +47,24 @@ describe("UserManagementView", () => { expect(screen.getByText("WPS")).toBeTruthy(); expect(screen.getByLabelText("自动同步已安装应用")).toBeChecked(); }); + + it("shows manual sync progress and result feedback", async () => { + const { rerender } = render(UserManagementView, { + props: { + user, + downloadedApps: [], + syncEnabled: false, + loading: false, + error: "", + syncing: true, + syncMessage: "", + }, + }); + + expect(screen.getByRole("button", { name: "同步中..." })).toBeDisabled(); + + await rerender({ syncing: false, syncMessage: "同步完成" }); + + expect(screen.getByText("同步完成")).toBeTruthy(); + }); }); diff --git a/src/__tests__/unit/appListSync.test.ts b/src/__tests__/unit/appListSync.test.ts index c567339d..8c23d16c 100644 --- a/src/__tests__/unit/appListSync.test.ts +++ b/src/__tests__/unit/appListSync.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { buildSyncItems, cloudItemKey, + cloudPackageKey, mergeInstalledApps, } from "@/modules/appListSync"; import type { App } from "@/global/typedefinition"; @@ -76,6 +77,10 @@ describe("appListSync", () => { ); }); + it("builds origin-agnostic package keys for cross-source restore detection", () => { + expect(cloudPackageKey({ pkgname: "amber-ce" })).toBe("amber-ce"); + }); + it("merges refreshed apps without mutating active modal origin lists", () => { const current = [createApp({ origin: "apm", pkgname: "apm-installed" })]; const refreshed = [ diff --git a/src/components/AppListRestoreModal.vue b/src/components/AppListRestoreModal.vue index 06feaab8..51aad703 100644 --- a/src/components/AppListRestoreModal.vue +++ b/src/components/AppListRestoreModal.vue @@ -129,7 +129,7 @@ import { computed, ref, watch } from "vue"; import type { SyncedAppListItem } from "@/global/typedefinition"; -import { cloudItemKey } from "@/modules/appListSync"; +import { cloudItemKey, cloudPackageKey } from "@/modules/appListSync"; const props = defineProps<{ show: boolean; @@ -137,6 +137,7 @@ const props = defineProps<{ error: string; items: SyncedAppListItem[]; installedKeys: Set; + installedPackageKeys?: Set; }>(); const emit = defineEmits<{ @@ -147,7 +148,8 @@ const emit = defineEmits<{ const selectedKeys = ref>(new Set()); const isInstalled = (item: SyncedAppListItem): boolean => - props.installedKeys.has(cloudItemKey(item)); + props.installedKeys.has(cloudItemKey(item)) || + Boolean(props.installedPackageKeys?.has(cloudPackageKey(item))); const selectedItems = computed(() => props.items.filter( @@ -157,7 +159,12 @@ const selectedItems = computed(() => const pruneSelectedKeys = (): void => { selectedKeys.value = new Set( - [...selectedKeys.value].filter((key) => !props.installedKeys.has(key)), + [...selectedKeys.value].filter((key) => { + const item = props.items.find( + (candidate) => cloudItemKey(candidate) === key, + ); + return item ? !isInstalled(item) : !props.installedKeys.has(key); + }), ); }; @@ -179,7 +186,7 @@ watch( ); watch( - () => props.installedKeys, + () => [props.installedKeys, props.installedPackageKeys] as const, () => { pruneSelectedKeys(); }, diff --git a/src/components/AppSidebar.vue b/src/components/AppSidebar.vue index d04e6ed1..9853ed02 100644 --- a/src/components/AppSidebar.vue +++ b/src/components/AppSidebar.vue @@ -20,24 +20,24 @@ :alt="accountLabel" class="h-11 w-11 rounded-2xl object-cover shadow-sm ring-1 ring-slate-900/5" /> -
+
{{ currentUser ? currentUser.forumLevel : "Spark Store" }} {{ accountLabel }}
@@ -177,6 +177,22 @@ const handleAccountClick = () => { showAccountMenu.value = !showAccountMenu.value; }; +const emitAccountAction = ( + action: + | "open-user-management" + | "open-favorites" + | "open-forum" + | "edit-profile" + | "logout", +) => { + showAccountMenu.value = false; + if (action === "open-user-management") emit("open-user-management"); + else if (action === "open-favorites") emit("open-favorites"); + else if (action === "open-forum") emit("open-forum"); + else if (action === "edit-profile") emit("edit-profile"); + else emit("logout"); +}; + const toggleTheme = () => { emit("toggle-theme"); }; diff --git a/src/components/CategoryBar.vue b/src/components/CategoryBar.vue index d697b6c0..613ccc30 100644 --- a/src/components/CategoryBar.vue +++ b/src/components/CategoryBar.vue @@ -8,7 +8,9 @@ @click="selectCategory('all')" > 全部 - {{ totalCount }} + {{ + totalCount + }}
@@ -101,22 +105,22 @@ const selectCategory = (category: string) => { } .category-pill-active { - background: #0071e3; + background: #2b7fff; color: #fff; } .category-pill-active:hover { - background: #0066cc; + background: #2b7fff; color: #fff; } .dark .category-pill-active { - background: #409cff; + background: #2b7fff; color: #fff; } .dark .category-pill-active:hover { - background: #0071e3; + background: #2b7fff; color: #fff; } diff --git a/src/components/FavoriteFolderManager.vue b/src/components/FavoriteFolderManager.vue index c39b6fc1..b0468a88 100644 --- a/src/components/FavoriteFolderManager.vue +++ b/src/components/FavoriteFolderManager.vue @@ -48,7 +48,7 @@ > 当前收藏夹暂无应用。 - +
@@ -121,6 +133,7 @@ import { computed, ref, watch } from "vue"; import type { FavoriteAvailabilityStatus, + App, FavoriteFolder, ResolvedFavoriteItem, } from "@/global/typedefinition"; @@ -138,6 +151,7 @@ const emit = defineEmits<{ "create-folder": []; "remove-selected": [itemIds: number[]]; "install-selected": [items: ResolvedFavoriteItem[]]; + "open-detail": [app: App]; }>(); const selectedIds = ref([]); @@ -163,6 +177,11 @@ const selectInstallable = () => { .map((item) => item.item.id); }; +const openFavoriteDetail = (resolved: ResolvedFavoriteItem) => { + if (!resolved.selectedApp) return; + emit("open-detail", resolved.selectedApp); +}; + const statusClass = (status: FavoriteAvailabilityStatus): string => { if (status === "installable") { return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"; diff --git a/src/components/FavoriteFolderSelector.vue b/src/components/FavoriteFolderSelector.vue index 083bcfa1..3eed8a7d 100644 --- a/src/components/FavoriteFolderSelector.vue +++ b/src/components/FavoriteFolderSelector.vue @@ -18,6 +18,7 @@

+