From 881aceb78c56f26a3e820e814ba9d010bb5c235c Mon Sep 17 00:00:00 2001
From: momen
Date: Mon, 18 May 2026 22:02:39 +0800
Subject: [PATCH 01/26] feat(account): add client account api foundation
---
src/__tests__/setup.ts | 4 +-
src/__tests__/unit/accountTypes.test.ts | 84 ++++++
src/global/storeConfig.ts | 8 +
src/global/typedefinition.ts | 127 +++++++++
src/modules/backendApi.ts | 336 ++++++++++++++++++++++++
src/vite-env.d.ts | 4 +
6 files changed, 562 insertions(+), 1 deletion(-)
create mode 100644 src/__tests__/unit/accountTypes.test.ts
create mode 100644 src/modules/backendApi.ts
diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts
index 48ed77ed..681eafc6 100644
--- a/src/__tests__/setup.ts
+++ b/src/__tests__/setup.ts
@@ -20,11 +20,13 @@ Object.defineProperty(window, "ipcRenderer", {
invoke: vi.fn(),
removeListener: vi.fn(),
},
+ writable: true,
});
// Mock window.apm_store
Object.defineProperty(window, "apm_store", {
value: {
- arch: "amd64-store",
+ arch: "amd64",
},
+ writable: true,
});
diff --git a/src/__tests__/unit/accountTypes.test.ts b/src/__tests__/unit/accountTypes.test.ts
new file mode 100644
index 00000000..c8983772
--- /dev/null
+++ b/src/__tests__/unit/accountTypes.test.ts
@@ -0,0 +1,84 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ FLARUM_BASE_URL,
+ FLARUM_REGISTER_URL,
+ SPARK_BACKEND_BASE_URL,
+} from "@/global/storeConfig";
+import type {
+ DownloadedAppRecord,
+ FavoriteFolder,
+ FavoriteItem,
+ ReviewTags,
+ SparkUser,
+ SyncedAppListItem,
+} from "@/global/typedefinition";
+
+describe("account shared types", () => {
+ it("exports backend/forum config and account shapes", () => {
+ const user: SparkUser = {
+ id: 1,
+ flarumUserId: "123",
+ username: "momen",
+ displayName: "Momen",
+ avatarUrl: "https://bbs.spark-app.store/avatar.png",
+ forumLevel: "管理员",
+ forumGroups: ["管理员"],
+ };
+ const folder: FavoriteFolder = {
+ id: 1,
+ name: "默认收藏夹",
+ itemCount: 1,
+ createdAt: "2026-05-18T00:00:00Z",
+ updatedAt: "2026-05-18T00:00:00Z",
+ };
+ const favorite: FavoriteItem = {
+ id: 2,
+ appKey: "app:office:wps",
+ pkgname: "wps",
+ name: "WPS",
+ category: "office",
+ iconUrl: "https://example.invalid/wps.png",
+ createdAt: "2026-05-18T00:00:00Z",
+ };
+ const download: DownloadedAppRecord = {
+ id: 3,
+ appKey: "app:office:wps",
+ pkgname: "wps",
+ name: "WPS",
+ category: "office",
+ selectedOrigin: "apm",
+ version: "1.0.0",
+ packageArch: "amd64",
+ downloadedAt: "2026-05-18T00:00:00Z",
+ };
+ const syncItem: SyncedAppListItem = {
+ pkgname: "wps",
+ origin: "apm",
+ category: "office",
+ version: "1.0.0",
+ packageArch: "amd64",
+ appName: "WPS",
+ iconUrl: "https://example.invalid/wps.png",
+ };
+ const tags: ReviewTags = {
+ origin: "apm",
+ category: "office",
+ pkgname: "wps",
+ version: "1.0.0",
+ packageArch: "amd64",
+ clientArch: "amd64",
+ distro: "deepin 25",
+ };
+
+ expect(typeof SPARK_BACKEND_BASE_URL).toBe("string");
+ expect(FLARUM_BASE_URL).toContain("bbs.spark-app.store");
+ expect(FLARUM_REGISTER_URL).toContain("register");
+ expect(user.forumGroups).toEqual(["管理员"]);
+ expect(folder.itemCount).toBe(1);
+ expect(favorite.appKey).toBe("app:office:wps");
+ expect(download.selectedOrigin).toBe("apm");
+ expect(syncItem.origin).toBe("apm");
+ expect(tags.packageArch).toBe("amd64");
+ });
+});
diff --git a/src/global/storeConfig.ts b/src/global/storeConfig.ts
index 8113a59f..3f1b2134 100644
--- a/src/global/storeConfig.ts
+++ b/src/global/storeConfig.ts
@@ -7,6 +7,14 @@ export const APM_STORE_BASE_URL: string =
export const APM_STORE_STATS_BASE_URL: string =
import.meta.env.VITE_APM_STORE_STATS_BASE_URL || "";
+export const SPARK_BACKEND_BASE_URL: string =
+ import.meta.env.VITE_SPARK_BACKEND_BASE_URL || "";
+
+export const FLARUM_BASE_URL = "https://bbs.spark-app.store";
+export const FLARUM_REGISTER_URL = `${FLARUM_BASE_URL}/register`;
+export const FLARUM_PROFILE_URL = `${FLARUM_BASE_URL}/u`;
+export const FLARUM_SETTINGS_URL = `${FLARUM_BASE_URL}/settings`;
+
// 下面的变量用于存储当前应用的信息,其实用在多个组件中
export const currentApp = ref(null);
export const currentAppSparkInstalled = ref(false);
diff --git a/src/global/typedefinition.ts b/src/global/typedefinition.ts
index f2f7ccd6..135848c7 100644
--- a/src/global/typedefinition.ts
+++ b/src/global/typedefinition.ts
@@ -249,3 +249,130 @@ export interface SidebarEntry {
type?: "category" | "search" | "link";
value?: string;
}
+
+export interface SparkUser {
+ id: number;
+ flarumUserId: string;
+ username: string;
+ displayName: string;
+ avatarUrl: string;
+ forumLevel: string;
+ forumGroups: string[];
+}
+
+export interface AuthSession {
+ accessToken: string;
+ tokenType: "bearer";
+ user: SparkUser;
+}
+
+export interface FlarumLoginPayload {
+ identification: string;
+ password: string;
+}
+
+export interface ReviewTags {
+ origin: "spark" | "apm";
+ category: string;
+ pkgname: string;
+ version: string;
+ packageArch: string;
+ clientArch: string;
+ distro: string;
+}
+
+export interface RatingSummary {
+ averageRating: number;
+ reviewCount: number;
+ starCounts: Record;
+}
+
+export interface AppReview {
+ id: number;
+ rating: number;
+ content: string;
+ version: string;
+ packageArch: string;
+ clientArch: string;
+ distro: string;
+ origin: "spark" | "apm";
+ category: string;
+ createdAt: string;
+ updatedAt: string;
+ userDisplayName: string;
+ userAvatarUrl: string;
+}
+
+export interface FavoriteFolder {
+ id: number;
+ name: string;
+ itemCount: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface FavoriteItem {
+ id: number;
+ appKey: string;
+ pkgname: string;
+ name: string;
+ category: string;
+ iconUrl: string;
+ createdAt: string;
+}
+
+export type FavoriteAvailabilityStatus =
+ | "installable"
+ | "installed"
+ | "platform-unavailable"
+ | "arch-unavailable"
+ | "downlisted";
+
+export interface ResolvedFavoriteItem {
+ item: FavoriteItem;
+ status: FavoriteAvailabilityStatus;
+ reason: string;
+ selectedApp: App | null;
+}
+
+export interface DownloadedAppRecord {
+ id: number;
+ appKey: string;
+ pkgname: string;
+ name: string;
+ category: string;
+ selectedOrigin: "spark" | "apm";
+ version: string;
+ packageArch: string;
+ downloadedAt: string;
+}
+
+export interface DownloadedAppList {
+ items: DownloadedAppRecord[];
+ total: number;
+ page: number;
+ pageSize: number;
+}
+
+export interface SyncedAppListItem {
+ id?: number;
+ pkgname: string;
+ origin: "spark" | "apm";
+ category: string;
+ version: string;
+ packageArch: string;
+ appName: string;
+ iconUrl: string;
+}
+
+export interface SyncedAppList {
+ snapshotName: string;
+ clientArch: string;
+ distro: string;
+ updatedAt: string;
+ items: SyncedAppListItem[];
+}
+
+export interface SystemInfo {
+ distro: string;
+}
diff --git a/src/modules/backendApi.ts b/src/modules/backendApi.ts
new file mode 100644
index 00000000..6811c108
--- /dev/null
+++ b/src/modules/backendApi.ts
@@ -0,0 +1,336 @@
+import axios from "axios";
+
+import { SPARK_BACKEND_BASE_URL } from "@/global/storeConfig";
+import type {
+ AppReview,
+ AuthSession,
+ DownloadedAppList,
+ DownloadedAppRecord,
+ FavoriteFolder,
+ FavoriteItem,
+ RatingSummary,
+ ReviewTags,
+ SparkUser,
+ SyncedAppList,
+ SyncedAppListItem,
+} from "@/global/typedefinition";
+
+const backend = axios.create({
+ baseURL: SPARK_BACKEND_BASE_URL,
+ timeout: 10000,
+});
+
+type ApiRecord = Record;
+
+const asApiRecord = (value: unknown): ApiRecord => {
+ if (value && typeof value === "object" && !Array.isArray(value)) {
+ return value as ApiRecord;
+ }
+ return {};
+};
+
+const asApiRecordArray = (value: unknown): ApiRecord[] => {
+ if (!Array.isArray(value)) return [];
+ return value.map(asApiRecord);
+};
+
+export const parseForumGroups = (raw: unknown): string[] => {
+ if (Array.isArray(raw)) {
+ return raw.filter((item): item is string => typeof item === "string");
+ }
+ if (typeof raw !== "string" || raw.length === 0) return [];
+
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ return Array.isArray(parsed)
+ ? parsed.filter((item): item is string => typeof item === "string")
+ : [];
+ } catch {
+ return [];
+ }
+};
+
+const toUser = (raw: ApiRecord): SparkUser => ({
+ id: Number(raw.id),
+ flarumUserId: String(raw.flarum_user_id || ""),
+ username: String(raw.username || ""),
+ displayName: String(raw.display_name || raw.username || ""),
+ avatarUrl: String(raw.avatar_url || ""),
+ forumLevel: String(raw.forum_level || "论坛用户"),
+ forumGroups: parseForumGroups(raw.forum_groups),
+});
+
+const toReview = (raw: ApiRecord): AppReview => ({
+ id: Number(raw.id),
+ rating: Number(raw.rating),
+ content: String(raw.content || ""),
+ version: String(raw.version || "unknown"),
+ packageArch: String(raw.package_arch || "unknown"),
+ clientArch: String(raw.client_arch || "unknown"),
+ distro: String(raw.distro || "unknown"),
+ origin: raw.origin === "spark" ? "spark" : "apm",
+ category: String(raw.category || ""),
+ createdAt: String(raw.created_at || ""),
+ updatedAt: String(raw.updated_at || ""),
+ userDisplayName: String(raw.user_display_name || ""),
+ userAvatarUrl: String(raw.user_avatar_url || ""),
+});
+
+const toFavoriteFolder = (raw: ApiRecord): FavoriteFolder => ({
+ id: Number(raw.id),
+ name: String(raw.name || ""),
+ itemCount: Number(raw.item_count || 0),
+ createdAt: String(raw.created_at || ""),
+ updatedAt: String(raw.updated_at || ""),
+});
+
+const toFavoriteItem = (raw: ApiRecord): FavoriteItem => ({
+ id: Number(raw.id),
+ appKey: String(raw.app_key || ""),
+ pkgname: String(raw.pkgname || ""),
+ name: String(raw.name || ""),
+ category: String(raw.category || ""),
+ iconUrl: String(raw.icon_url || ""),
+ createdAt: String(raw.created_at || ""),
+});
+
+const toDownloadedApp = (raw: ApiRecord): DownloadedAppRecord => ({
+ id: Number(raw.id),
+ appKey: String(raw.app_key || ""),
+ pkgname: String(raw.pkgname || ""),
+ name: String(raw.name || ""),
+ category: String(raw.category || ""),
+ selectedOrigin: raw.selected_origin === "spark" ? "spark" : "apm",
+ version: String(raw.version || ""),
+ packageArch: String(raw.package_arch || "unknown"),
+ downloadedAt: String(raw.downloaded_at || ""),
+});
+
+const toSyncedAppListItem = (raw: ApiRecord): SyncedAppListItem => ({
+ id: raw.id === undefined ? undefined : Number(raw.id),
+ pkgname: String(raw.pkgname || ""),
+ origin: raw.origin === "spark" ? "spark" : "apm",
+ category: String(raw.category || ""),
+ version: String(raw.version || ""),
+ packageArch: String(raw.package_arch || "unknown"),
+ appName: String(raw.app_name || ""),
+ iconUrl: String(raw.icon_url || ""),
+});
+
+const toSyncedAppList = (
+ raw: ApiRecord,
+ fallback?: { clientArch: string; distro: string; items: SyncedAppListItem[] },
+): SyncedAppList => ({
+ snapshotName: String(raw.snapshot_name || "默认列表"),
+ clientArch: String(raw.client_arch || fallback?.clientArch || "unknown"),
+ distro: String(raw.distro || fallback?.distro || "unknown"),
+ updatedAt: String(raw.updated_at || ""),
+ items: raw.items
+ ? asApiRecordArray(raw.items).map(toSyncedAppListItem)
+ : fallback?.items || [],
+});
+
+export const setBackendToken = (token: string | null): void => {
+ if (token) backend.defaults.headers.common.Authorization = `Bearer ${token}`;
+ else delete backend.defaults.headers.common.Authorization;
+};
+
+export const exchangeFlarumToken = async (payload: {
+ flarumUserId: string;
+ flarumToken: string;
+}): Promise => {
+ const response = await backend.post("/auth/flarum", {
+ flarum_user_id: payload.flarumUserId,
+ flarum_token: payload.flarumToken,
+ });
+ const data = asApiRecord(response.data);
+
+ return {
+ accessToken: String(data.access_token || ""),
+ tokenType: "bearer",
+ user: toUser(asApiRecord(data.user)),
+ };
+};
+
+export const fetchMe = async (): Promise => {
+ const response = await backend.get("/me");
+ return toUser(asApiRecord(response.data));
+};
+
+export const fetchRatingSummary = async (
+ appKey: string,
+): Promise => {
+ const response = await backend.get(
+ `/apps/${encodeURIComponent(appKey)}/rating-summary`,
+ );
+ const data = asApiRecord(response.data);
+
+ return {
+ averageRating: Number(data.average_rating || 0),
+ reviewCount: Number(data.review_count || 0),
+ starCounts: Object.fromEntries(
+ Object.entries(asApiRecord(data.star_counts)).map(([key, value]) => [
+ Number(key),
+ Number(value),
+ ]),
+ ),
+ };
+};
+
+export const fetchReviews = async (appKey: string): Promise => {
+ const response = await backend.get(
+ `/apps/${encodeURIComponent(appKey)}/reviews`,
+ );
+ return asApiRecordArray(response.data).map(toReview);
+};
+
+export const submitReview = async (
+ appKey: string,
+ payload: { rating: number; content: string; tags: ReviewTags },
+): Promise => {
+ const response = await backend.post(
+ `/apps/${encodeURIComponent(appKey)}/reviews`,
+ {
+ rating: payload.rating,
+ content: payload.content,
+ tags: {
+ origin: payload.tags.origin,
+ category: payload.tags.category,
+ pkgname: payload.tags.pkgname,
+ version: payload.tags.version,
+ package_arch: payload.tags.packageArch,
+ client_arch: payload.tags.clientArch,
+ distro: payload.tags.distro,
+ },
+ },
+ );
+ return toReview(asApiRecord(response.data));
+};
+
+export const listFavoriteFolders = async (): Promise => {
+ const response = await backend.get("/me/favorite-folders");
+ return asApiRecordArray(response.data).map(toFavoriteFolder);
+};
+
+export const createFavoriteFolder = async (
+ name: string,
+): Promise => {
+ const response = await backend.post("/me/favorite-folders", { name });
+ return toFavoriteFolder(asApiRecord(response.data));
+};
+
+export const renameFavoriteFolder = async (
+ folderId: number,
+ name: string,
+): Promise => {
+ const response = await backend.patch(`/me/favorite-folders/${folderId}`, {
+ name,
+ });
+ return toFavoriteFolder(asApiRecord(response.data));
+};
+
+export const deleteFavoriteFolder = async (folderId: number): Promise => {
+ await backend.delete(`/me/favorite-folders/${folderId}`);
+};
+
+export const listFavoriteItems = async (
+ folderId: number,
+): Promise => {
+ const response = await backend.get(`/me/favorite-folders/${folderId}/items`);
+ return asApiRecordArray(response.data).map(toFavoriteItem);
+};
+
+export const addFavoriteItem = async (
+ folderId: number | "default",
+ item: Omit,
+): Promise => {
+ const response = await backend.post(
+ `/me/favorite-folders/${folderId}/items`,
+ {
+ app_key: item.appKey,
+ pkgname: item.pkgname,
+ name: item.name,
+ category: item.category,
+ icon_url: item.iconUrl,
+ },
+ );
+ return toFavoriteItem(asApiRecord(response.data));
+};
+
+export const deleteFavoriteItem = async (
+ folderId: number,
+ itemId: number,
+): Promise => {
+ await backend.delete(`/me/favorite-folders/${folderId}/items/${itemId}`);
+};
+
+export const bulkDeleteFavoriteItems = async (
+ folderId: number,
+ itemIds: number[],
+): Promise => {
+ const response = await backend.post(
+ `/me/favorite-folders/${folderId}/items/bulk-delete`,
+ { item_ids: itemIds },
+ );
+ return Number(asApiRecord(response.data).deleted_count || 0);
+};
+
+export const listDownloadedApps = async (
+ page = 1,
+ pageSize = 20,
+): Promise => {
+ const response = await backend.get("/me/downloaded-apps", {
+ params: { page, page_size: pageSize },
+ });
+ const data = asApiRecord(response.data);
+
+ return {
+ items: asApiRecordArray(data.items).map(toDownloadedApp),
+ total: Number(data.total || 0),
+ page: Number(data.page || page),
+ pageSize: Number(data.page_size || pageSize),
+ };
+};
+
+export const recordDownloadedApp = async (
+ item: Omit,
+): Promise => {
+ const response = await backend.post("/me/downloaded-apps", {
+ app_key: item.appKey,
+ pkgname: item.pkgname,
+ name: item.name,
+ category: item.category,
+ selected_origin: item.selectedOrigin,
+ version: item.version,
+ package_arch: item.packageArch,
+ });
+ return toDownloadedApp(asApiRecord(response.data));
+};
+
+export const fetchSyncedAppList = async (): Promise => {
+ const response = await backend.get("/me/app-list");
+ if (!response.data) return null;
+ return toSyncedAppList(asApiRecord(response.data));
+};
+
+export const uploadSyncedAppList = async (payload: {
+ clientArch: string;
+ distro: string;
+ items: SyncedAppListItem[];
+}): Promise => {
+ const response = await backend.put("/me/app-list", {
+ client_arch: payload.clientArch,
+ distro: payload.distro,
+ items: payload.items.map((item) => ({
+ pkgname: item.pkgname,
+ origin: item.origin,
+ category: item.category,
+ version: item.version,
+ package_arch: item.packageArch,
+ app_name: item.appName,
+ icon_url: item.iconUrl,
+ })),
+ });
+
+ return toSyncedAppList(asApiRecord(response.data), payload);
+};
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
index 3de6f018..eebbaab2 100644
--- a/src/vite-env.d.ts
+++ b/src/vite-env.d.ts
@@ -10,6 +10,10 @@ declare module "*.vue" {
}
declare global {
+ interface ImportMetaEnv {
+ readonly VITE_SPARK_BACKEND_BASE_URL?: string;
+ }
+
interface Window {
// expose in the `electron/preload/index.ts`
ipcRenderer: IpcRendererFacade;
From c24c88458ce74eabe77582bd4b11eb50cdd3a226 Mon Sep 17 00:00:00 2001
From: momen
Date: Mon, 18 May 2026 22:21:18 +0800
Subject: [PATCH 02/26] fix(account): default backend api base url
---
.env.debug | 1 +
src/__tests__/unit/accountTypes.test.ts | 1 +
src/global/storeConfig.ts | 5 ++++-
3 files changed, 6 insertions(+), 1 deletion(-)
diff --git a/.env.debug b/.env.debug
index 637f41e3..30e7614a 100644
--- a/.env.debug
+++ b/.env.debug
@@ -1,3 +1,4 @@
VITE_APM_STORE_LOCAL_MODE=true
VITE_APM_STORE_BASE_URL=/local_amd64-store
VITE_APM_STORE_STATS_BASE_URL=/local_stats
+VITE_SPARK_BACKEND_BASE_URL=http://127.0.0.1:8000
diff --git a/src/__tests__/unit/accountTypes.test.ts b/src/__tests__/unit/accountTypes.test.ts
index c8983772..09ba85f0 100644
--- a/src/__tests__/unit/accountTypes.test.ts
+++ b/src/__tests__/unit/accountTypes.test.ts
@@ -72,6 +72,7 @@ describe("account shared types", () => {
};
expect(typeof SPARK_BACKEND_BASE_URL).toBe("string");
+ expect(SPARK_BACKEND_BASE_URL).toMatch(/^https?:\/\//);
expect(FLARUM_BASE_URL).toContain("bbs.spark-app.store");
expect(FLARUM_REGISTER_URL).toContain("register");
expect(user.forumGroups).toEqual(["管理员"]);
diff --git a/src/global/storeConfig.ts b/src/global/storeConfig.ts
index 3f1b2134..d5de8b4b 100644
--- a/src/global/storeConfig.ts
+++ b/src/global/storeConfig.ts
@@ -7,8 +7,11 @@ export const APM_STORE_BASE_URL: string =
export const APM_STORE_STATS_BASE_URL: string =
import.meta.env.VITE_APM_STORE_STATS_BASE_URL || "";
+export const DEFAULT_SPARK_BACKEND_BASE_URL = "http://127.0.0.1:8000";
+
export const SPARK_BACKEND_BASE_URL: string =
- import.meta.env.VITE_SPARK_BACKEND_BASE_URL || "";
+ import.meta.env.VITE_SPARK_BACKEND_BASE_URL ||
+ DEFAULT_SPARK_BACKEND_BASE_URL;
export const FLARUM_BASE_URL = "https://bbs.spark-app.store";
export const FLARUM_REGISTER_URL = `${FLARUM_BASE_URL}/register`;
From 63dac217c29c03ec9f6e51994c60a6fb3a1d8ad7 Mon Sep 17 00:00:00 2001
From: momen
Date: Mon, 18 May 2026 22:34:14 +0800
Subject: [PATCH 03/26] feat(account): add forum login and sidebar account
entry
---
src/App.vue | 94 +++++++++++++-
src/__tests__/unit/AppSidebar.account.test.ts | 48 +++++++
src/__tests__/unit/AppSidebar.test.ts | 1 +
src/__tests__/unit/LoginModal.test.ts | 23 ++++
src/__tests__/unit/authState.test.ts | 40 ++++++
src/components/AccountQuickMenu.vue | 78 ++++++++++++
src/components/AppSidebar.vue | 82 +++++++++---
src/components/LoginModal.vue | 120 ++++++++++++++++++
src/components/LoginPromptModal.vue | 58 +++++++++
src/global/authState.ts | 62 +++++++++
src/modules/backendApi.ts | 10 +-
src/modules/flarumAuth.ts | 28 ++++
12 files changed, 621 insertions(+), 23 deletions(-)
create mode 100644 src/__tests__/unit/AppSidebar.account.test.ts
create mode 100644 src/__tests__/unit/LoginModal.test.ts
create mode 100644 src/__tests__/unit/authState.test.ts
create mode 100644 src/components/AccountQuickMenu.vue
create mode 100644 src/components/LoginModal.vue
create mode 100644 src/components/LoginPromptModal.vue
create mode 100644 src/global/authState.ts
create mode 100644 src/modules/flarumAuth.ts
diff --git a/src/App.vue b/src/App.vue
index 1440c1cf..b47b5898 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -24,11 +24,18 @@
:store-filter="storeFilter"
:sidebar-entries="sidebarEntries"
:entry-counts="entryCounts"
+ :current-user="currentUser"
@toggle-theme="toggleTheme"
@select-tab="selectTab"
@close="isSidebarOpen = false"
@list="handleList"
@update="handleUpdate"
+ @request-login="showLoginModal = true"
+ @open-user-management="openUserManagement"
+ @open-favorites="openFavoriteManagement"
+ @open-forum="openExternalUrl(FLARUM_BASE_URL)"
+ @edit-profile="openExternalUrl(FLARUM_SETTINGS_URL)"
+ @logout="logout"
/>
@@ -169,6 +176,23 @@
+
+
+
+
@@ -191,8 +215,13 @@ import UninstallConfirmModal from "./components/UninstallConfirmModal.vue";
import ApmInstallConfirmModal from "./components/ApmInstallConfirmModal.vue";
import AboutModal from "./components/AboutModal.vue";
import SettingsModal from "./components/SettingsModal.vue";
+import LoginModal from "./components/LoginModal.vue";
+import LoginPromptModal from "./components/LoginPromptModal.vue";
import {
APM_STORE_BASE_URL,
+ FLARUM_BASE_URL,
+ FLARUM_REGISTER_URL,
+ FLARUM_SETTINGS_URL,
currentApp,
currentAppSparkInstalled,
currentAppApmInstalled,
@@ -211,6 +240,14 @@ import {
rankAppsBySearch,
} from "./modules/appSearch";
import { handleInstall, handleRetry } from "./modules/processInstall";
+import { exchangeFlarumToken } from "./modules/backendApi";
+import { requestFlarumToken } from "./modules/flarumAuth";
+import {
+ currentUser,
+ isLoggedIn,
+ logout,
+ setAuthSession,
+} from "./global/authState";
import {
getAllowedInstalledOrigin,
getEffectiveStoreFilter,
@@ -226,6 +263,7 @@ import type {
CategoryInfo,
HomeLink,
HomeList,
+ FlarumLoginPayload,
SidebarEntry,
UpdateCenterItem,
} from "./global/typedefinition";
@@ -289,6 +327,11 @@ const showUninstallModal = ref(false);
const uninstallTargetApp: Ref = ref(null);
const showAboutModal = ref(false);
const showSettingsModal = ref(false);
+const showLoginModal = ref(false);
+const loginLoading = ref(false);
+const loginError = ref("");
+const showLoginPrompt = ref(false);
+const loginPromptMessage = ref("请登录星火账号后继续操作。");
const sparkAvailable = ref(false);
const apmAvailable = ref(false);
const sidebarEntries: Ref = ref([]);
@@ -1062,6 +1105,51 @@ const closeSettingsModal = () => {
showSettingsModal.value = false;
};
+const openExternalUrl = (url: string) => {
+ window.open(url, "_blank", "noopener,noreferrer");
+};
+
+const requireLogin = (message: string): boolean => {
+ if (isLoggedIn.value) return true;
+ loginPromptMessage.value = message;
+ showLoginPrompt.value = true;
+ return false;
+};
+
+const openLoginFromPrompt = () => {
+ showLoginPrompt.value = false;
+ showLoginModal.value = true;
+};
+
+const handleFlarumLogin = async (payload: FlarumLoginPayload) => {
+ loginLoading.value = true;
+ loginError.value = "";
+
+ try {
+ const flarumToken = await requestFlarumToken(payload);
+ const session = await exchangeFlarumToken({
+ flarumUserId: flarumToken.userId,
+ flarumToken: flarumToken.token,
+ });
+ setAuthSession(session);
+ showLoginModal.value = false;
+ } catch (error: unknown) {
+ loginError.value = (error as Error)?.message || "登录失败,请稍后重试";
+ } finally {
+ loginLoading.value = false;
+ }
+};
+
+const openUserManagement = () => {
+ if (!requireLogin("请登录后查看和管理账号信息。")) return;
+ showLoginPrompt.value = false;
+};
+
+const openFavoriteManagement = () => {
+ if (!requireLogin("请登录后查看我的收藏。")) return;
+ showLoginPrompt.value = false;
+};
+
// TODO: 目前 APM 商店不能暂停下载
const pauseDownload = (id: DownloadItem) => {
const download = downloads.value.find((d) => d.id === id.id);
@@ -1195,7 +1283,7 @@ const loadSidebarConfig = async () => {
try {
const response = await axiosInstance.get(path);
const data = response.data;
- const entries = Array.isArray(data) ? data : (data.entries || []);
+ const entries = Array.isArray(data) ? data : data.entries || [];
for (const entry of entries) {
if (entry.id && entry.name) {
@@ -1249,9 +1337,7 @@ const loadApps = async (onFirstBatch?: () => void) => {
const path = `/${finalArch}/${category}/applist.json`;
logger.info(`加载分类: ${category} (来源: ${mode})`);
- const categoryApps = await fetchWithRetry(
- path,
- );
+ const categoryApps = await fetchWithRetry(path);
const normalizedApps = (categoryApps || []).map((appJson) => ({
name: appJson.Name,
diff --git a/src/__tests__/unit/AppSidebar.account.test.ts b/src/__tests__/unit/AppSidebar.account.test.ts
new file mode 100644
index 00000000..a1d79f34
--- /dev/null
+++ b/src/__tests__/unit/AppSidebar.account.test.ts
@@ -0,0 +1,48 @@
+import { fireEvent, render, screen } from "@testing-library/vue";
+import { describe, expect, it } from "vitest";
+
+import AppSidebar from "@/components/AppSidebar.vue";
+import type { SparkUser } from "@/global/typedefinition";
+
+const baseProps = {
+ activeTab: "all",
+ categoryCounts: { all: 0 },
+ themeMode: "auto" as const,
+ storeFilter: "both" as const,
+ sparkAvailable: true,
+ apmAvailable: true,
+ sidebarEntries: [],
+ entryCounts: {},
+};
+
+const user: SparkUser = {
+ id: 1,
+ flarumUserId: "123",
+ username: "momen",
+ displayName: "Momen",
+ avatarUrl: "https://bbs.spark-app.store/avatar.png",
+ forumLevel: "管理员",
+ forumGroups: ["管理员"],
+};
+
+describe("AppSidebar account entry", () => {
+ it("prompts login when anonymous", async () => {
+ const rendered = render(AppSidebar, {
+ props: { ...baseProps, currentUser: null },
+ });
+
+ await fireEvent.click(screen.getByRole("button", { name: /登录 \/ 注册/ }));
+
+ expect(rendered.emitted("request-login")).toHaveLength(1);
+ });
+
+ it("opens quick menu for logged-in users", async () => {
+ render(AppSidebar, { props: { ...baseProps, currentUser: user } });
+
+ await fireEvent.click(screen.getByRole("button", { name: /Momen/ }));
+
+ expect(screen.getByText("用户管理")).toBeTruthy();
+ expect(screen.getByText("我的收藏")).toBeTruthy();
+ expect(screen.getByText("退出登录")).toBeTruthy();
+ });
+});
diff --git a/src/__tests__/unit/AppSidebar.test.ts b/src/__tests__/unit/AppSidebar.test.ts
index f861add1..1a25e3bc 100644
--- a/src/__tests__/unit/AppSidebar.test.ts
+++ b/src/__tests__/unit/AppSidebar.test.ts
@@ -16,6 +16,7 @@ const renderSidebar = (
apmAvailable: true,
sidebarEntries: [],
entryCounts: {},
+ currentUser: null,
...overrides,
},
});
diff --git a/src/__tests__/unit/LoginModal.test.ts b/src/__tests__/unit/LoginModal.test.ts
new file mode 100644
index 00000000..d4cc9ddb
--- /dev/null
+++ b/src/__tests__/unit/LoginModal.test.ts
@@ -0,0 +1,23 @@
+import { fireEvent, render, screen } from "@testing-library/vue";
+import { describe, expect, it } from "vitest";
+
+import LoginModal from "@/components/LoginModal.vue";
+
+describe("LoginModal", () => {
+ it("emits login credentials and register request", async () => {
+ const rendered = render(LoginModal, {
+ props: { show: true, loading: false, error: "" },
+ });
+
+ await fireEvent.update(screen.getByLabelText("论坛账号"), "momen");
+ await fireEvent.update(screen.getByLabelText("论坛密码"), "secret");
+ await fireEvent.click(screen.getByRole("button", { name: "登录" }));
+ await fireEvent.click(screen.getByRole("button", { name: "注册账号" }));
+
+ expect(rendered.emitted("login")?.[0]?.[0]).toEqual({
+ identification: "momen",
+ password: "secret",
+ });
+ expect(rendered.emitted("register")).toHaveLength(1);
+ });
+});
diff --git a/src/__tests__/unit/authState.test.ts b/src/__tests__/unit/authState.test.ts
new file mode 100644
index 00000000..e1cc3315
--- /dev/null
+++ b/src/__tests__/unit/authState.test.ts
@@ -0,0 +1,40 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+describe("authState", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ localStorage.clear();
+ });
+
+ it("persists and clears a backend session", async () => {
+ const { authSession, currentUser, isLoggedIn, setAuthSession, logout } =
+ await import("@/global/authState");
+
+ setAuthSession({
+ accessToken: "jwt",
+ tokenType: "bearer",
+ user: {
+ id: 1,
+ flarumUserId: "123",
+ username: "momen",
+ displayName: "Momen",
+ avatarUrl: "https://bbs.spark-app.store/avatar.png",
+ forumLevel: "管理员",
+ forumGroups: ["管理员"],
+ },
+ });
+
+ expect(authSession.value?.accessToken).toBe("jwt");
+ expect(currentUser.value?.displayName).toBe("Momen");
+ expect(isLoggedIn.value).toBe(true);
+ expect(
+ JSON.parse(localStorage.getItem("spark-store-auth") || "{}").accessToken,
+ ).toBe("jwt");
+
+ logout();
+
+ expect(authSession.value).toBeNull();
+ expect(isLoggedIn.value).toBe(false);
+ expect(localStorage.getItem("spark-store-auth")).toBeNull();
+ });
+});
diff --git a/src/components/AccountQuickMenu.vue b/src/components/AccountQuickMenu.vue
new file mode 100644
index 00000000..b7dc12e2
--- /dev/null
+++ b/src/components/AccountQuickMenu.vue
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/AppSidebar.vue b/src/components/AppSidebar.vue
index 2ec3369d..d04e6ed1 100644
--- a/src/components/AppSidebar.vue
+++ b/src/components/AppSidebar.vue
@@ -1,21 +1,44 @@
-
-
-
![Amber PM]()
+
+
+
-
- Spark Store
- 星火应用商店
-
@@ -105,10 +128,11 @@
diff --git a/src/components/LoginPromptModal.vue b/src/components/LoginPromptModal.vue
new file mode 100644
index 00000000..bc656ac2
--- /dev/null
+++ b/src/components/LoginPromptModal.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+ 需要登录
+
+
+ {{ message }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/global/authState.ts b/src/global/authState.ts
new file mode 100644
index 00000000..1751002d
--- /dev/null
+++ b/src/global/authState.ts
@@ -0,0 +1,62 @@
+import { computed, ref } from "vue";
+
+import { setBackendToken } from "@/modules/backendApi";
+import type { AuthSession, SparkUser } from "./typedefinition";
+
+const AUTH_STORAGE_KEY = "spark-store-auth";
+
+const isSparkUser = (value: unknown): value is SparkUser => {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
+ const user = value as Record
;
+ return (
+ typeof user.id === "number" &&
+ typeof user.flarumUserId === "string" &&
+ typeof user.username === "string" &&
+ typeof user.displayName === "string" &&
+ typeof user.avatarUrl === "string" &&
+ typeof user.forumLevel === "string" &&
+ Array.isArray(user.forumGroups) &&
+ user.forumGroups.every((group) => typeof group === "string")
+ );
+};
+
+const isAuthSession = (value: unknown): value is AuthSession => {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
+ const session = value as Record;
+ return (
+ typeof session.accessToken === "string" &&
+ session.accessToken.length > 0 &&
+ session.tokenType === "bearer" &&
+ isSparkUser(session.user)
+ );
+};
+
+const loadStoredSession = (): AuthSession | null => {
+ const raw = localStorage.getItem(AUTH_STORAGE_KEY);
+ if (!raw) return null;
+
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ return isAuthSession(parsed) ? parsed : null;
+ } catch {
+ return null;
+ }
+};
+
+export const authSession = ref(loadStoredSession());
+export const currentUser = computed(() => authSession.value?.user ?? null);
+export const isLoggedIn = computed(() => authSession.value !== null);
+
+setBackendToken(authSession.value?.accessToken ?? null);
+
+export const setAuthSession = (session: AuthSession): void => {
+ authSession.value = session;
+ localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(session));
+ setBackendToken(session.accessToken);
+};
+
+export const logout = (): void => {
+ authSession.value = null;
+ localStorage.removeItem(AUTH_STORAGE_KEY);
+ setBackendToken(null);
+};
diff --git a/src/modules/backendApi.ts b/src/modules/backendApi.ts
index 6811c108..e07eed6f 100644
--- a/src/modules/backendApi.ts
+++ b/src/modules/backendApi.ts
@@ -131,8 +131,14 @@ const toSyncedAppList = (
});
export const setBackendToken = (token: string | null): void => {
- if (token) backend.defaults.headers.common.Authorization = `Bearer ${token}`;
- else delete backend.defaults.headers.common.Authorization;
+ const backendWithOptionalDefaults = backend as typeof backend & {
+ defaults?: { headers?: { common?: Record } };
+ };
+ const commonHeaders = backendWithOptionalDefaults.defaults?.headers?.common;
+ if (!commonHeaders) return;
+
+ if (token) commonHeaders.Authorization = `Bearer ${token}`;
+ else delete commonHeaders.Authorization;
};
export const exchangeFlarumToken = async (payload: {
diff --git a/src/modules/flarumAuth.ts b/src/modules/flarumAuth.ts
new file mode 100644
index 00000000..aa0cde6a
--- /dev/null
+++ b/src/modules/flarumAuth.ts
@@ -0,0 +1,28 @@
+import axios from "axios";
+
+import { FLARUM_BASE_URL } from "@/global/storeConfig";
+import type { FlarumLoginPayload } from "@/global/typedefinition";
+
+type FlarumTokenResponse = {
+ token: string;
+ userId: string;
+};
+
+const asRecord = (value: unknown): Record => {
+ if (value && typeof value === "object" && !Array.isArray(value)) {
+ return value as Record;
+ }
+ return {};
+};
+
+export const requestFlarumToken = async (
+ payload: FlarumLoginPayload,
+): Promise => {
+ const response = await axios.post(`${FLARUM_BASE_URL}/api/token`, payload);
+ const data = asRecord(response.data);
+
+ return {
+ token: String(data.token || ""),
+ userId: String(data.userId || data.user_id || ""),
+ };
+};
From 62081fb0ad6f7c16662ba830057a312374d33612 Mon Sep 17 00:00:00 2001
From: momen
Date: Mon, 18 May 2026 22:39:00 +0800
Subject: [PATCH 04/26] fix(account): trim forum login password
---
src/__tests__/unit/LoginModal.test.ts | 4 ++--
src/components/LoginModal.vue | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/__tests__/unit/LoginModal.test.ts b/src/__tests__/unit/LoginModal.test.ts
index d4cc9ddb..aa4cb985 100644
--- a/src/__tests__/unit/LoginModal.test.ts
+++ b/src/__tests__/unit/LoginModal.test.ts
@@ -9,8 +9,8 @@ describe("LoginModal", () => {
props: { show: true, loading: false, error: "" },
});
- await fireEvent.update(screen.getByLabelText("论坛账号"), "momen");
- await fireEvent.update(screen.getByLabelText("论坛密码"), "secret");
+ await fireEvent.update(screen.getByLabelText("论坛账号"), " momen ");
+ await fireEvent.update(screen.getByLabelText("论坛密码"), " secret ");
await fireEvent.click(screen.getByRole("button", { name: "登录" }));
await fireEvent.click(screen.getByRole("button", { name: "注册账号" }));
diff --git a/src/components/LoginModal.vue b/src/components/LoginModal.vue
index 077dccdf..c3e2b760 100644
--- a/src/components/LoginModal.vue
+++ b/src/components/LoginModal.vue
@@ -114,7 +114,7 @@ const password = ref("");
const submitLogin = () => {
emit("login", {
identification: identification.value.trim(),
- password: password.value,
+ password: password.value.trim(),
});
};
From c2e8b9a1b4601f4c10795e5d51ee27cee5af6a92 Mon Sep 17 00:00:00 2001
From: momen
Date: Mon, 18 May 2026 22:55:21 +0800
Subject: [PATCH 05/26] fix(account): route forum login through ipc
---
electron/main/index.ts | 47 +++++++
src/App.vue | 37 +++++-
.../unit/App.account-placeholders.test.ts | 117 ++++++++++++++++++
src/__tests__/unit/flarumAuth.test.ts | 35 ++++++
src/modules/flarumAuth.ts | 8 +-
src/vite-env.d.ts | 4 +
6 files changed, 242 insertions(+), 6 deletions(-)
create mode 100644 src/__tests__/unit/App.account-placeholders.test.ts
create mode 100644 src/__tests__/unit/flarumAuth.test.ts
diff --git a/electron/main/index.ts b/electron/main/index.ts
index 53cf19d1..0062b016 100644
--- a/electron/main/index.ts
+++ b/electron/main/index.ts
@@ -55,6 +55,7 @@ import "./backend/install-manager.js";
import "./handle-url-scheme.js";
const logger = pino({ name: "index.ts" });
+const FLARUM_TOKEN_URL = "https://bbs.spark-app.store/api/token";
// The built directory structure
//
@@ -118,6 +119,52 @@ ipcMain.handle("get-store-filter", (): "spark" | "apm" | "both" =>
ipcMain.handle("get-app-version", (): string => getAppVersion());
+ipcMain.handle("request-flarum-token", async (_event, payload: unknown) => {
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
+ throw new Error("登录信息格式不正确,请重新输入。");
+ }
+
+ const credentials = payload as Record;
+ if (
+ typeof credentials.identification !== "string" ||
+ typeof credentials.password !== "string"
+ ) {
+ throw new Error("登录信息格式不正确,请重新输入。");
+ }
+
+ const response = await fetch(FLARUM_TOKEN_URL, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ "User-Agent": getUserAgent(),
+ },
+ body: JSON.stringify({
+ identification: credentials.identification,
+ password: credentials.password,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error("论坛登录失败,请检查账号和密码。");
+ }
+
+ const data = (await response.json()) as Record;
+ const userId = data.userId ?? data.user_id;
+ if (
+ typeof data.token !== "string" ||
+ userId === undefined ||
+ userId === null
+ ) {
+ throw new Error("论坛登录响应异常,请稍后重试。");
+ }
+
+ return {
+ token: data.token,
+ userId: String(userId),
+ };
+});
+
const getMainWindowCloseGuardState = (): MainWindowCloseGuardState => ({
installTaskCount: tasks.size,
hasRunningUpdateCenterTasks:
diff --git a/src/App.vue b/src/App.vue
index b47b5898..8f01744f 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -62,7 +62,29 @@
@select-category="selectSubCategory"
/>
-
+
+
+ 用户管理
+
+
+ 账号资料与安全设置功能即将开放。
+
+
+
+
+ 我的收藏
+
+
+ 收藏应用列表功能即将开放。
+
+
+
{
const categories: Ref> = ref({});
const apps: Ref = ref([]);
const activeTab = ref("home");
+const currentView = ref<"default" | "account" | "favorites">("default");
const selectedCategory = ref("all");
const searchQuery = ref("");
const isSidebarOpen = ref(false);
@@ -456,6 +479,7 @@ const toggleTheme = () => {
};
const selectTab = (tab: string) => {
+ currentView.value = "default";
activeTab.value = tab;
selectedCategory.value = "all";
isSidebarOpen.value = false;
@@ -470,6 +494,7 @@ const selectTab = (tab: string) => {
};
const selectSubCategory = (category: string) => {
+ currentView.value = "default";
selectedCategory.value = category;
window.scrollTo({ top: 0, behavior: "smooth" });
};
@@ -532,6 +557,7 @@ const fetchAppFromStore = async (
};
const openDetail = async (app: App | Record) => {
+ currentView.value = "default";
// 提取 pkgname 和 category(必须存在)
const pkgname = (app as Record).pkgname as string;
const category =
@@ -1142,11 +1168,17 @@ const handleFlarumLogin = async (payload: FlarumLoginPayload) => {
const openUserManagement = () => {
if (!requireLogin("请登录后查看和管理账号信息。")) return;
+ currentView.value = "account";
+ activeTab.value = "account";
+ isSidebarOpen.value = false;
showLoginPrompt.value = false;
};
const openFavoriteManagement = () => {
if (!requireLogin("请登录后查看我的收藏。")) return;
+ currentView.value = "favorites";
+ activeTab.value = "favorites";
+ isSidebarOpen.value = false;
showLoginPrompt.value = false;
};
@@ -1390,10 +1422,12 @@ const loadApps = async (onFirstBatch?: () => void) => {
};
const handleSearchInput = (value: string) => {
+ currentView.value = "default";
searchQuery.value = value;
};
const handleSearchFocus = () => {
+ currentView.value = "default";
if (activeTab.value === "home") activeTab.value = "all";
};
@@ -1517,6 +1551,7 @@ onMounted(async () => {
// 根据包名直接打开应用详情
const tryOpen = () => {
// 先切换到"全部应用"分类
+ currentView.value = "default";
activeTab.value = "all";
// 使用类似 HomeView 的方式打开应用,从两个仓库获取完整信息
const target = apps.value.find((a) => a.pkgname === data.pkgname);
diff --git a/src/__tests__/unit/App.account-placeholders.test.ts b/src/__tests__/unit/App.account-placeholders.test.ts
new file mode 100644
index 00000000..f99b08a9
--- /dev/null
+++ b/src/__tests__/unit/App.account-placeholders.test.ts
@@ -0,0 +1,117 @@
+import { fireEvent, render, screen } from "@testing-library/vue";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import App from "@/App.vue";
+import { setAuthSession } from "@/global/authState";
+
+const invoke = vi.fn();
+
+vi.mock("axios", () => {
+ const get = vi.fn(async (url: string) => {
+ if (url.includes("categories.json")) return { data: {} };
+ return { data: [] };
+ });
+
+ return {
+ default: {
+ create: () => ({ get }),
+ },
+ };
+});
+
+vi.mock("@/modules/updateCenter", () => ({
+ createUpdateCenterStore: () => ({
+ isOpen: { value: false },
+ showCloseConfirm: { value: false },
+ showMigrationConfirm: { value: false },
+ searchQuery: { value: "" },
+ selectedTaskKeys: { value: new Set() },
+ snapshot: {
+ value: { items: [], tasks: [], warnings: [], hasRunningTasks: false },
+ },
+ filteredItems: { value: [] },
+ allSelected: { value: false },
+ someSelected: { value: false },
+ bind: vi.fn(),
+ unbind: vi.fn(),
+ open: vi.fn(),
+ refresh: vi.fn(),
+ ignoreItem: vi.fn(),
+ unignoreItem: vi.fn(),
+ toggleSelection: vi.fn(),
+ toggleSelectAll: vi.fn(),
+ getSelectedItems: vi.fn(() => []),
+ closeNow: vi.fn(),
+ startSelected: vi.fn(),
+ requestClose: vi.fn(),
+ }),
+}));
+
+describe("App account placeholders", () => {
+ beforeEach(() => {
+ invoke.mockReset();
+ invoke.mockImplementation(async (channel: string) => {
+ if (channel === "get-store-filter") return "both";
+ if (channel === "check-spark-available") return true;
+ if (channel === "check-apm-available") return true;
+ if (channel === "get-app-version") return "5.0.0";
+ return [];
+ });
+
+ Object.assign(window.ipcRenderer, {
+ invoke,
+ on: vi.fn(),
+ off: vi.fn(),
+ send: vi.fn(),
+ });
+
+ window.apm_store.arch = "amd64";
+ localStorage.clear();
+ setAuthSession({
+ accessToken: "backend-token",
+ tokenType: "bearer",
+ user: {
+ id: 1,
+ flarumUserId: "42",
+ username: "momen",
+ displayName: "Momen",
+ avatarUrl: "https://bbs.spark-app.store/avatar.png",
+ forumLevel: "管理员",
+ forumGroups: ["管理员"],
+ },
+ });
+
+ vi.stubGlobal(
+ "matchMedia",
+ vi.fn(() => ({
+ matches: false,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ })),
+ );
+ });
+
+ it("shows the user management placeholder from the logged-in quick menu", async () => {
+ render(App);
+
+ await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
+ await fireEvent.click(screen.getByText("用户管理"));
+
+ expect(
+ await screen.findByRole("heading", { name: "用户管理" }),
+ ).toBeTruthy();
+ expect(screen.queryByText("请登录后查看和管理账号信息。")).toBeNull();
+ });
+
+ it("shows the favorites placeholder from the logged-in quick menu", async () => {
+ render(App);
+
+ await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
+ await fireEvent.click(screen.getByText("我的收藏"));
+
+ expect(
+ await screen.findByRole("heading", { name: "我的收藏" }),
+ ).toBeTruthy();
+ expect(screen.queryByText("请登录后查看我的收藏。")).toBeNull();
+ });
+});
diff --git a/src/__tests__/unit/flarumAuth.test.ts b/src/__tests__/unit/flarumAuth.test.ts
new file mode 100644
index 00000000..3026e47f
--- /dev/null
+++ b/src/__tests__/unit/flarumAuth.test.ts
@@ -0,0 +1,35 @@
+import axios from "axios";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { requestFlarumToken } from "@/modules/flarumAuth";
+
+vi.mock("axios", () => ({
+ default: {
+ post: vi.fn(),
+ },
+}));
+
+describe("requestFlarumToken", () => {
+ beforeEach(() => {
+ vi.mocked(window.ipcRenderer.invoke).mockReset();
+ vi.mocked(axios.post).mockReset();
+ });
+
+ it("requests the Flarum token through main-process IPC", async () => {
+ vi.mocked(window.ipcRenderer.invoke).mockResolvedValue({
+ token: "forum-token",
+ user_id: 42,
+ });
+
+ const payload = { identification: "user@example.com", password: "secret" };
+
+ const token = await requestFlarumToken(payload);
+
+ expect(window.ipcRenderer.invoke).toHaveBeenCalledWith(
+ "request-flarum-token",
+ payload,
+ );
+ expect(axios.post).not.toHaveBeenCalled();
+ expect(token).toEqual({ token: "forum-token", userId: "42" });
+ });
+});
diff --git a/src/modules/flarumAuth.ts b/src/modules/flarumAuth.ts
index aa0cde6a..08b1b52c 100644
--- a/src/modules/flarumAuth.ts
+++ b/src/modules/flarumAuth.ts
@@ -1,6 +1,3 @@
-import axios from "axios";
-
-import { FLARUM_BASE_URL } from "@/global/storeConfig";
import type { FlarumLoginPayload } from "@/global/typedefinition";
type FlarumTokenResponse = {
@@ -18,8 +15,9 @@ const asRecord = (value: unknown): Record => {
export const requestFlarumToken = async (
payload: FlarumLoginPayload,
): Promise => {
- const response = await axios.post(`${FLARUM_BASE_URL}/api/token`, payload);
- const data = asRecord(response.data);
+ const data = asRecord(
+ await window.ipcRenderer.invoke("request-flarum-token", payload),
+ );
return {
token: String(data.token || ""),
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
index eebbaab2..d17b2d9a 100644
--- a/src/vite-env.d.ts
+++ b/src/vite-env.d.ts
@@ -34,6 +34,10 @@ interface IpcRendererFacade {
// IPC channel type definitions
declare interface IpcChannels {
"get-app-version": () => string;
+ "request-flarum-token": (payload: {
+ identification: string;
+ password: string;
+ }) => Promise<{ token: string; userId: string }>;
}
declare const __APP_VERSION__: string;
From e607e4991b496bd8645c22b88a8c23216bab5a80 Mon Sep 17 00:00:00 2001
From: momen
Date: Mon, 18 May 2026 23:08:29 +0800
Subject: [PATCH 06/26] feat(detail): move app details into content view
---
src/App.vue | 97 +++++--
src/__tests__/unit/AppDetailPage.test.ts | 50 ++++
src/__tests__/unit/appIdentity.test.ts | 60 +++++
src/components/AppDetailPage.vue | 310 +++++++++++++++++++++++
src/modules/appIdentity.ts | 44 ++++
5 files changed, 533 insertions(+), 28 deletions(-)
create mode 100644 src/__tests__/unit/AppDetailPage.test.ts
create mode 100644 src/__tests__/unit/appIdentity.test.ts
create mode 100644 src/components/AppDetailPage.vue
create mode 100644 src/modules/appIdentity.ts
diff --git a/src/App.vue b/src/App.vue
index 8f01744f..3535dec7 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -63,7 +63,29 @@
/>
-
-
{
const categories: Ref> = ref({});
const apps: Ref = ref([]);
const activeTab = ref("home");
-const currentView = ref<"default" | "account" | "favorites">("default");
+type MainView = "default" | "account" | "favorites" | "detail";
+const currentView = ref("default");
+const detailPreviousView = ref("default");
const selectedCategory = ref("all");
const searchQuery = ref("");
const isSidebarOpen = ref(false);
-const showModal = ref(false);
const showPreview = ref(false);
const currentScreenIndex = ref(0);
const screenshots = ref([]);
@@ -442,6 +456,23 @@ const entryCounts = computed(() => {
return counts;
});
+const currentDisplayApp = computed(() => getDisplayApp(currentApp.value));
+
+const clientArch = computed(() => window.apm_store.arch || "amd64");
+
+const currentReviewAppKey = computed(() => {
+ if (!currentDisplayApp.value) return "";
+ return buildReviewAppKey(currentDisplayApp.value, clientArch.value);
+});
+
+const currentReviewTags = computed(() => {
+ if (!currentDisplayApp.value) return null;
+ return buildReviewTags(currentDisplayApp.value, {
+ clientArch: clientArch.value,
+ distro: "unknown",
+ });
+});
+
// 方法
const syncThemePreference = () => {
document.documentElement.classList.toggle("dark", isDarkTheme.value);
@@ -557,7 +588,8 @@ const fetchAppFromStore = async (
};
const openDetail = async (app: App | Record) => {
- currentView.value = "default";
+ detailPreviousView.value =
+ currentView.value === "detail" ? "default" : currentView.value;
// 提取 pkgname 和 category(必须存在)
const pkgname = (app as Record).pkgname as string;
const category =
@@ -702,17 +734,14 @@ const openDetail = async (app: App | Record) => {
currentApp.value = finalApp;
currentScreenIndex.value = 0;
loadScreenshots(displayAppForScreenshots);
- showModal.value = true;
+ currentView.value = "detail";
currentAppSparkInstalled.value = false;
currentAppApmInstalled.value = false;
checkAppInstalled(finalApp);
nextTick(() => {
- const modal = document.querySelector(
- '[data-app-modal="detail"] .modal-panel',
- );
- if (modal) modal.scrollTop = 0;
+ window.scrollTo({ top: 0, behavior: "smooth" });
});
};
@@ -762,7 +791,11 @@ const loadScreenshots = (app: App) => {
};
const closeDetail = () => {
- showModal.value = false;
+ currentView.value =
+ detailPreviousView.value === "detail"
+ ? "default"
+ : detailPreviousView.value;
+ detailPreviousView.value = "default";
currentApp.value = null;
};
@@ -1075,6 +1108,14 @@ const onDetailInstall = async (app: App) => {
await handleInstall(app);
};
+const onDetailFavorite = (app: App) => {
+ logger.info(`Favorite requested for ${app.pkgname}`);
+};
+
+const handleDetailRequestLogin = (message: string) => {
+ requireLogin(message);
+};
+
const closeUninstallModal = () => {
showUninstallModal.value = false;
uninstallTargetApp.value = null;
@@ -1086,7 +1127,7 @@ const onUninstallSuccess = () => {
refreshInstalledApps();
}
// 更新当前详情页状态(如果在显示)
- if (showModal.value && currentApp.value) {
+ if (currentView.value === "detail" && currentApp.value) {
checkAppInstalled(currentApp.value);
}
};
@@ -1484,7 +1525,7 @@ onMounted(async () => {
if (e.key === "ArrowLeft") prevScreen();
if (e.key === "ArrowRight") nextScreen();
}
- if (showModal.value && e.key === "Escape") {
+ if (currentView.value === "detail" && e.key === "Escape") {
closeDetail();
}
});
diff --git a/src/__tests__/unit/AppDetailPage.test.ts b/src/__tests__/unit/AppDetailPage.test.ts
new file mode 100644
index 00000000..f758978e
--- /dev/null
+++ b/src/__tests__/unit/AppDetailPage.test.ts
@@ -0,0 +1,50 @@
+import { fireEvent, render, screen } from "@testing-library/vue";
+import { describe, expect, it } from "vitest";
+
+import AppDetailPage from "@/components/AppDetailPage.vue";
+import type { App } from "@/global/typedefinition";
+
+const app: 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("AppDetailPage", () => {
+ it("renders as page, emits back, and gates favorite for anonymous users", async () => {
+ const rendered = render(AppDetailPage, {
+ props: {
+ app,
+ screenshots: [],
+ sparkInstalled: false,
+ apmInstalled: false,
+ loggedIn: false,
+ reviewAppKey: "apm:amd64-apm:office:wps",
+ reviewTags: null,
+ },
+ });
+
+ expect(screen.getByText("Office suite")).toBeTruthy();
+ await fireEvent.click(screen.getByRole("button", { name: "返回" }));
+ await fireEvent.click(screen.getByRole("button", { name: "收藏" }));
+
+ expect(rendered.emitted("back")).toHaveLength(1);
+ expect(rendered.emitted("request-login")?.[0]?.[0]).toBe(
+ "收藏应用需要登录星火账号。",
+ );
+ });
+});
diff --git a/src/__tests__/unit/appIdentity.test.ts b/src/__tests__/unit/appIdentity.test.ts
new file mode 100644
index 00000000..ef376d10
--- /dev/null
+++ b/src/__tests__/unit/appIdentity.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ buildFavoriteAppKey,
+ buildReviewAppKey,
+ buildReviewTags,
+ getDisplayApp,
+ parsePackageArch,
+} from "@/modules/appIdentity";
+import type { App } from "@/global/typedefinition";
+
+const app: App = {
+ name: "WPS",
+ pkgname: "wps",
+ version: "1.0.0",
+ filename: "wps_1.0.0_amd64.deb",
+ torrent_address: "",
+ author: "",
+ contributor: "",
+ website: "",
+ update: "",
+ size: "",
+ more: "",
+ tags: "",
+ img_urls: [],
+ icons: "",
+ category: "office",
+ origin: "apm",
+ currentStatus: "not-installed",
+};
+
+describe("appIdentity", () => {
+ it("builds favorite and review keys", () => {
+ expect(buildFavoriteAppKey(app)).toBe("app:office:wps");
+ expect(buildReviewAppKey(app, "amd64")).toBe("apm:amd64-apm:office:wps");
+ });
+
+ it("parses package arch and review tags", () => {
+ expect(parsePackageArch(app.filename)).toBe("amd64");
+ expect(
+ buildReviewTags(app, { clientArch: "amd64", distro: "deepin 25" }),
+ ).toMatchObject({
+ origin: "apm",
+ category: "office",
+ pkgname: "wps",
+ packageArch: "amd64",
+ });
+ });
+
+ it("returns selected display app from merged apps", () => {
+ const merged: App = {
+ ...app,
+ isMerged: true,
+ viewingOrigin: "spark",
+ sparkApp: { ...app, origin: "spark" },
+ apmApp: app,
+ };
+ expect(getDisplayApp(merged)?.origin).toBe("spark");
+ });
+});
diff --git a/src/components/AppDetailPage.vue b/src/components/AppDetailPage.vue
new file mode 100644
index 00000000..52c82a2f
--- /dev/null
+++ b/src/components/AppDetailPage.vue
@@ -0,0 +1,310 @@
+
+
+
+
+
+
+
+
+
+
+
+ 应用详情
+
+
+
暂无应用详情
+
+
+
+
+
+ 应用截图
+
+
+
![screenshot]()
+
+
暂无应用截图
+
+
+
+
+
+
+
diff --git a/src/modules/appIdentity.ts b/src/modules/appIdentity.ts
new file mode 100644
index 00000000..efea36ed
--- /dev/null
+++ b/src/modules/appIdentity.ts
@@ -0,0 +1,44 @@
+import type { App, ReviewTags } from "@/global/typedefinition";
+
+export const parsePackageArch = (filename: string): string => {
+ const match = filename.match(/_([^_]+)\.deb$/);
+ return match?.[1] || "unknown";
+};
+
+export const buildStoreArch = (
+ origin: "spark" | "apm",
+ clientArch: string,
+): string => {
+ return `${clientArch}-${origin === "spark" ? "store" : "apm"}`;
+};
+
+export const buildFavoriteAppKey = (app: App): string => {
+ return `app:${app.category || "unknown"}:${app.pkgname}`;
+};
+
+export const buildReviewAppKey = (app: App, clientArch: string): string => {
+ return `${app.origin}:${buildStoreArch(app.origin, clientArch)}:${app.category || "unknown"}:${app.pkgname}`;
+};
+
+export const getDisplayApp = (app: App | null): App | null => {
+ if (!app) return null;
+ if (!app.isMerged) return app;
+ if (app.viewingOrigin === "spark") return app.sparkApp ?? app.apmApp ?? app;
+ if (app.viewingOrigin === "apm") return app.apmApp ?? app.sparkApp ?? app;
+ return app.sparkApp ?? app.apmApp ?? app;
+};
+
+export const buildReviewTags = (
+ app: App,
+ options: { clientArch: string; distro: string },
+): ReviewTags => {
+ return {
+ origin: app.origin,
+ category: app.category || "unknown",
+ pkgname: app.pkgname,
+ version: app.version,
+ packageArch: parsePackageArch(app.filename),
+ clientArch: options.clientArch,
+ distro: options.distro,
+ };
+};
From 75df598bc0fc71ed47558a9b8c73e68dc52fce43 Mon Sep 17 00:00:00 2001
From: momen
Date: Mon, 18 May 2026 23:14:10 +0800
Subject: [PATCH 07/26] fix(detail): normalize review store arch
---
src/__tests__/unit/appIdentity.test.ts | 9 +++++++++
src/modules/appIdentity.ts | 3 ++-
2 files changed, 11 insertions(+), 1 deletion(-)
diff --git a/src/__tests__/unit/appIdentity.test.ts b/src/__tests__/unit/appIdentity.test.ts
index ef376d10..fcc5bea0 100644
--- a/src/__tests__/unit/appIdentity.test.ts
+++ b/src/__tests__/unit/appIdentity.test.ts
@@ -35,6 +35,15 @@ describe("appIdentity", () => {
expect(buildReviewAppKey(app, "amd64")).toBe("apm:amd64-apm:office:wps");
});
+ it("builds review keys from already-qualified client arch values", () => {
+ expect(buildReviewAppKey({ ...app, origin: "spark" }, "amd64-store")).toBe(
+ "spark:amd64-store:office:wps",
+ );
+ expect(buildReviewAppKey(app, "amd64-apm")).toBe(
+ "apm:amd64-apm:office:wps",
+ );
+ });
+
it("parses package arch and review tags", () => {
expect(parsePackageArch(app.filename)).toBe("amd64");
expect(
diff --git a/src/modules/appIdentity.ts b/src/modules/appIdentity.ts
index efea36ed..7cf59e43 100644
--- a/src/modules/appIdentity.ts
+++ b/src/modules/appIdentity.ts
@@ -9,7 +9,8 @@ export const buildStoreArch = (
origin: "spark" | "apm",
clientArch: string,
): string => {
- return `${clientArch}-${origin === "spark" ? "store" : "apm"}`;
+ const rawArch = clientArch.replace(/-(store|apm)$/, "");
+ return `${rawArch}-${origin === "spark" ? "store" : "apm"}`;
};
export const buildFavoriteAppKey = (app: App): string => {
From e116dcee6325b4ce74edf824ef8fe6ce5a522dab Mon Sep 17 00:00:00 2001
From: momen
Date: Mon, 18 May 2026 23:27:56 +0800
Subject: [PATCH 08/26] feat(favorites): add cloud favorite management
---
src/App.vue | 180 ++++++++++++++++--
.../unit/FavoriteFolderManager.test.ts | 51 +++++
.../unit/favoriteAvailability.test.ts | 73 +++++++
src/components/FavoriteFolderManager.vue | 175 +++++++++++++++++
src/components/FavoriteFolderSelector.vue | 60 ++++++
src/modules/favoriteAvailability.ts | 114 +++++++++++
6 files changed, 639 insertions(+), 14 deletions(-)
create mode 100644 src/__tests__/unit/FavoriteFolderManager.test.ts
create mode 100644 src/__tests__/unit/favoriteAvailability.test.ts
create mode 100644 src/components/FavoriteFolderManager.vue
create mode 100644 src/components/FavoriteFolderSelector.vue
create mode 100644 src/modules/favoriteAvailability.ts
diff --git a/src/App.vue b/src/App.vue
index 3535dec7..02d49c85 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -95,17 +95,18 @@
账号资料与安全设置功能即将开放。
-
-
- 我的收藏
-
-
- 收藏应用列表功能即将开放。
-
-
+ :folders="favoriteFolders"
+ :active-folder-id="activeFavoriteFolderId"
+ :items="resolvedFavoriteItems"
+ :loading="favoriteLoading"
+ :error="favoriteError"
+ @select-folder="selectFavoriteFolder"
+ @create-folder="createFavoriteFolderFromPrompt"
+ @remove-selected="removeSelectedFavorites"
+ @install-selected="installResolvedFavorites"
+ />
+
+
@@ -246,6 +254,8 @@ import AboutModal from "./components/AboutModal.vue";
import SettingsModal from "./components/SettingsModal.vue";
import LoginModal from "./components/LoginModal.vue";
import LoginPromptModal from "./components/LoginPromptModal.vue";
+import FavoriteFolderSelector from "./components/FavoriteFolderSelector.vue";
+import FavoriteFolderManager from "./components/FavoriteFolderManager.vue";
import {
APM_STORE_BASE_URL,
FLARUM_BASE_URL,
@@ -269,7 +279,14 @@ import {
rankAppsBySearch,
} from "./modules/appSearch";
import { handleInstall, handleRetry } from "./modules/processInstall";
-import { exchangeFlarumToken } from "./modules/backendApi";
+import {
+ addFavoriteItem,
+ bulkDeleteFavoriteItems,
+ createFavoriteFolder,
+ exchangeFlarumToken,
+ listFavoriteFolders,
+ listFavoriteItems,
+} from "./modules/backendApi";
import { requestFlarumToken } from "./modules/flarumAuth";
import {
currentUser,
@@ -286,9 +303,11 @@ import {
import { createUpdateCenterStore } from "./modules/updateCenter";
import {
buildReviewAppKey,
+ buildFavoriteAppKey,
buildReviewTags,
getDisplayApp,
} from "./modules/appIdentity";
+import { resolveFavoriteItems } from "./modules/favoriteAvailability";
import type {
App,
AppJson,
@@ -301,6 +320,9 @@ import type {
SidebarEntry,
UpdateCenterItem,
ReviewTags,
+ FavoriteFolder,
+ FavoriteItem,
+ ResolvedFavoriteItem,
} from "./global/typedefinition";
import type { Ref } from "vue";
import type { IpcRendererEvent } from "electron";
@@ -372,6 +394,13 @@ const loginPromptMessage = ref("请登录星火账号后继续操作。");
const sparkAvailable = ref(false);
const apmAvailable = ref(false);
const sidebarEntries: Ref = ref([]);
+const favoriteFolders = ref([]);
+const activeFavoriteFolderId = ref(null);
+const favoriteItems = ref([]);
+const showFavoriteSelector = ref(false);
+const favoriteTargetApp = ref(null);
+const favoriteLoading = ref(false);
+const favoriteError = ref("");
/** 启动参数 --no-apm => 仅 Spark;--no-spark => 仅 APM;由主进程 IPC 提供 */
const storeFilter = ref<"spark" | "apm" | "both">("both");
@@ -473,6 +502,17 @@ const currentReviewTags = computed(() => {
});
});
+const resolvedFavoriteItems = computed(() =>
+ resolveFavoriteItems(
+ favoriteItems.value,
+ apps.value,
+ installedApps.value,
+ availableSources.value,
+ storeFilter.value,
+ clientArch.value,
+ ),
+);
+
// 方法
const syncThemePreference = () => {
document.documentElement.classList.toggle("dark", isDarkTheme.value);
@@ -1108,8 +1148,8 @@ const onDetailInstall = async (app: App) => {
await handleInstall(app);
};
-const onDetailFavorite = (app: App) => {
- logger.info(`Favorite requested for ${app.pkgname}`);
+const onDetailFavorite = async (app: App) => {
+ await openFavoriteSelector(app);
};
const handleDetailRequestLogin = (message: string) => {
@@ -1215,12 +1255,124 @@ const openUserManagement = () => {
showLoginPrompt.value = false;
};
-const openFavoriteManagement = () => {
+const loadFavoriteFolders = async (): Promise => {
+ favoriteFolders.value = await listFavoriteFolders();
+ if (!activeFavoriteFolderId.value && favoriteFolders.value.length > 0) {
+ activeFavoriteFolderId.value = favoriteFolders.value[0].id;
+ }
+};
+
+const loadActiveFavoriteItems = async (): Promise => {
+ if (!activeFavoriteFolderId.value) {
+ favoriteItems.value = [];
+ return;
+ }
+ favoriteItems.value = await listFavoriteItems(activeFavoriteFolderId.value);
+};
+
+const refreshFavorites = async (): Promise => {
+ favoriteLoading.value = true;
+ favoriteError.value = "";
+ try {
+ await loadFavoriteFolders();
+ await loadActiveFavoriteItems();
+ } catch (error: unknown) {
+ favoriteError.value = (error as Error)?.message || "读取收藏夹失败";
+ } finally {
+ favoriteLoading.value = false;
+ }
+};
+
+const openFavoriteSelector = async (app: App) => {
+ if (!requireLogin("收藏应用需要登录星火账号。")) return;
+ favoriteTargetApp.value = app;
+ favoriteError.value = "";
+ try {
+ await loadFavoriteFolders();
+ showFavoriteSelector.value = true;
+ } catch (error: unknown) {
+ favoriteError.value = (error as Error)?.message || "读取收藏夹失败";
+ }
+};
+
+const addCurrentFavoriteToFolder = async (folderId: number | "default") => {
+ const app = favoriteTargetApp.value;
+ if (!app) return;
+ try {
+ await addFavoriteItem(folderId, {
+ appKey: buildFavoriteAppKey(app),
+ pkgname: app.pkgname,
+ name: app.name,
+ category: app.category,
+ iconUrl: app.icons,
+ });
+ showFavoriteSelector.value = false;
+ favoriteTargetApp.value = null;
+ await refreshFavorites();
+ } catch (error: unknown) {
+ favoriteError.value = (error as Error)?.message || "添加收藏失败";
+ }
+};
+
+const openFavoriteManagement = async () => {
if (!requireLogin("请登录后查看我的收藏。")) return;
currentView.value = "favorites";
activeTab.value = "favorites";
isSidebarOpen.value = false;
showLoginPrompt.value = false;
+ await refreshFavorites();
+};
+
+const selectFavoriteFolder = async (folderId: number) => {
+ activeFavoriteFolderId.value = folderId;
+ favoriteLoading.value = true;
+ favoriteError.value = "";
+ try {
+ await loadActiveFavoriteItems();
+ } catch (error: unknown) {
+ favoriteError.value = (error as Error)?.message || "读取收藏应用失败";
+ } finally {
+ favoriteLoading.value = false;
+ }
+};
+
+const createFavoriteFolderFromPrompt = async () => {
+ const name = window.prompt("请输入收藏夹名称");
+ const folderName = name?.trim();
+ if (!folderName) return;
+ favoriteLoading.value = true;
+ favoriteError.value = "";
+ try {
+ const folder = await createFavoriteFolder(folderName);
+ activeFavoriteFolderId.value = folder.id;
+ await refreshFavorites();
+ } catch (error: unknown) {
+ favoriteError.value = (error as Error)?.message || "创建收藏夹失败";
+ } finally {
+ favoriteLoading.value = false;
+ }
+};
+
+const removeSelectedFavorites = async (ids: number[]) => {
+ if (!activeFavoriteFolderId.value || ids.length === 0) return;
+ favoriteLoading.value = true;
+ favoriteError.value = "";
+ try {
+ await bulkDeleteFavoriteItems(activeFavoriteFolderId.value, ids);
+ await refreshFavorites();
+ } catch (error: unknown) {
+ favoriteError.value = (error as Error)?.message || "移除收藏失败";
+ } finally {
+ favoriteLoading.value = false;
+ }
+};
+
+const installResolvedFavorites = async (items: ResolvedFavoriteItem[]) => {
+ for (const item of items) {
+ if (item.selectedApp) {
+ await onDetailInstall(item.selectedApp);
+ }
+ }
};
// TODO: 目前 APM 商店不能暂停下载
diff --git a/src/__tests__/unit/FavoriteFolderManager.test.ts b/src/__tests__/unit/FavoriteFolderManager.test.ts
new file mode 100644
index 00000000..2e588ece
--- /dev/null
+++ b/src/__tests__/unit/FavoriteFolderManager.test.ts
@@ -0,0 +1,51 @@
+import { fireEvent, render, screen } from "@testing-library/vue";
+import { describe, expect, it } from "vitest";
+
+import FavoriteFolderManager from "@/components/FavoriteFolderManager.vue";
+import type {
+ FavoriteFolder,
+ ResolvedFavoriteItem,
+} from "@/global/typedefinition";
+
+const folder: FavoriteFolder = {
+ id: 1,
+ name: "默认收藏夹",
+ itemCount: 1,
+ createdAt: "2026-05-18T00:00:00Z",
+ updatedAt: "2026-05-18T00:00:00Z",
+};
+
+const item: ResolvedFavoriteItem = {
+ item: {
+ id: 2,
+ appKey: "app:office:wps",
+ pkgname: "wps",
+ name: "WPS",
+ category: "office",
+ iconUrl: "",
+ createdAt: "2026-05-18T00:00:00Z",
+ },
+ status: "downlisted",
+ reason: "已下架",
+ selectedApp: null,
+};
+
+describe("FavoriteFolderManager", () => {
+ it("shows downlisted favorites and emits bulk delete", async () => {
+ const rendered = render(FavoriteFolderManager, {
+ props: {
+ folders: [folder],
+ activeFolderId: 1,
+ items: [item],
+ loading: false,
+ error: "",
+ },
+ });
+
+ expect(screen.getByText("已下架")).toBeTruthy();
+ await fireEvent.click(screen.getByLabelText("选择 WPS"));
+ await fireEvent.click(screen.getByRole("button", { name: "移除选中" }));
+
+ expect(rendered.emitted("remove-selected")?.[0]?.[0]).toEqual([2]);
+ });
+});
diff --git a/src/__tests__/unit/favoriteAvailability.test.ts b/src/__tests__/unit/favoriteAvailability.test.ts
new file mode 100644
index 00000000..4b7e5c6b
--- /dev/null
+++ b/src/__tests__/unit/favoriteAvailability.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from "vitest";
+
+import { resolveFavoriteItems } from "@/modules/favoriteAvailability";
+import type { App, FavoriteItem } from "@/global/typedefinition";
+
+const app = (origin: "spark" | "apm", overrides: Partial = {}): App => ({
+ name: "WPS",
+ pkgname: "wps",
+ version: "1.0.0",
+ filename: "wps_1.0.0_amd64.deb",
+ torrent_address: "",
+ author: "",
+ contributor: "",
+ website: "",
+ update: "",
+ size: "",
+ more: "",
+ tags: "",
+ img_urls: [],
+ icons: "",
+ category: "office",
+ origin,
+ currentStatus: "not-installed",
+ arch: "amd64",
+ ...overrides,
+});
+
+const favorite: FavoriteItem = {
+ id: 1,
+ appKey: "app:office:wps",
+ pkgname: "wps",
+ name: "WPS",
+ category: "office",
+ iconUrl: "",
+ createdAt: "2026-05-18T00:00:00Z",
+};
+
+describe("favoriteAvailability", () => {
+ it("marks downlisted favorites", () => {
+ expect(
+ resolveFavoriteItems(
+ [favorite],
+ [],
+ [],
+ { spark: true, apm: true },
+ "both",
+ )[0].status,
+ ).toBe("downlisted");
+ });
+
+ it("selects preferred installable variant", () => {
+ const resolved = resolveFavoriteItems(
+ [favorite],
+ [app("spark"), app("apm")],
+ [],
+ { spark: true, apm: true },
+ "both",
+ )[0];
+ expect(resolved.status).toBe("installable");
+ expect(resolved.selectedApp?.origin).toBe("apm");
+ });
+
+ it("marks installed favorites", () => {
+ const resolved = resolveFavoriteItems(
+ [favorite],
+ [app("apm")],
+ [app("apm", { currentStatus: "installed" })],
+ { spark: true, apm: true },
+ "both",
+ )[0];
+ expect(resolved.status).toBe("installed");
+ });
+});
diff --git a/src/components/FavoriteFolderManager.vue b/src/components/FavoriteFolderManager.vue
new file mode 100644
index 00000000..c39b6fc1
--- /dev/null
+++ b/src/components/FavoriteFolderManager.vue
@@ -0,0 +1,175 @@
+
+
+
+
+
+ 我的收藏
+
+
+ 管理收藏夹中的应用,已下架或不可用项目会保留显示。
+
+
+
+
+
+
+
+
+
+ 加载中...
+ {{ error }}
+
+
+ 当前收藏夹暂无应用。
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/FavoriteFolderSelector.vue b/src/components/FavoriteFolderSelector.vue
new file mode 100644
index 00000000..7660b866
--- /dev/null
+++ b/src/components/FavoriteFolderSelector.vue
@@ -0,0 +1,60 @@
+
+
+
+
+
+ 添加到收藏夹
+
+
+ 选择要保存当前应用的收藏夹。
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/favoriteAvailability.ts b/src/modules/favoriteAvailability.ts
new file mode 100644
index 00000000..0da82e81
--- /dev/null
+++ b/src/modules/favoriteAvailability.ts
@@ -0,0 +1,114 @@
+import {
+ HYBRID_DEFAULT_PRIORITY,
+ getHybridDefaultOrigin,
+} from "@/global/storeConfig";
+import type {
+ App,
+ FavoriteItem,
+ ResolvedFavoriteItem,
+ StoreFilter,
+} from "@/global/typedefinition";
+
+type SourceAvailability = {
+ spark: boolean;
+ apm: boolean;
+};
+
+const normalizeArch = (arch: string): string =>
+ arch.replace(/-(store|apm)$/, "");
+
+const appMatchesFavorite = (app: App, item: FavoriteItem): boolean =>
+ app.pkgname === item.pkgname && app.category === item.category;
+
+const appMatchesClientArch = (app: App, clientArch: string): boolean => {
+ if (!app.arch) return true;
+ return normalizeArch(app.arch) === normalizeArch(clientArch);
+};
+
+const sourceAllowed = (
+ origin: "spark" | "apm",
+ available: SourceAvailability,
+ storeFilter: StoreFilter,
+): boolean => {
+ if (!available[origin]) return false;
+ if (storeFilter === "both") return true;
+ return storeFilter === origin;
+};
+
+const choosePreferredApp = (apps: App[]): App => {
+ if (apps.length === 1) return apps[0];
+
+ const referenceApp = apps.find((app) => app.origin === "spark") ?? apps[0];
+ const preferredOrigin =
+ getHybridDefaultOrigin(referenceApp) === "spark"
+ ? HYBRID_DEFAULT_PRIORITY
+ : getHybridDefaultOrigin(referenceApp);
+ return apps.find((app) => app.origin === preferredOrigin) ?? apps[0];
+};
+
+export const resolveFavoriteItems = (
+ items: FavoriteItem[],
+ catalogApps: App[],
+ installedApps: App[],
+ available: SourceAvailability,
+ storeFilter: StoreFilter,
+ clientArch = window.apm_store.arch || "amd64",
+): ResolvedFavoriteItem[] => {
+ return items.map((item) => {
+ const catalogMatches = catalogApps.filter((app) =>
+ appMatchesFavorite(app, item),
+ );
+
+ if (catalogMatches.length === 0) {
+ return {
+ item,
+ status: "downlisted",
+ reason: "已下架",
+ selectedApp: null,
+ };
+ }
+
+ const installedMatch = installedApps.find((app) =>
+ appMatchesFavorite(app, item),
+ );
+ if (installedMatch) {
+ return {
+ item,
+ status: "installed",
+ reason: "已安装",
+ selectedApp: installedMatch,
+ };
+ }
+
+ const archMatches = catalogMatches.filter((app) =>
+ appMatchesClientArch(app, clientArch),
+ );
+ if (archMatches.length === 0) {
+ return {
+ item,
+ status: "arch-unavailable",
+ reason: "当前架构不可用",
+ selectedApp: null,
+ };
+ }
+
+ const sourceMatches = archMatches.filter((app) =>
+ sourceAllowed(app.origin, available, storeFilter),
+ );
+ if (sourceMatches.length === 0) {
+ return {
+ item,
+ status: "platform-unavailable",
+ reason: "当前来源不可用",
+ selectedApp: null,
+ };
+ }
+
+ return {
+ item,
+ status: "installable",
+ reason: "可安装",
+ selectedApp: choosePreferredApp(sourceMatches),
+ };
+ });
+};
From 58789ecd1fb11f10c03876f5a0d99fda6c36e379 Mon Sep 17 00:00:00 2001
From: momen
Date: Mon, 18 May 2026 23:40:28 +0800
Subject: [PATCH 09/26] fix(favorites): honor source priority and installed
state
---
src/App.vue | 2 +-
.../unit/App.account-placeholders.test.ts | 97 ++++++++++++++++++-
.../unit/favoriteAvailability.test.ts | 46 ++++++++-
src/modules/favoriteAvailability.ts | 16 ++-
4 files changed, 148 insertions(+), 13 deletions(-)
diff --git a/src/App.vue b/src/App.vue
index 02d49c85..7991c251 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1274,7 +1274,7 @@ const refreshFavorites = async (): Promise => {
favoriteLoading.value = true;
favoriteError.value = "";
try {
- await loadFavoriteFolders();
+ await Promise.all([refreshInstalledApps(), loadFavoriteFolders()]);
await loadActiveFavoriteItems();
} catch (error: unknown) {
favoriteError.value = (error as Error)?.message || "读取收藏夹失败";
diff --git a/src/__tests__/unit/App.account-placeholders.test.ts b/src/__tests__/unit/App.account-placeholders.test.ts
index f99b08a9..2a5fb6c8 100644
--- a/src/__tests__/unit/App.account-placeholders.test.ts
+++ b/src/__tests__/unit/App.account-placeholders.test.ts
@@ -3,12 +3,59 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import App from "@/App.vue";
import { setAuthSession } from "@/global/authState";
+import type { FavoriteFolder, FavoriteItem } from "@/global/typedefinition";
const invoke = vi.fn();
+const favoriteFolders: FavoriteFolder[] = [
+ {
+ id: 7,
+ name: "默认收藏夹",
+ itemCount: 1,
+ createdAt: "2026-05-18T00:00:00Z",
+ updatedAt: "2026-05-18T00:00:00Z",
+ },
+];
+
+const favoriteItems: FavoriteItem[] = [
+ {
+ id: 11,
+ appKey: "app:office:wps",
+ pkgname: "wps",
+ name: "WPS",
+ category: "office",
+ iconUrl: "",
+ createdAt: "2026-05-18T00:00:00Z",
+ },
+];
+
vi.mock("axios", () => {
const get = vi.fn(async (url: string) => {
- if (url.includes("categories.json")) return { data: {} };
+ if (url.includes("categories.json")) {
+ return { data: { office: { zh: "办公" } } };
+ }
+ if (url.includes("/office/applist.json")) {
+ return {
+ data: [
+ {
+ Name: "WPS",
+ Pkgname: "wps",
+ Version: "1.0.0",
+ Filename: "wps_1.0.0_amd64.deb",
+ Torrent_address: "",
+ Author: "",
+ Contributor: "",
+ Website: "",
+ Update: "",
+ Size: "",
+ More: "",
+ Tags: "",
+ img_urls: [],
+ icons: "",
+ },
+ ],
+ };
+ }
return { data: [] };
});
@@ -47,6 +94,16 @@ vi.mock("@/modules/updateCenter", () => ({
}),
}));
+vi.mock("@/modules/backendApi", () => ({
+ addFavoriteItem: vi.fn(),
+ bulkDeleteFavoriteItems: vi.fn(),
+ createFavoriteFolder: vi.fn(),
+ exchangeFlarumToken: vi.fn(),
+ listFavoriteFolders: vi.fn(async () => favoriteFolders),
+ listFavoriteItems: vi.fn(async () => favoriteItems),
+ setBackendToken: vi.fn(),
+}));
+
describe("App account placeholders", () => {
beforeEach(() => {
invoke.mockReset();
@@ -114,4 +171,42 @@ describe("App account placeholders", () => {
).toBeTruthy();
expect(screen.queryByText("请登录后查看我的收藏。")).toBeNull();
});
+
+ it("refreshes installed apps before resolving favorite management state", async () => {
+ invoke.mockImplementation(async (channel: string) => {
+ if (channel === "get-store-filter") return "both";
+ if (channel === "check-spark-available") return true;
+ if (channel === "check-apm-available") return true;
+ if (channel === "get-app-version") return "5.0.0";
+ if (channel === "list-installed") {
+ return {
+ success: true,
+ apps: [
+ {
+ pkgname: "wps",
+ name: "WPS",
+ version: "1.0.0",
+ arch: "amd64",
+ flags: "installed",
+ origin: "apm",
+ },
+ ],
+ };
+ }
+ return [];
+ });
+ render(App);
+
+ await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
+ await fireEvent.click(screen.getByText("我的收藏"));
+
+ expect(
+ await screen.findByRole("heading", { name: "我的收藏" }),
+ ).toBeTruthy();
+ expect(await screen.findByText("已安装")).toBeTruthy();
+ expect(invoke).toHaveBeenCalledWith("list-installed", {
+ origin: "apm",
+ pkgnameList: undefined,
+ });
+ });
});
diff --git a/src/__tests__/unit/favoriteAvailability.test.ts b/src/__tests__/unit/favoriteAvailability.test.ts
index 4b7e5c6b..5519c0c1 100644
--- a/src/__tests__/unit/favoriteAvailability.test.ts
+++ b/src/__tests__/unit/favoriteAvailability.test.ts
@@ -1,8 +1,11 @@
-import { describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveFavoriteItems } from "@/modules/favoriteAvailability";
+import { loadPriorityConfig } from "@/global/storeConfig";
import type { App, FavoriteItem } from "@/global/typedefinition";
+const originalFetch = globalThis.fetch;
+
const app = (origin: "spark" | "apm", overrides: Partial = {}): App => ({
name: "WPS",
pkgname: "wps",
@@ -36,6 +39,14 @@ const favorite: FavoriteItem = {
};
describe("favoriteAvailability", () => {
+ afterEach(async () => {
+ vi.restoreAllMocks();
+ globalThis.fetch = originalFetch;
+ vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: false } as Response);
+ await loadPriorityConfig("amd64");
+ vi.restoreAllMocks();
+ });
+
it("marks downlisted favorites", () => {
expect(
resolveFavoriteItems(
@@ -48,7 +59,16 @@ describe("favoriteAvailability", () => {
).toBe("downlisted");
});
- it("selects preferred installable variant", () => {
+ it("selects preferred installable variant", async () => {
+ vi.spyOn(globalThis, "fetch").mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ sparkPriority: { pkgnames: [], categories: [], tags: [] },
+ apmPriority: { pkgnames: [], categories: [], tags: [] },
+ }),
+ } as Response);
+ await loadPriorityConfig("amd64");
+
const resolved = resolveFavoriteItems(
[favorite],
[app("spark"), app("apm")],
@@ -60,6 +80,28 @@ describe("favoriteAvailability", () => {
expect(resolved.selectedApp?.origin).toBe("apm");
});
+ it("selects Spark when hybrid priority config prefers Spark", async () => {
+ vi.spyOn(globalThis, "fetch").mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ sparkPriority: { pkgnames: ["wps"], categories: [], tags: [] },
+ apmPriority: { pkgnames: [], categories: [], tags: [] },
+ }),
+ } as Response);
+ await loadPriorityConfig("amd64");
+
+ const resolved = resolveFavoriteItems(
+ [favorite],
+ [app("spark"), app("apm")],
+ [],
+ { spark: true, apm: true },
+ "both",
+ )[0];
+
+ expect(resolved.status).toBe("installable");
+ expect(resolved.selectedApp?.origin).toBe("spark");
+ });
+
it("marks installed favorites", () => {
const resolved = resolveFavoriteItems(
[favorite],
diff --git a/src/modules/favoriteAvailability.ts b/src/modules/favoriteAvailability.ts
index 0da82e81..4323f855 100644
--- a/src/modules/favoriteAvailability.ts
+++ b/src/modules/favoriteAvailability.ts
@@ -1,7 +1,4 @@
-import {
- HYBRID_DEFAULT_PRIORITY,
- getHybridDefaultOrigin,
-} from "@/global/storeConfig";
+import { getHybridDefaultOrigin } from "@/global/storeConfig";
import type {
App,
FavoriteItem,
@@ -20,6 +17,10 @@ const normalizeArch = (arch: string): string =>
const appMatchesFavorite = (app: App, item: FavoriteItem): boolean =>
app.pkgname === item.pkgname && app.category === item.category;
+const installedAppMatchesFavorite = (app: App, item: FavoriteItem): boolean =>
+ app.pkgname === item.pkgname &&
+ (app.category === item.category || app.category === "unknown");
+
const appMatchesClientArch = (app: App, clientArch: string): boolean => {
if (!app.arch) return true;
return normalizeArch(app.arch) === normalizeArch(clientArch);
@@ -39,10 +40,7 @@ const choosePreferredApp = (apps: App[]): App => {
if (apps.length === 1) return apps[0];
const referenceApp = apps.find((app) => app.origin === "spark") ?? apps[0];
- const preferredOrigin =
- getHybridDefaultOrigin(referenceApp) === "spark"
- ? HYBRID_DEFAULT_PRIORITY
- : getHybridDefaultOrigin(referenceApp);
+ const preferredOrigin = getHybridDefaultOrigin(referenceApp);
return apps.find((app) => app.origin === preferredOrigin) ?? apps[0];
};
@@ -69,7 +67,7 @@ export const resolveFavoriteItems = (
}
const installedMatch = installedApps.find((app) =>
- appMatchesFavorite(app, item),
+ installedAppMatchesFavorite(app, item),
);
if (installedMatch) {
return {
From 3a8baf606cda14190542b2dd09b39bb92f672d1e Mon Sep 17 00:00:00 2001
From: momen
Date: Mon, 18 May 2026 23:53:44 +0800
Subject: [PATCH 10/26] fix(favorites): refresh installed apps across origins
---
src/App.vue | 92 ++++++++++++++++++-
.../unit/App.account-placeholders.test.ts | 52 ++++++++++-
.../unit/favoriteAvailability.test.ts | 11 +++
src/modules/favoriteAvailability.ts | 4 +-
4 files changed, 156 insertions(+), 3 deletions(-)
diff --git a/src/App.vue b/src/App.vue
index 7991c251..cc6bc309 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -322,6 +322,7 @@ import type {
ReviewTags,
FavoriteFolder,
FavoriteItem,
+ InstalledAppInfo,
ResolvedFavoriteItem,
} from "./global/typedefinition";
import type { Ref } from "vue";
@@ -1134,6 +1135,95 @@ const refreshInstalledApps = async () => {
}
};
+const mapInstalledAppToCatalogApp = (
+ app: InstalledAppInfo,
+ origin: "spark" | "apm",
+): App | null => {
+ let appInfo = apps.value.find(
+ (catalogApp) =>
+ catalogApp.pkgname === app.pkgname && catalogApp.origin === origin,
+ );
+
+ if (origin === "spark" && !appInfo) {
+ return null;
+ }
+
+ if (appInfo) {
+ appInfo.flags = app.flags;
+ appInfo.arch = app.arch;
+ appInfo.currentStatus = "installed";
+ appInfo.isDependency = app.isDependency;
+ return appInfo;
+ }
+
+ return {
+ name: app.name || app.pkgname,
+ pkgname: app.pkgname,
+ version: app.version,
+ category: "unknown",
+ tags: "",
+ more: "",
+ filename: "",
+ torrent_address: "",
+ author: "",
+ contributor: "",
+ website: "",
+ update: "",
+ size: "",
+ img_urls: [],
+ icons: app.icon || "",
+ origin: app.origin || (app.arch?.includes("apm") ? "apm" : "spark"),
+ currentStatus: "installed",
+ arch: app.arch,
+ flags: app.flags,
+ isDependency: app.isDependency,
+ };
+};
+
+const refreshFavoriteInstalledApps = async (): Promise => {
+ const origins: Array<"spark" | "apm"> = [];
+ if (isOriginEnabled(storeFilter.value, "spark") && sparkAvailable.value) {
+ origins.push("spark");
+ }
+ if (isOriginEnabled(storeFilter.value, "apm") && apmAvailable.value) {
+ origins.push("apm");
+ }
+
+ const refreshedApps: App[] = [];
+ await Promise.all(
+ origins.map(async (origin) => {
+ const pkgnameList =
+ origin === "spark"
+ ? apps.value
+ .filter((app) => app.origin === "spark")
+ .map((app) => app.pkgname)
+ : undefined;
+ const result = await window.ipcRenderer.invoke("list-installed", {
+ origin,
+ pkgnameList,
+ });
+ if (!result?.success) return;
+
+ for (const app of result.apps as InstalledAppInfo[]) {
+ const appInfo = mapInstalledAppToCatalogApp(app, origin);
+ if (appInfo) refreshedApps.push(appInfo);
+ }
+ }),
+ );
+
+ const refreshedKeys = new Set(
+ refreshedApps.map((app) => `${app.origin}:${app.pkgname}`),
+ );
+ installedApps.value = [
+ ...installedApps.value.filter(
+ (app) =>
+ !origins.includes(app.origin) &&
+ !refreshedKeys.has(`${app.origin}:${app.pkgname}`),
+ ),
+ ...refreshedApps,
+ ];
+};
+
const requestUninstall = (app: App) => {
uninstallTargetApp.value = app;
showUninstallModal.value = true;
@@ -1274,7 +1364,7 @@ const refreshFavorites = async (): Promise => {
favoriteLoading.value = true;
favoriteError.value = "";
try {
- await Promise.all([refreshInstalledApps(), loadFavoriteFolders()]);
+ await Promise.all([refreshFavoriteInstalledApps(), loadFavoriteFolders()]);
await loadActiveFavoriteItems();
} catch (error: unknown) {
favoriteError.value = (error as Error)?.message || "读取收藏夹失败";
diff --git a/src/__tests__/unit/App.account-placeholders.test.ts b/src/__tests__/unit/App.account-placeholders.test.ts
index 2a5fb6c8..cde5ebc3 100644
--- a/src/__tests__/unit/App.account-placeholders.test.ts
+++ b/src/__tests__/unit/App.account-placeholders.test.ts
@@ -1,4 +1,4 @@
-import { fireEvent, render, screen } from "@testing-library/vue";
+import { fireEvent, render, screen, waitFor } from "@testing-library/vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import App from "@/App.vue";
@@ -197,6 +197,10 @@ describe("App account placeholders", () => {
});
render(App);
+ await waitFor(() => {
+ expect(screen.getByText("2")).toBeTruthy();
+ });
+
await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
await fireEvent.click(screen.getByText("我的收藏"));
@@ -209,4 +213,50 @@ describe("App account placeholders", () => {
pkgnameList: undefined,
});
});
+
+ it("refreshes Spark installed state for favorites in both mode", async () => {
+ invoke.mockImplementation(async (channel: string, payload?: unknown) => {
+ if (channel === "get-store-filter") return "both";
+ if (channel === "check-spark-available") return true;
+ if (channel === "check-apm-available") return true;
+ if (channel === "get-app-version") return "5.0.0";
+ if (channel === "list-installed") {
+ const request = payload as { origin?: string };
+ if (request.origin === "spark") {
+ return {
+ success: true,
+ apps: [
+ {
+ pkgname: "wps",
+ name: "WPS",
+ version: "1.0.0",
+ arch: "amd64",
+ flags: "installed",
+ origin: "spark",
+ },
+ ],
+ };
+ }
+ return { success: true, apps: [] };
+ }
+ return [];
+ });
+ render(App);
+
+ await waitFor(() => {
+ expect(screen.getByText("2")).toBeTruthy();
+ });
+
+ await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
+ await fireEvent.click(screen.getByText("我的收藏"));
+
+ expect(
+ await screen.findByRole("heading", { name: "我的收藏" }),
+ ).toBeTruthy();
+ expect(await screen.findByText("已安装")).toBeTruthy();
+ expect(invoke).toHaveBeenCalledWith("list-installed", {
+ origin: "spark",
+ pkgnameList: ["wps"],
+ });
+ });
});
diff --git a/src/__tests__/unit/favoriteAvailability.test.ts b/src/__tests__/unit/favoriteAvailability.test.ts
index 5519c0c1..f5158d14 100644
--- a/src/__tests__/unit/favoriteAvailability.test.ts
+++ b/src/__tests__/unit/favoriteAvailability.test.ts
@@ -112,4 +112,15 @@ describe("favoriteAvailability", () => {
)[0];
expect(resolved.status).toBe("installed");
});
+
+ it("marks installed favorites from all-category catalog matches", () => {
+ const resolved = resolveFavoriteItems(
+ [favorite],
+ [app("spark")],
+ [app("spark", { category: "all", currentStatus: "installed" })],
+ { spark: true, apm: true },
+ "both",
+ )[0];
+ expect(resolved.status).toBe("installed");
+ });
});
diff --git a/src/modules/favoriteAvailability.ts b/src/modules/favoriteAvailability.ts
index 4323f855..0c0ba399 100644
--- a/src/modules/favoriteAvailability.ts
+++ b/src/modules/favoriteAvailability.ts
@@ -19,7 +19,9 @@ const appMatchesFavorite = (app: App, item: FavoriteItem): boolean =>
const installedAppMatchesFavorite = (app: App, item: FavoriteItem): boolean =>
app.pkgname === item.pkgname &&
- (app.category === item.category || app.category === "unknown");
+ (app.category === item.category ||
+ app.category === "all" ||
+ app.category === "unknown");
const appMatchesClientArch = (app: App, clientArch: string): boolean => {
if (!app.arch) return true;
From 3a4aa7807a6ba198fc0fb1549fe5066200fd39e9 Mon Sep 17 00:00:00 2001
From: momen
Date: Tue, 19 May 2026 00:07:26 +0800
Subject: [PATCH 11/26] fix(favorites): clear account data on logout
---
src/App.vue | 32 +++++++++++++++--
.../unit/App.account-placeholders.test.ts | 34 ++++++++++++++++++-
2 files changed, 62 insertions(+), 4 deletions(-)
diff --git a/src/App.vue b/src/App.vue
index cc6bc309..62aed9cb 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -35,7 +35,7 @@
@open-favorites="openFavoriteManagement"
@open-forum="openExternalUrl(FLARUM_BASE_URL)"
@edit-profile="openExternalUrl(FLARUM_SETTINGS_URL)"
- @logout="logout"
+ @logout="handleLogout"
/>
@@ -1318,6 +1318,29 @@ const openLoginFromPrompt = () => {
showLoginModal.value = true;
};
+const clearFavoriteState = () => {
+ favoriteFolders.value = [];
+ activeFavoriteFolderId.value = null;
+ favoriteItems.value = [];
+ showFavoriteSelector.value = false;
+ favoriteTargetApp.value = null;
+ favoriteLoading.value = false;
+ favoriteError.value = "";
+};
+
+const handleLogout = () => {
+ logout();
+ clearFavoriteState();
+ showLoginModal.value = false;
+ showLoginPrompt.value = false;
+ isSidebarOpen.value = false;
+ if (currentView.value === "favorites" || currentView.value === "account") {
+ currentView.value = "default";
+ activeTab.value = "home";
+ selectedCategory.value = "all";
+ }
+};
+
const handleFlarumLogin = async (payload: FlarumLoginPayload) => {
loginLoading.value = true;
loginError.value = "";
@@ -1347,8 +1370,11 @@ const openUserManagement = () => {
const loadFavoriteFolders = async (): Promise => {
favoriteFolders.value = await listFavoriteFolders();
- if (!activeFavoriteFolderId.value && favoriteFolders.value.length > 0) {
- activeFavoriteFolderId.value = favoriteFolders.value[0].id;
+ const activeFolderExists = favoriteFolders.value.some(
+ (folder) => folder.id === activeFavoriteFolderId.value,
+ );
+ if (!activeFolderExists) {
+ activeFavoriteFolderId.value = favoriteFolders.value[0]?.id ?? null;
}
};
diff --git a/src/__tests__/unit/App.account-placeholders.test.ts b/src/__tests__/unit/App.account-placeholders.test.ts
index cde5ebc3..df9debbe 100644
--- a/src/__tests__/unit/App.account-placeholders.test.ts
+++ b/src/__tests__/unit/App.account-placeholders.test.ts
@@ -163,7 +163,9 @@ describe("App account placeholders", () => {
it("shows the favorites placeholder from the logged-in quick menu", async () => {
render(App);
- await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
+ await fireEvent.click(
+ await screen.findByRole("button", { name: /^Momen$/ }),
+ );
await fireEvent.click(screen.getByText("我的收藏"));
expect(
@@ -259,4 +261,34 @@ describe("App account placeholders", () => {
pkgnameList: ["wps"],
});
});
+
+ it("clears favorite data and leaves protected favorites view after logout", async () => {
+ render(App);
+
+ await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
+ await fireEvent.click(screen.getByText("我的收藏"));
+
+ expect(
+ await screen.findByRole("heading", { name: "我的收藏" }),
+ ).toBeTruthy();
+ expect(await screen.findByText("默认收藏夹 (1)")).toBeTruthy();
+ expect(await screen.findByText("wps · office")).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();
+ });
+ expect(screen.queryByText("默认收藏夹 (1)")).toBeNull();
+ expect(screen.queryByText("wps · office")).toBeNull();
+ expect(screen.queryByRole("heading", { name: "我的收藏" })).toBeNull();
+ });
});
From 8da044495ade89d28520a86746e03d172d14e018 Mon Sep 17 00:00:00 2001
From: momen
Date: Tue, 19 May 2026 00:18:01 +0800
Subject: [PATCH 12/26] fix(favorites): ignore stale account requests
---
src/App.vue | 68 +++++++++++++++----
.../unit/App.account-placeholders.test.ts | 60 ++++++++++++++++
2 files changed, 113 insertions(+), 15 deletions(-)
diff --git a/src/App.vue b/src/App.vue
index 62aed9cb..887f5cba 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -402,6 +402,7 @@ const showFavoriteSelector = ref(false);
const favoriteTargetApp = ref(null);
const favoriteLoading = ref(false);
const favoriteError = ref("");
+const favoriteRequestGeneration = ref(0);
/** 启动参数 --no-apm => 仅 Spark;--no-spark => 仅 APM;由主进程 IPC 提供 */
const storeFilter = ref<"spark" | "apm" | "both">("both");
@@ -1319,6 +1320,7 @@ const openLoginFromPrompt = () => {
};
const clearFavoriteState = () => {
+ favoriteRequestGeneration.value += 1;
favoriteFolders.value = [];
activeFavoriteFolderId.value = null;
favoriteItems.value = [];
@@ -1328,6 +1330,9 @@ const clearFavoriteState = () => {
favoriteError.value = "";
};
+const isCurrentFavoriteRequest = (generation: number): boolean =>
+ favoriteRequestGeneration.value === generation && isLoggedIn.value;
+
const handleLogout = () => {
logout();
clearFavoriteState();
@@ -1368,50 +1373,73 @@ const openUserManagement = () => {
showLoginPrompt.value = false;
};
-const loadFavoriteFolders = async (): Promise => {
- favoriteFolders.value = await listFavoriteFolders();
- const activeFolderExists = favoriteFolders.value.some(
+const loadFavoriteFolders = async (
+ generation = favoriteRequestGeneration.value,
+): Promise => {
+ const folders = await listFavoriteFolders();
+ if (!isCurrentFavoriteRequest(generation)) return false;
+
+ favoriteFolders.value = folders;
+ const activeFolderExists = folders.some(
(folder) => folder.id === activeFavoriteFolderId.value,
);
if (!activeFolderExists) {
- activeFavoriteFolderId.value = favoriteFolders.value[0]?.id ?? null;
+ activeFavoriteFolderId.value = folders[0]?.id ?? null;
}
+ return true;
};
-const loadActiveFavoriteItems = async (): Promise => {
+const loadActiveFavoriteItems = async (
+ generation = favoriteRequestGeneration.value,
+): Promise => {
if (!activeFavoriteFolderId.value) {
+ if (!isCurrentFavoriteRequest(generation)) return false;
favoriteItems.value = [];
- return;
+ return true;
}
- favoriteItems.value = await listFavoriteItems(activeFavoriteFolderId.value);
+ const items = await listFavoriteItems(activeFavoriteFolderId.value);
+ if (!isCurrentFavoriteRequest(generation)) return false;
+
+ favoriteItems.value = items;
+ return true;
};
const refreshFavorites = async (): Promise => {
+ const generation = favoriteRequestGeneration.value;
favoriteLoading.value = true;
favoriteError.value = "";
try {
- await Promise.all([refreshFavoriteInstalledApps(), loadFavoriteFolders()]);
- await loadActiveFavoriteItems();
+ await Promise.all([
+ refreshFavoriteInstalledApps(),
+ loadFavoriteFolders(generation),
+ ]);
+ if (!isCurrentFavoriteRequest(generation)) return;
+ await loadActiveFavoriteItems(generation);
} catch (error: unknown) {
+ if (!isCurrentFavoriteRequest(generation)) return;
favoriteError.value = (error as Error)?.message || "读取收藏夹失败";
} finally {
- favoriteLoading.value = false;
+ if (isCurrentFavoriteRequest(generation)) favoriteLoading.value = false;
}
};
const openFavoriteSelector = async (app: App) => {
if (!requireLogin("收藏应用需要登录星火账号。")) return;
+ const generation = favoriteRequestGeneration.value;
favoriteTargetApp.value = app;
favoriteError.value = "";
try {
- await loadFavoriteFolders();
+ const foldersLoaded = await loadFavoriteFolders(generation);
+ if (!foldersLoaded || !isCurrentFavoriteRequest(generation)) return;
showFavoriteSelector.value = true;
} catch (error: unknown) {
+ if (!isCurrentFavoriteRequest(generation)) return;
favoriteError.value = (error as Error)?.message || "读取收藏夹失败";
}
};
const addCurrentFavoriteToFolder = async (folderId: number | "default") => {
+ const generation = favoriteRequestGeneration.value;
const app = favoriteTargetApp.value;
if (!app) return;
try {
@@ -1422,10 +1450,12 @@ const addCurrentFavoriteToFolder = async (folderId: number | "default") => {
category: app.category,
iconUrl: app.icons,
});
+ if (!isCurrentFavoriteRequest(generation)) return;
showFavoriteSelector.value = false;
favoriteTargetApp.value = null;
await refreshFavorites();
} catch (error: unknown) {
+ if (!isCurrentFavoriteRequest(generation)) return;
favoriteError.value = (error as Error)?.message || "添加收藏失败";
}
};
@@ -1440,15 +1470,17 @@ const openFavoriteManagement = async () => {
};
const selectFavoriteFolder = async (folderId: number) => {
+ const generation = favoriteRequestGeneration.value;
activeFavoriteFolderId.value = folderId;
favoriteLoading.value = true;
favoriteError.value = "";
try {
- await loadActiveFavoriteItems();
+ await loadActiveFavoriteItems(generation);
} catch (error: unknown) {
+ if (!isCurrentFavoriteRequest(generation)) return;
favoriteError.value = (error as Error)?.message || "读取收藏应用失败";
} finally {
- favoriteLoading.value = false;
+ if (isCurrentFavoriteRequest(generation)) favoriteLoading.value = false;
}
};
@@ -1456,30 +1488,36 @@ const createFavoriteFolderFromPrompt = async () => {
const name = window.prompt("请输入收藏夹名称");
const folderName = name?.trim();
if (!folderName) return;
+ const generation = favoriteRequestGeneration.value;
favoriteLoading.value = true;
favoriteError.value = "";
try {
const folder = await createFavoriteFolder(folderName);
+ if (!isCurrentFavoriteRequest(generation)) return;
activeFavoriteFolderId.value = folder.id;
await refreshFavorites();
} catch (error: unknown) {
+ if (!isCurrentFavoriteRequest(generation)) return;
favoriteError.value = (error as Error)?.message || "创建收藏夹失败";
} finally {
- favoriteLoading.value = false;
+ if (isCurrentFavoriteRequest(generation)) favoriteLoading.value = false;
}
};
const removeSelectedFavorites = async (ids: number[]) => {
if (!activeFavoriteFolderId.value || ids.length === 0) return;
+ const generation = favoriteRequestGeneration.value;
favoriteLoading.value = true;
favoriteError.value = "";
try {
await bulkDeleteFavoriteItems(activeFavoriteFolderId.value, ids);
+ if (!isCurrentFavoriteRequest(generation)) return;
await refreshFavorites();
} catch (error: unknown) {
+ if (!isCurrentFavoriteRequest(generation)) return;
favoriteError.value = (error as Error)?.message || "移除收藏失败";
} finally {
- favoriteLoading.value = false;
+ if (isCurrentFavoriteRequest(generation)) favoriteLoading.value = false;
}
};
diff --git a/src/__tests__/unit/App.account-placeholders.test.ts b/src/__tests__/unit/App.account-placeholders.test.ts
index df9debbe..45c89532 100644
--- a/src/__tests__/unit/App.account-placeholders.test.ts
+++ b/src/__tests__/unit/App.account-placeholders.test.ts
@@ -2,6 +2,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import App from "@/App.vue";
+import { listFavoriteFolders } from "@/modules/backendApi";
import { setAuthSession } from "@/global/authState";
import type { FavoriteFolder, FavoriteItem } from "@/global/typedefinition";
@@ -29,6 +30,15 @@ const favoriteItems: FavoriteItem[] = [
},
];
+const createDeferred = () => {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((promiseResolve) => {
+ resolve = promiseResolve;
+ });
+
+ return { promise, resolve };
+};
+
vi.mock("axios", () => {
const get = vi.fn(async (url: string) => {
if (url.includes("categories.json")) {
@@ -146,6 +156,13 @@ describe("App account placeholders", () => {
removeEventListener: vi.fn(),
})),
);
+ vi.stubGlobal("scrollTo", vi.fn());
+ class MockIntersectionObserver {
+ observe = vi.fn();
+ disconnect = vi.fn();
+ unobserve = vi.fn();
+ }
+ vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
});
it("shows the user management placeholder from the logged-in quick menu", async () => {
@@ -291,4 +308,47 @@ describe("App account placeholders", () => {
expect(screen.queryByText("wps · office")).toBeNull();
expect(screen.queryByRole("heading", { name: "我的收藏" })).toBeNull();
});
+
+ it("does not reopen the favorite selector when folder loading resolves after logout", async () => {
+ const slowFolders = createDeferred();
+ vi.mocked(listFavoriteFolders).mockReturnValueOnce(slowFolders.promise);
+ render(App);
+
+ await fireEvent.click(await screen.findByText("全部应用"));
+ await fireEvent.click(await screen.findByText("wps · 1.0.0"));
+ expect(await screen.findByRole("heading", { name: "WPS" })).toBeTruthy();
+ await fireEvent.click(screen.getByRole("button", { name: "收藏" }));
+
+ 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();
+ });
+
+ slowFolders.resolve([
+ {
+ id: 42,
+ name: "旧账号收藏夹",
+ itemCount: 1,
+ createdAt: "2026-05-18T00:00:00Z",
+ updatedAt: "2026-05-18T00:00:00Z",
+ },
+ ]);
+ await slowFolders.promise;
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(screen.queryByText("旧账号收藏夹 (1)")).toBeNull();
+ expect(screen.queryByText("wps · office")).toBeNull();
+ expect(screen.queryByText("旧账号收藏夹")).toBeNull();
+ expect(screen.queryByRole("dialog", { name: "选择收藏夹" })).toBeNull();
+ });
});
From 78a04fb51fba74ecf538b6267cd0d1470ef57187 Mon Sep 17 00:00:00 2001
From: momen
Date: Tue, 19 May 2026 00:25:57 +0800
Subject: [PATCH 13/26] feat(account): record downloads and show reviews
---
electron/main/index.ts | 18 ++
src/App.vue | 30 +++-
src/__tests__/unit/ReviewsPanel.test.ts | 27 +++
src/__tests__/unit/processInstall.test.ts | 40 +++++
src/components/AppDetailPage.vue | 9 +
src/components/ReviewsPanel.vue | 192 ++++++++++++++++++++++
src/modules/processInstall.ts | 11 +-
src/vite-env.d.ts | 3 +-
8 files changed, 323 insertions(+), 7 deletions(-)
create mode 100644 src/__tests__/unit/ReviewsPanel.test.ts
create mode 100644 src/components/ReviewsPanel.vue
diff --git a/electron/main/index.ts b/electron/main/index.ts
index 0062b016..5d060a23 100644
--- a/electron/main/index.ts
+++ b/electron/main/index.ts
@@ -40,6 +40,23 @@ function getAppVersion(): string {
}
}
+function getSystemInfo(): { distro: string } {
+ try {
+ const raw = fs.readFileSync("/etc/os-release", "utf8");
+ const fields = Object.fromEntries(
+ raw
+ .split("\n")
+ .map((line) => line.match(/^([A-Z_]+)=(.*)$/))
+ .filter((match): match is RegExpMatchArray => match !== null)
+ .map((match) => [match[1], match[2].replace(/^"|"$/g, "")]),
+ );
+ const distro = fields.PRETTY_NAME || fields.NAME || "unknown";
+ return { distro };
+ } catch {
+ return { distro: "unknown" };
+ }
+}
+
// 处理 --version 参数(在单实例检查之前)
if (process.argv.includes("--version") || process.argv.includes("-v")) {
console.log(getAppVersion());
@@ -118,6 +135,7 @@ ipcMain.handle("get-store-filter", (): "spark" | "apm" | "both" =>
);
ipcMain.handle("get-app-version", (): string => getAppVersion());
+ipcMain.handle("get-system-info", (): { distro: string } => getSystemInfo());
ipcMain.handle("request-flarum-token", async (_event, payload: unknown) => {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
diff --git a/src/App.vue b/src/App.vue
index 887f5cba..9e64dfcf 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -286,6 +286,7 @@ import {
exchangeFlarumToken,
listFavoriteFolders,
listFavoriteItems,
+ recordDownloadedApp,
} from "./modules/backendApi";
import { requestFlarumToken } from "./modules/flarumAuth";
import {
@@ -306,6 +307,7 @@ import {
buildFavoriteAppKey,
buildReviewTags,
getDisplayApp,
+ parsePackageArch,
} from "./modules/appIdentity";
import { resolveFavoriteItems } from "./modules/favoriteAvailability";
import type {
@@ -324,6 +326,7 @@ import type {
FavoriteItem,
InstalledAppInfo,
ResolvedFavoriteItem,
+ SystemInfo,
} from "./global/typedefinition";
import type { Ref } from "vue";
import type { IpcRendererEvent } from "electron";
@@ -403,6 +406,7 @@ const favoriteTargetApp = ref(null);
const favoriteLoading = ref(false);
const favoriteError = ref("");
const favoriteRequestGeneration = ref(0);
+const systemInfo = ref({ distro: "unknown" });
/** 启动参数 --no-apm => 仅 Spark;--no-spark => 仅 APM;由主进程 IPC 提供 */
const storeFilter = ref<"spark" | "apm" | "both">("both");
@@ -500,7 +504,7 @@ const currentReviewTags = computed(() => {
if (!currentDisplayApp.value) return null;
return buildReviewTags(currentDisplayApp.value, {
clientArch: clientArch.value,
- distro: "unknown",
+ distro: systemInfo.value.distro,
});
});
@@ -1236,7 +1240,22 @@ const onDetailRemove = (app: App) => {
};
const onDetailInstall = async (app: App) => {
- await handleInstall(app);
+ const download = await handleInstall(app);
+ if (!download || !isLoggedIn.value) return;
+
+ try {
+ await recordDownloadedApp({
+ appKey: buildFavoriteAppKey(app),
+ pkgname: app.pkgname,
+ name: app.name,
+ category: app.category,
+ selectedOrigin: app.origin,
+ version: app.version,
+ packageArch: app.arch || parsePackageArch(app.filename),
+ });
+ } catch (error: unknown) {
+ logger.warn({ err: error }, "记录下载应用失败");
+ }
};
const onDetailFavorite = async (app: App) => {
@@ -1783,6 +1802,13 @@ onMounted(async () => {
initTheme();
updateCenterStore.bind();
+ try {
+ systemInfo.value = await window.ipcRenderer.invoke("get-system-info");
+ } catch (error: unknown) {
+ logger.warn({ err: error }, "读取系统信息失败");
+ systemInfo.value = { distro: "unknown" };
+ }
+
// 从主进程获取启动参数(--no-apm / --no-spark),再加载数据
storeFilter.value = await window.ipcRenderer.invoke("get-store-filter");
diff --git a/src/__tests__/unit/ReviewsPanel.test.ts b/src/__tests__/unit/ReviewsPanel.test.ts
new file mode 100644
index 00000000..b8cf0f7a
--- /dev/null
+++ b/src/__tests__/unit/ReviewsPanel.test.ts
@@ -0,0 +1,27 @@
+import { render, screen } from "@testing-library/vue";
+import { describe, expect, it } from "vitest";
+
+import ReviewsPanel from "@/components/ReviewsPanel.vue";
+import type { ReviewTags } from "@/global/typedefinition";
+
+const tags: ReviewTags = {
+ origin: "apm",
+ category: "office",
+ pkgname: "wps",
+ version: "1.0.0",
+ packageArch: "amd64",
+ clientArch: "amd64",
+ distro: "deepin 25",
+};
+
+describe("ReviewsPanel", () => {
+ it("shows anonymous login prompt and read-only review tags", () => {
+ render(ReviewsPanel, {
+ props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: false },
+ });
+
+ expect(screen.getByText("登录后发表评论")).toBeTruthy();
+ expect(screen.getByText("1.0.0")).toBeTruthy();
+ expect(screen.getByText("deepin 25")).toBeTruthy();
+ });
+});
diff --git a/src/__tests__/unit/processInstall.test.ts b/src/__tests__/unit/processInstall.test.ts
index de2b85c2..aca2a9ee 100644
--- a/src/__tests__/unit/processInstall.test.ts
+++ b/src/__tests__/unit/processInstall.test.ts
@@ -107,4 +107,44 @@ describe("processInstall queue forwarding", () => {
expect.stringContaining('"id":5'),
);
});
+
+ it("returns queued download metadata for account records", async () => {
+ vi.doMock("axios", () => ({
+ default: {
+ create: vi.fn(() => ({
+ post: vi.fn(() => Promise.resolve({ data: { ok: true } })),
+ })),
+ },
+ }));
+ Object.assign(window.ipcRenderer, {
+ on: vi.fn(),
+ send: vi.fn(),
+ invoke: vi.fn(() => Promise.resolve(true)),
+ });
+ window.apm_store.arch = "amd64";
+ const { handleInstall } = await import("@/modules/processInstall");
+
+ const result = await handleInstall({
+ name: "WPS",
+ pkgname: "wps",
+ version: "1.0.0",
+ filename: "wps_1.0.0_amd64.deb",
+ torrent_address: "",
+ author: "",
+ contributor: "",
+ website: "",
+ update: "",
+ size: "",
+ more: "",
+ tags: "",
+ img_urls: [],
+ icons: "",
+ category: "office",
+ origin: "apm",
+ currentStatus: "not-installed",
+ });
+
+ expect(result?.pkgname).toBe("wps");
+ expect(result?.origin).toBe("apm");
+ });
});
diff --git a/src/components/AppDetailPage.vue b/src/components/AppDetailPage.vue
index 52c82a2f..ad710083 100644
--- a/src/components/AppDetailPage.vue
+++ b/src/components/AppDetailPage.vue
@@ -194,6 +194,14 @@
暂无应用截图
+
+
@@ -201,6 +209,7 @@
diff --git a/src/modules/processInstall.ts b/src/modules/processInstall.ts
index 67c77c61..397003a0 100644
--- a/src/modules/processInstall.ts
+++ b/src/modules/processInstall.ts
@@ -21,16 +21,18 @@ import axios from "axios";
const logger = pino({ name: "processInstall.ts" });
-export const handleInstall = async (appObj?: App) => {
+export const handleInstall = async (
+ appObj?: App,
+): Promise => {
const targetApp = appObj || currentApp.value;
- if (!targetApp?.pkgname) return;
+ if (!targetApp?.pkgname) return null;
// APM 应用:在创建下载任务前检查 APM 是否可用
if (targetApp.origin === "apm") {
const hasApm = await window.ipcRenderer.invoke("check-apm-available");
if (!hasApm) {
showApmInstallDialog.value = true;
- return;
+ return null;
}
}
@@ -42,7 +44,7 @@ export const handleInstall = async (appObj?: App) => {
logger.info(
`任务已存在,忽略重复添加: ${targetApp.pkgname} (${targetApp.origin})`,
);
- return;
+ return null;
}
// 创建下载任务
@@ -98,6 +100,7 @@ export const handleInstall = async (appObj?: App) => {
.then((response) => {
logger.info("下载次数统计已发送,状态:", response.data);
});
+ return download;
};
export const handleRetry = (download_: DownloadItem) => {
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
index d17b2d9a..1eca2b6e 100644
--- a/src/vite-env.d.ts
+++ b/src/vite-env.d.ts
@@ -1,7 +1,7 @@
/* eslint-disable */
///
-import type { UpdateCenterBridge } from "@/global/typedefinition";
+import type { SystemInfo, UpdateCenterBridge } from "@/global/typedefinition";
declare module "*.vue" {
import type { DefineComponent } from "vue";
@@ -34,6 +34,7 @@ interface IpcRendererFacade {
// IPC channel type definitions
declare interface IpcChannels {
"get-app-version": () => string;
+ "get-system-info": () => Promise;
"request-flarum-token": (payload: {
identification: string;
password: string;
From 4c2225290cd9be2711afb53cd6c837247bc44274 Mon Sep 17 00:00:00 2001
From: momen
Date: Tue, 19 May 2026 00:44:36 +0800
Subject: [PATCH 14/26] fix(account): record downloads after success
---
src/App.vue | 44 +++-
.../unit/App.download-records.test.ts | 191 ++++++++++++++++++
src/__tests__/unit/AppDetailPage.test.ts | 85 +++++++-
src/__tests__/unit/ReviewsPanel.test.ts | 117 ++++++++++-
src/components/AppDetailPage.vue | 22 +-
src/components/ReviewsPanel.vue | 26 ++-
6 files changed, 465 insertions(+), 20 deletions(-)
create mode 100644 src/__tests__/unit/App.download-records.test.ts
diff --git a/src/App.vue b/src/App.vue
index 9e64dfcf..1bb3eb49 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -314,6 +314,7 @@ import type {
App,
AppJson,
DownloadItem,
+ DownloadResult,
ChannelPayload,
CategoryInfo,
HomeLink,
@@ -327,6 +328,7 @@ import type {
InstalledAppInfo,
ResolvedFavoriteItem,
SystemInfo,
+ DownloadedAppRecord,
} from "./global/typedefinition";
import type { Ref } from "vue";
import type { IpcRendererEvent } from "electron";
@@ -407,6 +409,8 @@ const favoriteLoading = ref(false);
const favoriteError = ref("");
const favoriteRequestGeneration = ref(0);
const systemInfo = ref({ distro: "unknown" });
+type PendingDownloadRecord = Omit;
+const pendingDownloadRecords = new Map();
/** 启动参数 --no-apm => 仅 Spark;--no-spark => 仅 APM;由主进程 IPC 提供 */
const storeFilter = ref<"spark" | "apm" | "both">("both");
@@ -1243,16 +1247,29 @@ const onDetailInstall = async (app: App) => {
const download = await handleInstall(app);
if (!download || !isLoggedIn.value) return;
+ pendingDownloadRecords.set(download.id, {
+ appKey: buildFavoriteAppKey(app),
+ pkgname: app.pkgname,
+ name: app.name,
+ category: app.category,
+ selectedOrigin: app.origin,
+ version: app.version,
+ packageArch: app.arch || parsePackageArch(app.filename),
+ });
+};
+
+const handleInstallCompleteForDownloadRecord = async (
+ _event: IpcRendererEvent,
+ result: DownloadResult,
+) => {
+ const pendingRecord = pendingDownloadRecords.get(result.id);
+ if (!pendingRecord) return;
+
+ pendingDownloadRecords.delete(result.id);
+ if (!result.success || !isLoggedIn.value) return;
+
try {
- await recordDownloadedApp({
- appKey: buildFavoriteAppKey(app),
- pkgname: app.pkgname,
- name: app.name,
- category: app.category,
- selectedOrigin: app.origin,
- version: app.version,
- packageArch: app.arch || parsePackageArch(app.filename),
- });
+ await recordDownloadedApp(pendingRecord);
} catch (error: unknown) {
logger.warn({ err: error }, "记录下载应用失败");
}
@@ -1952,6 +1969,11 @@ onMounted(async () => {
},
);
+ window.ipcRenderer.on(
+ "install-complete",
+ handleInstallCompleteForDownloadRecord,
+ );
+
window.ipcRenderer.on(
"remove-complete",
(_event: IpcRendererEvent, payload: ChannelPayload) => {
@@ -1968,6 +1990,10 @@ onMounted(async () => {
onUnmounted(() => {
updateCenterStore.unbind();
+ window.ipcRenderer.off(
+ "install-complete",
+ handleInstallCompleteForDownloadRecord,
+ );
});
// 观察器
diff --git a/src/__tests__/unit/App.download-records.test.ts b/src/__tests__/unit/App.download-records.test.ts
new file mode 100644
index 00000000..7611fe63
--- /dev/null
+++ b/src/__tests__/unit/App.download-records.test.ts
@@ -0,0 +1,191 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/vue";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import App from "@/App.vue";
+import { recordDownloadedApp } from "@/modules/backendApi";
+import { setAuthSession } from "@/global/authState";
+import type { DownloadResult } from "@/global/typedefinition";
+
+const invoke = vi.fn();
+const send = vi.fn();
+const ipcHandlers = new Map void>();
+
+vi.mock("axios", () => {
+ const get = vi.fn(async (url: string) => {
+ if (url.includes("categories.json")) {
+ return { data: { office: { zh: "办公" } } };
+ }
+ if (url.includes("/office/applist.json")) {
+ return {
+ data: [
+ {
+ Name: "WPS",
+ Pkgname: "wps",
+ Version: "1.0.0",
+ Filename: "wps_1.0.0_amd64.deb",
+ Torrent_address: "",
+ Author: "",
+ Contributor: "",
+ Website: "",
+ Update: "",
+ Size: "",
+ More: "Office suite",
+ Tags: "",
+ img_urls: "[]",
+ icons: "",
+ },
+ ],
+ };
+ }
+ return { data: [] };
+ });
+ const post = vi.fn(async () => ({ data: { ok: true } }));
+
+ return {
+ default: {
+ create: () => ({ get, post }),
+ },
+ };
+});
+
+vi.mock("@/modules/updateCenter", () => ({
+ createUpdateCenterStore: () => ({
+ isOpen: { value: false },
+ showCloseConfirm: { value: false },
+ showMigrationConfirm: { value: false },
+ searchQuery: { value: "" },
+ selectedTaskKeys: { value: new Set() },
+ snapshot: {
+ value: { items: [], tasks: [], warnings: [], hasRunningTasks: false },
+ },
+ filteredItems: { value: [] },
+ allSelected: { value: false },
+ someSelected: { value: false },
+ bind: vi.fn(),
+ unbind: vi.fn(),
+ open: vi.fn(),
+ refresh: vi.fn(),
+ ignoreItem: vi.fn(),
+ unignoreItem: vi.fn(),
+ toggleSelection: vi.fn(),
+ toggleSelectAll: vi.fn(),
+ getSelectedItems: vi.fn(() => []),
+ closeNow: vi.fn(),
+ startSelected: vi.fn(),
+ requestClose: vi.fn(),
+ }),
+}));
+
+vi.mock("@/modules/backendApi", () => ({
+ addFavoriteItem: vi.fn(),
+ bulkDeleteFavoriteItems: vi.fn(),
+ createFavoriteFolder: vi.fn(),
+ exchangeFlarumToken: vi.fn(),
+ listFavoriteFolders: vi.fn(async () => []),
+ listFavoriteItems: vi.fn(async () => []),
+ recordDownloadedApp: vi.fn(async () => undefined),
+ setBackendToken: vi.fn(),
+}));
+
+describe("App download records", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ ipcHandlers.clear();
+ 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: [] };
+ if (channel === "check-installed") return false;
+ return [];
+ });
+
+ Object.assign(window.ipcRenderer, {
+ invoke,
+ send,
+ on: vi.fn((channel: string, handler: (...args: unknown[]) => void) => {
+ ipcHandlers.set(channel, handler);
+ }),
+ off: vi.fn(),
+ });
+ window.apm_store.arch = "amd64";
+ localStorage.clear();
+ setAuthSession({
+ accessToken: "backend-token",
+ tokenType: "bearer",
+ user: {
+ id: 1,
+ flarumUserId: "42",
+ username: "momen",
+ displayName: "Momen",
+ avatarUrl: "https://bbs.spark-app.store/avatar.png",
+ forumLevel: "管理员",
+ forumGroups: ["管理员"],
+ },
+ });
+
+ vi.stubGlobal(
+ "matchMedia",
+ vi.fn(() => ({
+ matches: false,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ })),
+ );
+ vi.stubGlobal("scrollTo", vi.fn());
+ class MockIntersectionObserver {
+ observe = vi.fn();
+ disconnect = vi.fn();
+ unobserve = vi.fn();
+ }
+ vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
+ });
+
+ it("records a download only after the queued install completes successfully", async () => {
+ render(App);
+
+ await fireEvent.click(
+ await screen.findByRole("button", { name: "全部应用 1" }),
+ );
+ await fireEvent.click(await screen.findByText("WPS"));
+ await fireEvent.click(await screen.findByRole("button", { name: "安装" }));
+
+ await waitFor(() => {
+ expect(send).toHaveBeenCalledWith(
+ "queue-install",
+ expect.stringContaining('"pkgname":"wps"'),
+ );
+ });
+ expect(recordDownloadedApp).not.toHaveBeenCalled();
+
+ const queuedPayload = vi
+ .mocked(send)
+ .mock.calls.find(
+ ([channel]) => channel === "queue-install",
+ )?.[1] as string;
+ const queuedDownload = JSON.parse(queuedPayload) as { id: number };
+ const completion: DownloadResult = {
+ id: queuedDownload.id,
+ time: Date.now(),
+ message: "installed",
+ success: true,
+ exitCode: 0,
+ status: "completed",
+ origin: "apm",
+ };
+
+ ipcHandlers.get("install-complete")?.({}, completion);
+
+ await waitFor(() => {
+ expect(recordDownloadedApp).toHaveBeenCalledWith(
+ expect.objectContaining({
+ appKey: "app:office:wps",
+ pkgname: "wps",
+ selectedOrigin: "apm",
+ }),
+ );
+ });
+ });
+});
diff --git a/src/__tests__/unit/AppDetailPage.test.ts b/src/__tests__/unit/AppDetailPage.test.ts
index f758978e..36505fbd 100644
--- a/src/__tests__/unit/AppDetailPage.test.ts
+++ b/src/__tests__/unit/AppDetailPage.test.ts
@@ -1,8 +1,17 @@
import { fireEvent, render, screen } from "@testing-library/vue";
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
import AppDetailPage from "@/components/AppDetailPage.vue";
-import type { App } from "@/global/typedefinition";
+import type { App, ReviewTags } from "@/global/typedefinition";
+
+vi.mock("@/components/ReviewsPanel.vue", () => ({
+ default: {
+ name: "ReviewsPanel",
+ props: ["appKey", "tags", "loggedIn"],
+ template:
+ '',
+ },
+}));
const app: App = {
name: "WPS",
@@ -24,6 +33,40 @@ const app: App = {
currentStatus: "not-installed",
};
+const sparkApp: App = {
+ ...app,
+ name: "WPS Spark",
+ version: "2.0.0",
+ filename: "wps_2.0.0_amd64.deb",
+ origin: "spark",
+};
+
+const apmApp: App = {
+ ...app,
+ name: "WPS APM",
+ version: "1.0.0",
+ filename: "wps_1.0.0_amd64.deb",
+ origin: "apm",
+};
+
+const mergedApp: App = {
+ ...sparkApp,
+ isMerged: true,
+ sparkApp,
+ apmApp,
+ viewingOrigin: "spark",
+};
+
+const sparkTags: ReviewTags = {
+ origin: "spark",
+ category: "office",
+ pkgname: "wps",
+ version: "2.0.0",
+ packageArch: "amd64",
+ clientArch: "amd64",
+ distro: "deepin 25",
+};
+
describe("AppDetailPage", () => {
it("renders as page, emits back, and gates favorite for anonymous users", async () => {
const rendered = render(AppDetailPage, {
@@ -47,4 +90,42 @@ describe("AppDetailPage", () => {
"收藏应用需要登录星火账号。",
);
});
+
+ it("updates review identity when switching a merged app origin", async () => {
+ render(AppDetailPage, {
+ props: {
+ app: mergedApp,
+ screenshots: [],
+ sparkInstalled: false,
+ apmInstalled: false,
+ loggedIn: false,
+ reviewAppKey: "spark:amd64-store:office:wps",
+ reviewTags: sparkTags,
+ },
+ });
+
+ expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
+ "data-app-key",
+ "spark:amd64-store:office:wps",
+ );
+ expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
+ "data-origin",
+ "spark",
+ );
+
+ await fireEvent.click(screen.getByRole("button", { name: "APM" }));
+
+ expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
+ "data-app-key",
+ "apm:amd64-apm:office:wps",
+ );
+ expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
+ "data-origin",
+ "apm",
+ );
+ expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
+ "data-version",
+ "1.0.0",
+ );
+ });
});
diff --git a/src/__tests__/unit/ReviewsPanel.test.ts b/src/__tests__/unit/ReviewsPanel.test.ts
index b8cf0f7a..fd6ef4d7 100644
--- a/src/__tests__/unit/ReviewsPanel.test.ts
+++ b/src/__tests__/unit/ReviewsPanel.test.ts
@@ -1,8 +1,29 @@
import { render, screen } from "@testing-library/vue";
-import { describe, expect, it } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
import ReviewsPanel from "@/components/ReviewsPanel.vue";
-import type { ReviewTags } from "@/global/typedefinition";
+import {
+ fetchRatingSummary,
+ fetchReviews,
+ submitReview,
+} from "@/modules/backendApi";
+import type {
+ AppReview,
+ RatingSummary,
+ ReviewTags,
+} from "@/global/typedefinition";
+
+const emptySummary: RatingSummary = {
+ averageRating: 0,
+ reviewCount: 0,
+ starCounts: {},
+};
+
+vi.mock("@/modules/backendApi", () => ({
+ fetchRatingSummary: vi.fn(async () => emptySummary),
+ fetchReviews: vi.fn(async () => []),
+ submitReview: vi.fn(),
+}));
const tags: ReviewTags = {
origin: "apm",
@@ -15,6 +36,14 @@ const tags: ReviewTags = {
};
describe("ReviewsPanel", () => {
+ beforeEach(() => {
+ vi.mocked(fetchRatingSummary).mockReset();
+ vi.mocked(fetchReviews).mockReset();
+ vi.mocked(submitReview).mockReset();
+ vi.mocked(fetchRatingSummary).mockResolvedValue(emptySummary);
+ vi.mocked(fetchReviews).mockResolvedValue([]);
+ });
+
it("shows anonymous login prompt and read-only review tags", () => {
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: false },
@@ -24,4 +53,88 @@ describe("ReviewsPanel", () => {
expect(screen.getByText("1.0.0")).toBeTruthy();
expect(screen.getByText("deepin 25")).toBeTruthy();
});
+
+ it("ignores stale review responses after app key changes", async () => {
+ let resolveFirstSummary!: (summary: RatingSummary) => void;
+ let resolveFirstReviews!: (reviews: AppReview[]) => void;
+ let resolveSecondSummary!: (summary: RatingSummary) => void;
+ let resolveSecondReviews!: (reviews: AppReview[]) => void;
+
+ vi.mocked(fetchRatingSummary)
+ .mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveFirstSummary = resolve;
+ }),
+ )
+ .mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveSecondSummary = resolve;
+ }),
+ );
+ vi.mocked(fetchReviews)
+ .mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveFirstReviews = resolve;
+ }),
+ )
+ .mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveSecondReviews = resolve;
+ }),
+ );
+
+ const rendered = render(ReviewsPanel, {
+ props: { appKey: "first", tags, loggedIn: false },
+ });
+
+ await rendered.rerender({ appKey: "second", tags, loggedIn: false });
+
+ resolveSecondSummary({ averageRating: 5, reviewCount: 1, starCounts: {} });
+ resolveSecondReviews([
+ {
+ id: 2,
+ rating: 5,
+ content: "second review",
+ version: tags.version,
+ packageArch: tags.packageArch,
+ clientArch: tags.clientArch,
+ distro: tags.distro,
+ origin: tags.origin,
+ category: tags.category,
+ createdAt: "2026-05-18T00:00:00Z",
+ updatedAt: "2026-05-18T00:00:00Z",
+ userDisplayName: "Second User",
+ userAvatarUrl: "",
+ },
+ ]);
+
+ expect(await screen.findByText("second review")).toBeTruthy();
+ expect(screen.getByText("5.0 / 5 (1)")).toBeTruthy();
+
+ resolveFirstSummary({ averageRating: 1, reviewCount: 1, starCounts: {} });
+ resolveFirstReviews([
+ {
+ id: 1,
+ rating: 1,
+ content: "first review",
+ version: tags.version,
+ packageArch: tags.packageArch,
+ clientArch: tags.clientArch,
+ distro: tags.distro,
+ origin: tags.origin,
+ category: tags.category,
+ createdAt: "2026-05-18T00:00:00Z",
+ updatedAt: "2026-05-18T00:00:00Z",
+ userDisplayName: "First User",
+ userAvatarUrl: "",
+ },
+ ]);
+
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ expect(screen.getByText("second review")).toBeTruthy();
+ expect(screen.getByText("5.0 / 5 (1)")).toBeTruthy();
+ expect(screen.queryByText("first review")).toBeNull();
+ expect(screen.queryByText("1.0 / 5 (1)")).toBeNull();
+ });
});
diff --git a/src/components/AppDetailPage.vue b/src/components/AppDetailPage.vue
index ad710083..e3b26ca4 100644
--- a/src/components/AppDetailPage.vue
+++ b/src/components/AppDetailPage.vue
@@ -214,7 +214,11 @@ import {
APM_STORE_BASE_URL,
getHybridDefaultOrigin,
} from "@/global/storeConfig";
-import { getDisplayApp } from "@/modules/appIdentity";
+import {
+ buildReviewAppKey,
+ buildReviewTags,
+ getDisplayApp,
+} from "@/modules/appIdentity";
import type { App, ReviewTags } from "@/global/typedefinition";
const props = defineProps<{
@@ -299,6 +303,22 @@ const detailHtml = computed(
() => displayApp.value?.more.replace(/\n/g, "
") ?? "",
);
+const reviewAppKey = computed(() => {
+ if (!displayApp.value) return "";
+ return buildReviewAppKey(
+ displayApp.value,
+ props.reviewTags?.clientArch ?? "amd64",
+ );
+});
+
+const reviewTags = computed(() => {
+ if (!displayApp.value || !props.reviewTags) return null;
+ return buildReviewTags(displayApp.value, {
+ clientArch: props.reviewTags.clientArch,
+ distro: props.reviewTags.distro,
+ });
+});
+
const selectOrigin = (origin: "spark" | "apm") => {
viewingOrigin.value = origin;
if (displayApp.value) emit("check-install", displayApp.value);
diff --git a/src/components/ReviewsPanel.vue b/src/components/ReviewsPanel.vue
index 2bd0ec10..eefc2382 100644
--- a/src/components/ReviewsPanel.vue
+++ b/src/components/ReviewsPanel.vue
@@ -145,6 +145,7 @@ const summary = ref(null);
const loading = ref(false);
const submitting = ref(false);
const error = ref("");
+const loadGeneration = ref(0);
const ratingText = computed(() => {
if (!summary.value || summary.value.reviewCount === 0) return "暂无评分";
@@ -153,37 +154,50 @@ const ratingText = computed(() => {
const loadReviews = async () => {
if (!props.appKey) return;
+ const generation = loadGeneration.value + 1;
+ loadGeneration.value = generation;
+ const appKey = props.appKey;
loading.value = true;
error.value = "";
try {
const [nextSummary, nextReviews] = await Promise.all([
- fetchRatingSummary(props.appKey),
- fetchReviews(props.appKey),
+ fetchRatingSummary(appKey),
+ fetchReviews(appKey),
]);
+ if (generation !== loadGeneration.value || appKey !== props.appKey) return;
summary.value = nextSummary;
reviews.value = nextReviews;
} catch (caught: unknown) {
+ if (generation !== loadGeneration.value || appKey !== props.appKey) return;
error.value = (caught as Error)?.message || "加载评价失败";
} finally {
- loading.value = false;
+ if (generation === loadGeneration.value && appKey === props.appKey) {
+ loading.value = false;
+ }
}
};
const submit = async () => {
+ const appKey = props.appKey;
+ const tags = props.tags;
submitting.value = true;
error.value = "";
try {
- await submitReview(props.appKey, {
+ await submitReview(appKey, {
rating: rating.value,
content: content.value.trim(),
- tags: props.tags,
+ tags,
});
+ if (appKey !== props.appKey) return;
content.value = "";
await loadReviews();
} catch (caught: unknown) {
+ if (appKey !== props.appKey) return;
error.value = (caught as Error)?.message || "发表评论失败";
} finally {
- submitting.value = false;
+ if (appKey === props.appKey) {
+ submitting.value = false;
+ }
}
};
From 4b81869b6e4751140059eb35dd6324a10c2868a4 Mon Sep 17 00:00:00 2001
From: momen
Date: Tue, 19 May 2026 00:54:36 +0800
Subject: [PATCH 15/26] fix(account): keep download record pending through
retry
---
src/App.vue | 3 +-
.../unit/App.download-records.test.ts | 85 ++++++++++++++++++-
2 files changed, 85 insertions(+), 3 deletions(-)
diff --git a/src/App.vue b/src/App.vue
index 1bb3eb49..b070ea5a 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1265,13 +1265,14 @@ const handleInstallCompleteForDownloadRecord = async (
const pendingRecord = pendingDownloadRecords.get(result.id);
if (!pendingRecord) return;
- pendingDownloadRecords.delete(result.id);
if (!result.success || !isLoggedIn.value) return;
try {
await recordDownloadedApp(pendingRecord);
} catch (error: unknown) {
logger.warn({ err: error }, "记录下载应用失败");
+ } finally {
+ pendingDownloadRecords.delete(result.id);
}
};
diff --git a/src/__tests__/unit/App.download-records.test.ts b/src/__tests__/unit/App.download-records.test.ts
index 7611fe63..f101275c 100644
--- a/src/__tests__/unit/App.download-records.test.ts
+++ b/src/__tests__/unit/App.download-records.test.ts
@@ -1,9 +1,16 @@
-import { fireEvent, render, screen, waitFor } from "@testing-library/vue";
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/vue";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import App from "@/App.vue";
import { recordDownloadedApp } from "@/modules/backendApi";
import { setAuthSession } from "@/global/authState";
+import { downloads } from "@/global/downloadStatus";
import type { DownloadResult } from "@/global/typedefinition";
const invoke = vi.fn();
@@ -88,9 +95,14 @@ vi.mock("@/modules/backendApi", () => ({
}));
describe("App download records", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
beforeEach(() => {
vi.clearAllMocks();
ipcHandlers.clear();
+ downloads.value = [];
invoke.mockImplementation(async (channel: string) => {
if (channel === "get-store-filter") return "apm";
if (channel === "check-spark-available") return false;
@@ -188,4 +200,73 @@ describe("App download records", () => {
);
});
});
+
+ it("keeps a pending download record through a failed install retry", async () => {
+ render(App);
+
+ await fireEvent.click(
+ await screen.findByRole("button", { name: "全部应用 1" }),
+ );
+ await fireEvent.click(await screen.findByText("WPS"));
+ await fireEvent.click(await screen.findByRole("button", { name: "安装" }));
+
+ await waitFor(() => {
+ expect(send).toHaveBeenCalledWith(
+ "queue-install",
+ expect.stringContaining('"pkgname":"wps"'),
+ );
+ });
+
+ const queuedPayload = vi
+ .mocked(send)
+ .mock.calls.find(
+ ([channel]) => channel === "queue-install",
+ )?.[1] as string;
+ const queuedDownload = JSON.parse(queuedPayload) as { id: number };
+ const failedCompletion: DownloadResult = {
+ id: queuedDownload.id,
+ time: Date.now(),
+ message: "failed",
+ success: false,
+ exitCode: 1,
+ status: "failed",
+ origin: "apm",
+ };
+
+ ipcHandlers.get("install-complete")?.({}, failedCompletion);
+ downloads.value[0].status = "failed";
+
+ await waitFor(() => {
+ expect(recordDownloadedApp).not.toHaveBeenCalled();
+ expect(screen.getByTitle("重试")).toBeInTheDocument();
+ });
+
+ await fireEvent.click(screen.getByTitle("重试"));
+
+ const successfulCompletion: DownloadResult = {
+ id: queuedDownload.id,
+ time: Date.now(),
+ message: "installed",
+ success: true,
+ exitCode: 0,
+ status: "completed",
+ origin: "apm",
+ };
+
+ ipcHandlers.get("install-complete")?.({}, successfulCompletion);
+
+ await waitFor(() => {
+ expect(recordDownloadedApp).toHaveBeenCalledTimes(1);
+ expect(recordDownloadedApp).toHaveBeenCalledWith(
+ expect.objectContaining({
+ appKey: "app:office:wps",
+ pkgname: "wps",
+ name: "WPS",
+ category: "office",
+ selectedOrigin: "apm",
+ version: "1.0.0",
+ }),
+ );
+ });
+ });
});
From b839e0770cf4f4fe4bc9916ccbe7e0389549b671 Mon Sep 17 00:00:00 2001
From: momen
Date: Tue, 19 May 2026 01:02:53 +0800
Subject: [PATCH 16/26] fix(account): bind pending downloads to user
---
src/App.vue | 32 +++++++++--
.../unit/App.download-records.test.ts | 57 +++++++++++++++++++
2 files changed, 85 insertions(+), 4 deletions(-)
diff --git a/src/App.vue b/src/App.vue
index b070ea5a..60867b38 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -409,7 +409,12 @@ const favoriteLoading = ref(false);
const favoriteError = ref("");
const favoriteRequestGeneration = ref(0);
const systemInfo = ref({ distro: "unknown" });
-type PendingDownloadRecord = Omit;
+type PendingDownloadRecord = Omit<
+ DownloadedAppRecord,
+ "id" | "downloadedAt"
+> & {
+ userId: number;
+};
const pendingDownloadRecords = new Map();
/** 启动参数 --no-apm => 仅 Spark;--no-spark => 仅 APM;由主进程 IPC 提供 */
@@ -1245,9 +1250,11 @@ const onDetailRemove = (app: App) => {
const onDetailInstall = async (app: App) => {
const download = await handleInstall(app);
- if (!download || !isLoggedIn.value) return;
+ const initiatingUserId = currentUser.value?.id;
+ if (!download || !isLoggedIn.value || initiatingUserId === undefined) return;
pendingDownloadRecords.set(download.id, {
+ userId: initiatingUserId,
appKey: buildFavoriteAppKey(app),
pkgname: app.pkgname,
name: app.name,
@@ -1265,10 +1272,26 @@ const handleInstallCompleteForDownloadRecord = async (
const pendingRecord = pendingDownloadRecords.get(result.id);
if (!pendingRecord) return;
- if (!result.success || !isLoggedIn.value) return;
+ if (
+ !result.success ||
+ !isLoggedIn.value ||
+ currentUser.value?.id !== pendingRecord.userId
+ ) {
+ return;
+ }
+
+ const downloadRecord: Omit = {
+ appKey: pendingRecord.appKey,
+ pkgname: pendingRecord.pkgname,
+ name: pendingRecord.name,
+ category: pendingRecord.category,
+ selectedOrigin: pendingRecord.selectedOrigin,
+ version: pendingRecord.version,
+ packageArch: pendingRecord.packageArch,
+ };
try {
- await recordDownloadedApp(pendingRecord);
+ await recordDownloadedApp(downloadRecord);
} catch (error: unknown) {
logger.warn({ err: error }, "记录下载应用失败");
} finally {
@@ -1372,6 +1395,7 @@ const isCurrentFavoriteRequest = (generation: number): boolean =>
const handleLogout = () => {
logout();
+ pendingDownloadRecords.clear();
clearFavoriteState();
showLoginModal.value = false;
showLoginPrompt.value = false;
diff --git a/src/__tests__/unit/App.download-records.test.ts b/src/__tests__/unit/App.download-records.test.ts
index f101275c..726aa1f3 100644
--- a/src/__tests__/unit/App.download-records.test.ts
+++ b/src/__tests__/unit/App.download-records.test.ts
@@ -269,4 +269,61 @@ describe("App download records", () => {
);
});
});
+
+ it("does not record a queued install under a later logged-in user", async () => {
+ render(App);
+
+ await fireEvent.click(
+ await screen.findByRole("button", { name: "全部应用 1" }),
+ );
+ await fireEvent.click(await screen.findByText("WPS"));
+ await fireEvent.click(await screen.findByRole("button", { name: "安装" }));
+
+ await waitFor(() => {
+ expect(send).toHaveBeenCalledWith(
+ "queue-install",
+ expect.stringContaining('"pkgname":"wps"'),
+ );
+ });
+
+ const queuedPayload = vi
+ .mocked(send)
+ .mock.calls.find(
+ ([channel]) => channel === "queue-install",
+ )?.[1] as string;
+ const queuedDownload = JSON.parse(queuedPayload) as { id: number };
+
+ await fireEvent.click(screen.getByRole("button", { name: "Momen" }));
+ await fireEvent.click(await screen.findByText("退出登录"));
+
+ setAuthSession({
+ accessToken: "backend-token-b",
+ tokenType: "bearer",
+ user: {
+ id: 2,
+ flarumUserId: "84",
+ username: "second",
+ displayName: "Second User",
+ avatarUrl: "https://bbs.spark-app.store/avatar-b.png",
+ forumLevel: "用户",
+ forumGroups: ["用户"],
+ },
+ });
+
+ const completion: DownloadResult = {
+ id: queuedDownload.id,
+ time: Date.now(),
+ message: "installed",
+ success: true,
+ exitCode: 0,
+ status: "completed",
+ origin: "apm",
+ };
+
+ ipcHandlers.get("install-complete")?.({}, completion);
+
+ await waitFor(() => {
+ expect(recordDownloadedApp).not.toHaveBeenCalled();
+ });
+ });
});
From f280039874a08b158dc7dea77ef2d0eeb9d1264c Mon Sep 17 00:00:00 2001
From: momen
Date: Tue, 19 May 2026 01:10:17 +0800
Subject: [PATCH 17/26] fix(account): guard download record user races
---
src/App.vue | 17 +-
.../unit/App.download-records.test.ts | 175 +++++++++++++++---
2 files changed, 162 insertions(+), 30 deletions(-)
diff --git a/src/App.vue b/src/App.vue
index 60867b38..ff021ac3 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1249,9 +1249,16 @@ const onDetailRemove = (app: App) => {
};
const onDetailInstall = async (app: App) => {
+ const initiatingUserId = currentUser.value?.id ?? null;
const download = await handleInstall(app);
- const initiatingUserId = currentUser.value?.id;
- if (!download || !isLoggedIn.value || initiatingUserId === undefined) return;
+ if (
+ !download ||
+ initiatingUserId === null ||
+ !isLoggedIn.value ||
+ currentUser.value?.id !== initiatingUserId
+ ) {
+ return;
+ }
pendingDownloadRecords.set(download.id, {
userId: initiatingUserId,
@@ -1272,6 +1279,10 @@ const handleInstallCompleteForDownloadRecord = async (
const pendingRecord = pendingDownloadRecords.get(result.id);
if (!pendingRecord) return;
+ if (result.success) {
+ pendingDownloadRecords.delete(result.id);
+ }
+
if (
!result.success ||
!isLoggedIn.value ||
@@ -1294,8 +1305,6 @@ const handleInstallCompleteForDownloadRecord = async (
await recordDownloadedApp(downloadRecord);
} catch (error: unknown) {
logger.warn({ err: error }, "记录下载应用失败");
- } finally {
- pendingDownloadRecords.delete(result.id);
}
};
diff --git a/src/__tests__/unit/App.download-records.test.ts b/src/__tests__/unit/App.download-records.test.ts
index 726aa1f3..00756522 100644
--- a/src/__tests__/unit/App.download-records.test.ts
+++ b/src/__tests__/unit/App.download-records.test.ts
@@ -17,6 +17,46 @@ const invoke = vi.fn();
const send = vi.fn();
const ipcHandlers = new Map void>();
+const setSecondUserSession = () => {
+ setAuthSession({
+ accessToken: "backend-token-b",
+ tokenType: "bearer",
+ user: {
+ id: 2,
+ flarumUserId: "84",
+ username: "second",
+ displayName: "Second User",
+ avatarUrl: "https://bbs.spark-app.store/avatar-b.png",
+ forumLevel: "用户",
+ forumGroups: ["用户"],
+ },
+ });
+};
+
+const setInitialUserSession = () => {
+ setAuthSession({
+ accessToken: "backend-token",
+ tokenType: "bearer",
+ user: {
+ id: 1,
+ flarumUserId: "42",
+ username: "momen",
+ displayName: "Momen",
+ avatarUrl: "https://bbs.spark-app.store/avatar.png",
+ forumLevel: "管理员",
+ forumGroups: ["管理员"],
+ },
+ });
+};
+
+const createControlledPromise = () => {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((promiseResolve) => {
+ resolve = promiseResolve;
+ });
+ return { promise, resolve };
+};
+
vi.mock("axios", () => {
const get = vi.fn(async (url: string) => {
if (url.includes("categories.json")) {
@@ -124,19 +164,7 @@ describe("App download records", () => {
});
window.apm_store.arch = "amd64";
localStorage.clear();
- setAuthSession({
- accessToken: "backend-token",
- tokenType: "bearer",
- user: {
- id: 1,
- flarumUserId: "42",
- username: "momen",
- displayName: "Momen",
- avatarUrl: "https://bbs.spark-app.store/avatar.png",
- forumLevel: "管理员",
- forumGroups: ["管理员"],
- },
- });
+ setInitialUserSession();
vi.stubGlobal(
"matchMedia",
@@ -296,19 +324,7 @@ describe("App download records", () => {
await fireEvent.click(screen.getByRole("button", { name: "Momen" }));
await fireEvent.click(await screen.findByText("退出登录"));
- setAuthSession({
- accessToken: "backend-token-b",
- tokenType: "bearer",
- user: {
- id: 2,
- flarumUserId: "84",
- username: "second",
- displayName: "Second User",
- avatarUrl: "https://bbs.spark-app.store/avatar-b.png",
- forumLevel: "用户",
- forumGroups: ["用户"],
- },
- });
+ setSecondUserSession();
const completion: DownloadResult = {
id: queuedDownload.id,
@@ -326,4 +342,111 @@ describe("App download records", () => {
expect(recordDownloadedApp).not.toHaveBeenCalled();
});
});
+
+ it("does not bind a queued install to a user who logged in during the APM availability check", async () => {
+ const apmCheck = createControlledPromise();
+ let apmCheckCalls = 0;
+ invoke.mockImplementation(async (channel: string) => {
+ if (channel === "get-store-filter") return "apm";
+ if (channel === "check-spark-available") return false;
+ if (channel === "check-apm-available") {
+ apmCheckCalls += 1;
+ return apmCheckCalls === 1 ? true : apmCheck.promise;
+ }
+ 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: [] };
+ if (channel === "check-installed") return false;
+ return [];
+ });
+ render(App);
+
+ await fireEvent.click(
+ await screen.findByRole("button", { name: "全部应用 1" }),
+ );
+ await fireEvent.click(await screen.findByText("WPS"));
+ await fireEvent.click(await screen.findByRole("button", { name: "安装" }));
+
+ await waitFor(() => {
+ expect(apmCheckCalls).toBe(2);
+ });
+ setSecondUserSession();
+ apmCheck.resolve(true);
+
+ await waitFor(() => {
+ expect(send).toHaveBeenCalledWith(
+ "queue-install",
+ expect.stringContaining('"pkgname":"wps"'),
+ );
+ });
+
+ const queuedPayload = vi
+ .mocked(send)
+ .mock.calls.find(
+ ([channel]) => channel === "queue-install",
+ )?.[1] as string;
+ const queuedDownload = JSON.parse(queuedPayload) as { id: number };
+ const completion: DownloadResult = {
+ id: queuedDownload.id,
+ time: Date.now(),
+ message: "installed",
+ success: true,
+ exitCode: 0,
+ status: "completed",
+ origin: "apm",
+ };
+
+ ipcHandlers.get("install-complete")?.({}, completion);
+
+ await waitFor(() => {
+ expect(recordDownloadedApp).not.toHaveBeenCalled();
+ });
+ });
+
+ it("cleans up a successful pending record even when the active user does not match", async () => {
+ render(App);
+
+ await fireEvent.click(
+ await screen.findByRole("button", { name: "全部应用 1" }),
+ );
+ await fireEvent.click(await screen.findByText("WPS"));
+ await fireEvent.click(await screen.findByRole("button", { name: "安装" }));
+
+ await waitFor(() => {
+ expect(send).toHaveBeenCalledWith(
+ "queue-install",
+ expect.stringContaining('"pkgname":"wps"'),
+ );
+ });
+
+ const queuedPayload = vi
+ .mocked(send)
+ .mock.calls.find(
+ ([channel]) => channel === "queue-install",
+ )?.[1] as string;
+ const queuedDownload = JSON.parse(queuedPayload) as { id: number };
+ const completion: DownloadResult = {
+ id: queuedDownload.id,
+ time: Date.now(),
+ message: "installed",
+ success: true,
+ exitCode: 0,
+ status: "completed",
+ origin: "apm",
+ };
+
+ setSecondUserSession();
+ ipcHandlers.get("install-complete")?.({}, completion);
+
+ await waitFor(() => {
+ expect(recordDownloadedApp).not.toHaveBeenCalled();
+ });
+
+ setInitialUserSession();
+ ipcHandlers.get("install-complete")?.({}, completion);
+
+ await waitFor(() => {
+ expect(recordDownloadedApp).not.toHaveBeenCalled();
+ });
+ });
});
From bbd9cbccb71aac8a684943c00ec50c028ac264fa Mon Sep 17 00:00:00 2001
From: momen
Date: Tue, 19 May 2026 01:17:58 +0800
Subject: [PATCH 18/26] feat(account): add user management view
---
src/App.vue | 63 ++++--
src/__tests__/unit/UserManagementView.test.ts | 48 +++++
src/components/UserManagementView.vue | 184 ++++++++++++++++++
src/global/accountSyncState.ts | 26 +++
4 files changed, 309 insertions(+), 12 deletions(-)
create mode 100644 src/__tests__/unit/UserManagementView.test.ts
create mode 100644 src/components/UserManagementView.vue
create mode 100644 src/global/accountSyncState.ts
diff --git a/src/App.vue b/src/App.vue
index ff021ac3..e2c98bb3 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -84,17 +84,19 @@
@check-install="checkAppInstalled"
/>
-
-
- 用户管理
-
-
- 账号资料与安全设置功能即将开放。
-
-
+
(null);
const favoriteLoading = ref(false);
const favoriteError = ref("");
const favoriteRequestGeneration = ref(0);
+const downloadedApps = ref([]);
+const downloadedLoading = ref(false);
+const downloadedError = ref("");
const systemInfo = ref({ distro: "unknown" });
type PendingDownloadRecord = Omit<
DownloadedAppRecord,
@@ -1399,6 +1410,12 @@ const clearFavoriteState = () => {
favoriteError.value = "";
};
+const clearDownloadedState = () => {
+ downloadedApps.value = [];
+ downloadedLoading.value = false;
+ downloadedError.value = "";
+};
+
const isCurrentFavoriteRequest = (generation: number): boolean =>
favoriteRequestGeneration.value === generation && isLoggedIn.value;
@@ -1406,6 +1423,7 @@ const handleLogout = () => {
logout();
pendingDownloadRecords.clear();
clearFavoriteState();
+ clearDownloadedState();
showLoginModal.value = false;
showLoginPrompt.value = false;
isSidebarOpen.value = false;
@@ -1435,12 +1453,33 @@ const handleFlarumLogin = async (payload: FlarumLoginPayload) => {
}
};
-const openUserManagement = () => {
+const loadDownloadedHistory = async (): Promise => {
+ if (!requireLogin("请登录后查看和管理账号信息。")) return;
+
+ downloadedLoading.value = true;
+ downloadedError.value = "";
+ try {
+ const result = await listDownloadedApps(1, 50);
+ downloadedApps.value = result.items;
+ } catch (error: unknown) {
+ downloadedApps.value = [];
+ downloadedError.value = (error as Error)?.message || "读取下载历史失败";
+ } finally {
+ downloadedLoading.value = false;
+ }
+};
+
+const syncInstalledAppsNow = () => {
+ logger.warn("已安装应用同步将在后续任务中启用");
+};
+
+const openUserManagement = async () => {
if (!requireLogin("请登录后查看和管理账号信息。")) return;
currentView.value = "account";
activeTab.value = "account";
isSidebarOpen.value = false;
showLoginPrompt.value = false;
+ await loadDownloadedHistory();
};
const loadFavoriteFolders = async (
diff --git a/src/__tests__/unit/UserManagementView.test.ts b/src/__tests__/unit/UserManagementView.test.ts
new file mode 100644
index 00000000..d9811cb9
--- /dev/null
+++ b/src/__tests__/unit/UserManagementView.test.ts
@@ -0,0 +1,48 @@
+import { render, screen } from "@testing-library/vue";
+import { describe, expect, it } from "vitest";
+
+import UserManagementView from "@/components/UserManagementView.vue";
+import type { DownloadedAppRecord, SparkUser } from "@/global/typedefinition";
+
+const user: SparkUser = {
+ id: 1,
+ flarumUserId: "123",
+ username: "momen",
+ displayName: "Momen",
+ avatarUrl: "https://bbs.spark-app.store/avatar.png",
+ forumLevel: "管理员",
+ forumGroups: ["管理员"],
+};
+
+const download: DownloadedAppRecord = {
+ id: 1,
+ appKey: "app:office:wps",
+ pkgname: "wps",
+ name: "WPS",
+ category: "office",
+ selectedOrigin: "apm",
+ version: "1.0.0",
+ packageArch: "amd64",
+ downloadedAt: "2026-05-18T00:00:00Z",
+};
+
+describe("UserManagementView", () => {
+ it("renders profile, forum level, links, downloads, and sync preference", () => {
+ render(UserManagementView, {
+ props: {
+ user,
+ downloadedApps: [download],
+ syncEnabled: true,
+ loading: false,
+ error: "",
+ },
+ });
+
+ expect(screen.getByText("Momen")).toBeTruthy();
+ expect(screen.getByText("管理员")).toBeTruthy();
+ expect(screen.getByText("论坛首页")).toBeTruthy();
+ expect(screen.getByText("修改论坛资料")).toBeTruthy();
+ expect(screen.getByText("WPS")).toBeTruthy();
+ expect(screen.getByLabelText("自动同步已安装应用")).toBeChecked();
+ });
+});
diff --git a/src/components/UserManagementView.vue b/src/components/UserManagementView.vue
new file mode 100644
index 00000000..0f7711c8
--- /dev/null
+++ b/src/components/UserManagementView.vue
@@ -0,0 +1,184 @@
+
+
+
+
+
![]()
+
+ {{ userInitial }}
+
+
+
+ 用户管理
+
+
+ {{ user.displayName }}
+
+
+ @{{ user.username }}
+
+
+ {{ user.forumLevel }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ group }}
+
+
+
+
+
+
+
+
+
+
+
+
+ 下载历史
+
+
+ 最近通过当前账号记录的应用安装历史。
+
+
+
+
+
+
+ 正在加载下载历史...
+
+
+ {{ error }}
+
+
+ 暂无下载记录。
+
+
+
+
+
+
+
diff --git a/src/global/accountSyncState.ts b/src/global/accountSyncState.ts
new file mode 100644
index 00000000..acb0846e
--- /dev/null
+++ b/src/global/accountSyncState.ts
@@ -0,0 +1,26 @@
+import { ref, watch } from "vue";
+
+const INSTALLED_SYNC_STORAGE_KEY = "spark-store-installed-sync-enabled";
+
+const readSyncEnabled = (): boolean | null => {
+ const savedValue = localStorage.getItem(INSTALLED_SYNC_STORAGE_KEY);
+ if (savedValue === "true") return true;
+ if (savedValue === "false") return false;
+ return null;
+};
+
+export const installedSyncEnabled = ref(readSyncEnabled());
+
+export const setInstalledSyncEnabled = (enabled: boolean): void => {
+ installedSyncEnabled.value = enabled;
+ localStorage.setItem(INSTALLED_SYNC_STORAGE_KEY, String(enabled));
+};
+
+watch(installedSyncEnabled, (enabled) => {
+ if (enabled === null) {
+ localStorage.removeItem(INSTALLED_SYNC_STORAGE_KEY);
+ return;
+ }
+
+ localStorage.setItem(INSTALLED_SYNC_STORAGE_KEY, String(enabled));
+});
From ac1f46bd73774954277bdacf3d23728050707d3a Mon Sep 17 00:00:00 2001
From: momen
Date: Tue, 19 May 2026 01:33:34 +0800
Subject: [PATCH 19/26] fix(account): ignore stale downloaded history
---
src/App.vue | 18 +++-
.../unit/App.account-placeholders.test.ts | 90 ++++++++++++++++++-
2 files changed, 105 insertions(+), 3 deletions(-)
diff --git a/src/App.vue b/src/App.vue
index e2c98bb3..1bd1927a 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -419,6 +419,7 @@ const favoriteRequestGeneration = ref(0);
const downloadedApps = ref([]);
const downloadedLoading = ref(false);
const downloadedError = ref("");
+const downloadedRequestGeneration = ref(0);
const systemInfo = ref({ distro: "unknown" });
type PendingDownloadRecord = Omit<
DownloadedAppRecord,
@@ -1411,6 +1412,7 @@ const clearFavoriteState = () => {
};
const clearDownloadedState = () => {
+ downloadedRequestGeneration.value += 1;
downloadedApps.value = [];
downloadedLoading.value = false;
downloadedError.value = "";
@@ -1419,6 +1421,13 @@ const clearDownloadedState = () => {
const isCurrentFavoriteRequest = (generation: number): boolean =>
favoriteRequestGeneration.value === generation && isLoggedIn.value;
+const isCurrentDownloadedRequest = (
+ generation: number,
+ userId: number,
+): boolean =>
+ downloadedRequestGeneration.value === generation &&
+ currentUser.value?.id === userId;
+
const handleLogout = () => {
logout();
pendingDownloadRecords.clear();
@@ -1455,17 +1464,24 @@ const handleFlarumLogin = async (payload: FlarumLoginPayload) => {
const loadDownloadedHistory = async (): Promise => {
if (!requireLogin("请登录后查看和管理账号信息。")) return;
+ const userId = currentUser.value?.id;
+ if (userId === undefined) return;
+ const generation = downloadedRequestGeneration.value;
downloadedLoading.value = true;
downloadedError.value = "";
try {
const result = await listDownloadedApps(1, 50);
+ if (!isCurrentDownloadedRequest(generation, userId)) return;
downloadedApps.value = result.items;
} catch (error: unknown) {
+ if (!isCurrentDownloadedRequest(generation, userId)) return;
downloadedApps.value = [];
downloadedError.value = (error as Error)?.message || "读取下载历史失败";
} finally {
- downloadedLoading.value = false;
+ if (isCurrentDownloadedRequest(generation, userId)) {
+ downloadedLoading.value = false;
+ }
}
};
diff --git a/src/__tests__/unit/App.account-placeholders.test.ts b/src/__tests__/unit/App.account-placeholders.test.ts
index 45c89532..bd683dcc 100644
--- a/src/__tests__/unit/App.account-placeholders.test.ts
+++ b/src/__tests__/unit/App.account-placeholders.test.ts
@@ -2,9 +2,13 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import App from "@/App.vue";
-import { listFavoriteFolders } from "@/modules/backendApi";
+import { listDownloadedApps, listFavoriteFolders } from "@/modules/backendApi";
import { setAuthSession } from "@/global/authState";
-import type { FavoriteFolder, FavoriteItem } from "@/global/typedefinition";
+import type {
+ DownloadedAppList,
+ FavoriteFolder,
+ FavoriteItem,
+} from "@/global/typedefinition";
const invoke = vi.fn();
@@ -39,6 +43,31 @@ const createDeferred = () => {
return { promise, resolve };
};
+const downloadedList = (
+ items: DownloadedAppList["items"],
+): DownloadedAppList => ({
+ items,
+ total: items.length,
+ page: 1,
+ pageSize: 50,
+});
+
+const setSecondUserSession = () => {
+ setAuthSession({
+ accessToken: "backend-token-b",
+ tokenType: "bearer",
+ user: {
+ id: 2,
+ flarumUserId: "84",
+ username: "second",
+ displayName: "Second User",
+ avatarUrl: "https://bbs.spark-app.store/avatar-b.png",
+ forumLevel: "用户",
+ forumGroups: ["用户"],
+ },
+ });
+};
+
vi.mock("axios", () => {
const get = vi.fn(async (url: string) => {
if (url.includes("categories.json")) {
@@ -109,6 +138,7 @@ vi.mock("@/modules/backendApi", () => ({
bulkDeleteFavoriteItems: vi.fn(),
createFavoriteFolder: vi.fn(),
exchangeFlarumToken: vi.fn(),
+ listDownloadedApps: vi.fn(async () => downloadedList([])),
listFavoriteFolders: vi.fn(async () => favoriteFolders),
listFavoriteItems: vi.fn(async () => favoriteItems),
setBackendToken: vi.fn(),
@@ -351,4 +381,60 @@ describe("App account placeholders", () => {
expect(screen.queryByText("旧账号收藏夹")).toBeNull();
expect(screen.queryByRole("dialog", { name: "选择收藏夹" })).toBeNull();
});
+
+ it("ignores downloaded history that resolves after switching users", async () => {
+ const firstHistory = createDeferred();
+ const secondHistory = createDeferred();
+ vi.mocked(listDownloadedApps)
+ .mockReturnValueOnce(firstHistory.promise)
+ .mockReturnValueOnce(secondHistory.promise);
+ render(App);
+
+ await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
+ await fireEvent.click(screen.getByText("用户管理"));
+ 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("退出登录"));
+ setSecondUserSession();
+
+ const secondUserButton = await screen.findByRole("button", {
+ name: /^Second User$/,
+ });
+ if (!screen.queryByText("用户管理")) {
+ await fireEvent.click(secondUserButton);
+ }
+ await fireEvent.click(await screen.findByText("用户管理"));
+ secondHistory.resolve(downloadedList([]));
+ expect(await screen.findByText("暂无下载记录。")).toBeTruthy();
+
+ firstHistory.resolve(
+ downloadedList([
+ {
+ id: 77,
+ appKey: "app:office:old-account-app",
+ pkgname: "old-account-app",
+ name: "旧账号应用",
+ category: "office",
+ selectedOrigin: "apm",
+ version: "1.0.0",
+ packageArch: "amd64",
+ downloadedAt: "2026-05-18T00:00:00Z",
+ },
+ ]),
+ );
+ await firstHistory.promise;
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(screen.queryByText("旧账号应用")).toBeNull();
+ expect(screen.getByText("暂无下载记录。")).toBeTruthy();
+ });
});
From acffb6c5eeea51b0222c7c37b51cacb0cbca1765 Mon Sep 17 00:00:00 2001
From: momen
Date: Tue, 19 May 2026 01:43:28 +0800
Subject: [PATCH 20/26] feat(sync): add installed app cloud sync
---
src/App.vue | 132 +++++++++++++-
.../unit/AppListRestoreModal.test.ts | 57 ++++++
src/__tests__/unit/InstalledAppsModal.test.ts | 76 ++++++++
src/__tests__/unit/appListSync.test.ts | 74 ++++++++
src/components/AppListRestoreModal.vue | 172 ++++++++++++++++++
src/components/InstalledAppsModal.vue | 32 +++-
src/modules/appListSync.ts | 30 +++
7 files changed, 570 insertions(+), 3 deletions(-)
create mode 100644 src/__tests__/unit/AppListRestoreModal.test.ts
create mode 100644 src/__tests__/unit/appListSync.test.ts
create mode 100644 src/components/AppListRestoreModal.vue
create mode 100644 src/modules/appListSync.ts
diff --git a/src/App.vue b/src/App.vue
index 1bd1927a..170cd6ec 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -170,12 +170,27 @@
:store-filter="storeFilter"
:spark-available="sparkAvailable"
:apm-available="apmAvailable"
+ :logged-in="isLoggedIn"
+ :syncing="syncLoading"
@close="closeInstalledModal"
@refresh="refreshInstalledApps"
@open-app="openDownloadedApp($event.pkgname, $event.origin)"
@open-detail="openDetail"
@uninstall="uninstallInstalledApp"
@switch-origin="handleSwitchOrigin"
+ @sync-to-account="syncInstalledAppsToAccount"
+ @restore-from-account="openRestoreFromAccount"
+ @request-login="requireLogin('云端同步需要登录星火账号。')"
+ />
+
+
([]);
const downloadedLoading = ref(false);
const downloadedError = ref("");
const downloadedRequestGeneration = ref(0);
+const syncLoading = ref(false);
+const restoreLoading = ref(false);
+const restoreError = ref("");
+const showRestoreModal = ref(false);
+const restoreItems = ref([]);
+const restoreRequestGeneration = ref(0);
+const installedSyncPromptShown = ref(false);
const systemInfo = ref({ distro: "unknown" });
type PendingDownloadRecord = Omit<
DownloadedAppRecord,
@@ -540,6 +567,10 @@ const resolvedFavoriteItems = computed(() =>
),
);
+const installedCloudKeys = computed(
+ () => new Set(installedApps.value.map((app) => cloudItemKey(app))),
+);
+
// 方法
const syncThemePreference = () => {
document.documentElement.classList.toggle("dark", isDarkTheme.value);
@@ -1418,6 +1449,14 @@ const clearDownloadedState = () => {
downloadedError.value = "";
};
+const clearRestoreState = () => {
+ restoreRequestGeneration.value += 1;
+ restoreItems.value = [];
+ restoreLoading.value = false;
+ restoreError.value = "";
+ showRestoreModal.value = false;
+};
+
const isCurrentFavoriteRequest = (generation: number): boolean =>
favoriteRequestGeneration.value === generation && isLoggedIn.value;
@@ -1428,11 +1467,16 @@ const isCurrentDownloadedRequest = (
downloadedRequestGeneration.value === generation &&
currentUser.value?.id === userId;
+const isCurrentRestoreRequest = (generation: number, userId: number): boolean =>
+ restoreRequestGeneration.value === generation &&
+ currentUser.value?.id === userId;
+
const handleLogout = () => {
logout();
pendingDownloadRecords.clear();
clearFavoriteState();
clearDownloadedState();
+ clearRestoreState();
showLoginModal.value = false;
showLoginPrompt.value = false;
isSidebarOpen.value = false;
@@ -1485,8 +1529,91 @@ const loadDownloadedHistory = async (): Promise => {
}
};
-const syncInstalledAppsNow = () => {
- logger.warn("已安装应用同步将在后续任务中启用");
+const refreshInstalledSyncCandidates = async (): Promise => {
+ await refreshFavoriteInstalledApps();
+};
+
+const syncInstalledAppsToAccount = async (): Promise => {
+ if (!requireLogin("云端同步需要登录星火账号。")) return;
+ syncLoading.value = true;
+ try {
+ await refreshInstalledSyncCandidates();
+ const items = buildSyncItems(installedApps.value);
+ await uploadSyncedAppList({
+ clientArch: window.apm_store.arch || "amd64",
+ distro: systemInfo.value.distro,
+ items,
+ });
+ downloadedError.value = "";
+ } catch (error: unknown) {
+ downloadedError.value = (error as Error)?.message || "同步已安装应用失败";
+ } finally {
+ syncLoading.value = false;
+ }
+};
+
+const syncInstalledAppsNow = (): void => {
+ void syncInstalledAppsToAccount();
+};
+
+const openRestoreFromAccount = async (): Promise => {
+ if (!requireLogin("云端同步需要登录星火账号。")) return;
+ const userId = currentUser.value?.id;
+ if (userId === undefined) return;
+ const generation = restoreRequestGeneration.value + 1;
+ restoreRequestGeneration.value = generation;
+ showRestoreModal.value = true;
+ restoreLoading.value = true;
+ restoreError.value = "";
+ restoreItems.value = [];
+ try {
+ await refreshInstalledSyncCandidates();
+ const result = await fetchSyncedAppList();
+ if (!isCurrentRestoreRequest(generation, userId)) return;
+ restoreItems.value = result?.items || [];
+ } catch (error: unknown) {
+ if (!isCurrentRestoreRequest(generation, userId)) return;
+ restoreError.value = (error as Error)?.message || "读取云端应用列表失败";
+ } finally {
+ if (isCurrentRestoreRequest(generation, userId)) {
+ restoreLoading.value = false;
+ }
+ }
+};
+
+const installCloudItems = (items: SyncedAppListItem[]): void => {
+ for (const item of items) {
+ const app = apps.value.find(
+ (candidate) =>
+ candidate.pkgname === item.pkgname &&
+ candidate.origin === item.origin &&
+ candidate.category === item.category,
+ );
+ if (!app) continue;
+ void onDetailInstall(app);
+ }
+ showRestoreModal.value = false;
+};
+
+const maybePromptInstalledSync = async (): Promise => {
+ if (
+ import.meta.env.MODE === "test" ||
+ !isLoggedIn.value ||
+ installedSyncPromptShown.value ||
+ installedSyncEnabled.value !== null
+ ) {
+ if (isLoggedIn.value && installedSyncEnabled.value === true) {
+ await syncInstalledAppsToAccount();
+ }
+ return;
+ }
+
+ installedSyncPromptShown.value = true;
+ const enabled = window.confirm(
+ "是否启用已安装应用列表自动同步到星火账号?仅同步商店识别的非依赖应用。",
+ );
+ setInstalledSyncEnabled(enabled);
+ if (enabled) await syncInstalledAppsToAccount();
};
const openUserManagement = async () => {
@@ -1954,6 +2081,7 @@ onMounted(async () => {
]).then(() => {
// 所有数据加载完成后的回调(可选)
logger.info("所有应用数据加载完成");
+ void maybePromptInstalledSync();
});
// 设置键盘导航
diff --git a/src/__tests__/unit/AppListRestoreModal.test.ts b/src/__tests__/unit/AppListRestoreModal.test.ts
new file mode 100644
index 00000000..dbe26fc5
--- /dev/null
+++ b/src/__tests__/unit/AppListRestoreModal.test.ts
@@ -0,0 +1,57 @@
+import { fireEvent, render, screen } from "@testing-library/vue";
+import { describe, expect, it } from "vitest";
+
+import AppListRestoreModal from "@/components/AppListRestoreModal.vue";
+import type { SyncedAppListItem } from "@/global/typedefinition";
+
+const createItem = (
+ overrides: Partial = {},
+): SyncedAppListItem => ({
+ pkgname: "spark-notes",
+ origin: "spark",
+ category: "office",
+ version: "1.0.0",
+ packageArch: "amd64",
+ appName: "Spark Notes",
+ iconUrl: "",
+ ...overrides,
+});
+
+describe("AppListRestoreModal", () => {
+ it("emits selected installable cloud items", async () => {
+ const rendered = render(AppListRestoreModal, {
+ props: {
+ show: true,
+ loading: false,
+ error: "",
+ items: [
+ createItem(),
+ createItem({ pkgname: "amber-ce", appName: "Amber CE" }),
+ ],
+ installedKeys: new Set(),
+ },
+ });
+
+ await fireEvent.click(screen.getByLabelText("Spark Notes"));
+ await fireEvent.click(screen.getByRole("button", { name: "加入安装队列" }));
+
+ expect(rendered.emitted("install-selected")?.[0]?.[0]).toEqual([
+ expect.objectContaining({ pkgname: "spark-notes" }),
+ ]);
+ });
+
+ it("disables already installed cloud items", () => {
+ render(AppListRestoreModal, {
+ props: {
+ show: true,
+ loading: false,
+ error: "",
+ items: [createItem()],
+ installedKeys: new Set(["spark:spark-notes"]),
+ },
+ });
+
+ expect(screen.getByLabelText("Spark Notes")).toBeDisabled();
+ expect(screen.getByText("已安装")).toBeTruthy();
+ });
+});
diff --git a/src/__tests__/unit/InstalledAppsModal.test.ts b/src/__tests__/unit/InstalledAppsModal.test.ts
index 5236fd44..bbd5f136 100644
--- a/src/__tests__/unit/InstalledAppsModal.test.ts
+++ b/src/__tests__/unit/InstalledAppsModal.test.ts
@@ -37,6 +37,8 @@ describe("InstalledAppsModal", () => {
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
+ loggedIn: false,
+ syncing: false,
},
});
@@ -57,6 +59,8 @@ describe("InstalledAppsModal", () => {
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
+ loggedIn: false,
+ syncing: false,
},
});
@@ -75,6 +79,8 @@ describe("InstalledAppsModal", () => {
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
+ loggedIn: false,
+ syncing: false,
},
});
@@ -97,6 +103,8 @@ describe("InstalledAppsModal", () => {
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
+ loggedIn: false,
+ syncing: false,
},
});
@@ -119,6 +127,8 @@ describe("InstalledAppsModal", () => {
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
+ loggedIn: false,
+ syncing: false,
},
});
@@ -136,9 +146,75 @@ describe("InstalledAppsModal", () => {
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
+ loggedIn: false,
+ syncing: false,
},
});
expect(screen.queryByRole("button", { name: "查看详情" })).toBeNull();
});
+
+ it("requests login for cloud actions when logged out", async () => {
+ const rendered = render(InstalledAppsModal, {
+ props: {
+ show: true,
+ apps: [],
+ loading: false,
+ error: "",
+ activeOrigin: "spark",
+ storeFilter: "both",
+ sparkAvailable: true,
+ apmAvailable: true,
+ loggedIn: false,
+ syncing: false,
+ },
+ });
+
+ await fireEvent.click(screen.getByRole("button", { name: "同步到账号" }));
+ await fireEvent.click(screen.getByRole("button", { name: "从账号恢复" }));
+
+ expect(rendered.emitted("request-login")).toHaveLength(2);
+ });
+
+ it("emits cloud sync and restore events when logged in", async () => {
+ const rendered = render(InstalledAppsModal, {
+ props: {
+ show: true,
+ apps: [],
+ loading: false,
+ error: "",
+ activeOrigin: "spark",
+ storeFilter: "both",
+ sparkAvailable: true,
+ apmAvailable: true,
+ loggedIn: true,
+ syncing: false,
+ },
+ });
+
+ await fireEvent.click(screen.getByRole("button", { name: "同步到账号" }));
+ await fireEvent.click(screen.getByRole("button", { name: "从账号恢复" }));
+
+ expect(rendered.emitted("sync-to-account")).toHaveLength(1);
+ expect(rendered.emitted("restore-from-account")).toHaveLength(1);
+ });
+
+ it("disables sync button while syncing", () => {
+ render(InstalledAppsModal, {
+ props: {
+ show: true,
+ apps: [],
+ loading: false,
+ error: "",
+ activeOrigin: "spark",
+ storeFilter: "both",
+ sparkAvailable: true,
+ apmAvailable: true,
+ loggedIn: true,
+ syncing: true,
+ },
+ });
+
+ expect(screen.getByRole("button", { name: "同步中" })).toBeDisabled();
+ });
});
diff --git a/src/__tests__/unit/appListSync.test.ts b/src/__tests__/unit/appListSync.test.ts
new file mode 100644
index 00000000..988e438b
--- /dev/null
+++ b/src/__tests__/unit/appListSync.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from "vitest";
+
+import { buildSyncItems, cloudItemKey } from "@/modules/appListSync";
+import type { App } from "@/global/typedefinition";
+
+const createApp = (overrides: Partial = {}): App => ({
+ name: "Spark Notes",
+ pkgname: "spark-notes",
+ version: "1.0.0",
+ filename: "spark-notes_1.0.0_amd64.deb",
+ torrent_address: "",
+ author: "",
+ contributor: "",
+ website: "",
+ update: "",
+ size: "1 MB",
+ more: "",
+ tags: "",
+ img_urls: [],
+ icons: "https://example.test/icon.png",
+ category: "office",
+ origin: "spark",
+ currentStatus: "installed",
+ ...overrides,
+});
+
+describe("appListSync", () => {
+ it("builds cloud sync items for installed store-recognized user apps", () => {
+ expect(buildSyncItems([createApp()])).toEqual([
+ {
+ pkgname: "spark-notes",
+ origin: "spark",
+ category: "office",
+ version: "1.0.0",
+ packageArch: "amd64",
+ appName: "Spark Notes",
+ iconUrl: "https://example.test/icon.png",
+ },
+ ]);
+ });
+
+ it("filters out non-installed unknown dependency and unusable package entries", () => {
+ const items = buildSyncItems([
+ createApp({ pkgname: "not-installed", currentStatus: "not-installed" }),
+ createApp({ pkgname: "unknown-app", category: "unknown" }),
+ createApp({ pkgname: "dependency", isDependency: true }),
+ createApp({ pkgname: "" }),
+ createApp({ pkgname: "blank-origin", origin: "spark" }),
+ createApp({ pkgname: "kept", origin: "apm", arch: "arm64" }),
+ ]);
+
+ expect(items).toEqual([
+ expect.objectContaining({ pkgname: "blank-origin" }),
+ expect.objectContaining({ pkgname: "kept", packageArch: "arm64" }),
+ ]);
+ });
+
+ it("uses pkgname as appName and blank icon when optional display fields are missing", () => {
+ const app = createApp({ icons: "", pkgname: "fallback-name" });
+ app.name = "";
+ const [syncItem] = buildSyncItems([app]);
+
+ expect(syncItem).toMatchObject({
+ appName: "fallback-name",
+ iconUrl: "",
+ });
+ });
+
+ it("builds stable installed keys from origin and package", () => {
+ expect(cloudItemKey({ origin: "apm", pkgname: "amber-ce" })).toBe(
+ "apm:amber-ce",
+ );
+ });
+});
diff --git a/src/components/AppListRestoreModal.vue b/src/components/AppListRestoreModal.vue
new file mode 100644
index 00000000..3b577c90
--- /dev/null
+++ b/src/components/AppListRestoreModal.vue
@@ -0,0 +1,172 @@
+
+
+
+
+
+
+
+ 从账号恢复
+
+
+ 选择云端已同步的应用加入安装队列
+
+
+
+
+
+
+
+ 正在读取云端应用列表…
+
+
+ {{ error }}
+
+
+ 云端暂无已同步应用
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/InstalledAppsModal.vue b/src/components/InstalledAppsModal.vue
index f4b8f763..280968e7 100644
--- a/src/components/InstalledAppsModal.vue
+++ b/src/components/InstalledAppsModal.vue
@@ -28,6 +28,23 @@
+
+
();
-defineEmits<{
+const emit = defineEmits<{
(e: "close"): void;
(e: "refresh"): void;
(e: "uninstall", app: App): void;
(e: "switch-origin", origin: "apm" | "spark"): void;
(e: "open-app", app: App): void;
(e: "open-detail", app: App): void;
+ (e: "sync-to-account"): void;
+ (e: "restore-from-account"): void;
+ (e: "request-login"): void;
}>();
+const handleSyncClick = () => {
+ emit(props.loggedIn ? "sync-to-account" : "request-login");
+};
+
+const handleRestoreClick = () => {
+ emit(props.loggedIn ? "restore-from-account" : "request-login");
+};
+
const onOverlayWheel = (e: WheelEvent) => {
const target = e.target as HTMLElement;
if (target.closest(".overflow-y-auto, .overflow-auto")) return;
diff --git a/src/modules/appListSync.ts b/src/modules/appListSync.ts
new file mode 100644
index 00000000..6e26f78a
--- /dev/null
+++ b/src/modules/appListSync.ts
@@ -0,0 +1,30 @@
+import type { App, SyncedAppListItem } from "@/global/typedefinition";
+import { parsePackageArch } from "@/modules/appIdentity";
+
+const hasUsablePackageIdentity = (app: App): boolean => {
+ return app.pkgname.trim().length > 0 && Boolean(app.origin);
+};
+
+export const buildSyncItems = (apps: App[]): SyncedAppListItem[] => {
+ return apps
+ .filter(
+ (app) =>
+ app.currentStatus === "installed" &&
+ app.category !== "unknown" &&
+ !app.isDependency &&
+ hasUsablePackageIdentity(app),
+ )
+ .map((app) => ({
+ pkgname: app.pkgname,
+ origin: app.origin,
+ category: app.category,
+ version: app.version,
+ packageArch: app.arch || parsePackageArch(app.filename),
+ appName: app.name || app.pkgname,
+ iconUrl: app.icons || "",
+ }));
+};
+
+export const cloudItemKey = (
+ item: Pick
,
+): string => `${item.origin}:${item.pkgname}`;
From 753f91e837d353c9dda01c187ddfbc11cab6cc7c Mon Sep 17 00:00:00 2001
From: momen
Date: Tue, 19 May 2026 01:50:34 +0800
Subject: [PATCH 21/26] fix(sync): resolve restore item edge cases
---
src/App.vue | 9 +--
.../unit/App.account-placeholders.test.ts | 59 ++++++++++++++++++-
.../unit/AppListRestoreModal.test.ts | 18 ++++++
src/components/AppListRestoreModal.vue | 18 +++++-
4 files changed, 97 insertions(+), 7 deletions(-)
diff --git a/src/App.vue b/src/App.vue
index 170cd6ec..e9d49cb9 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1583,12 +1583,13 @@ const openRestoreFromAccount = async (): Promise => {
const installCloudItems = (items: SyncedAppListItem[]): void => {
for (const item of items) {
- const app = apps.value.find(
+ const candidates = apps.value.filter(
(candidate) =>
- candidate.pkgname === item.pkgname &&
- candidate.origin === item.origin &&
- candidate.category === item.category,
+ candidate.pkgname === item.pkgname && candidate.origin === item.origin,
);
+ const app =
+ candidates.find((candidate) => candidate.category === item.category) ??
+ candidates[0];
if (!app) continue;
void onDetailInstall(app);
}
diff --git a/src/__tests__/unit/App.account-placeholders.test.ts b/src/__tests__/unit/App.account-placeholders.test.ts
index bd683dcc..932d6d61 100644
--- a/src/__tests__/unit/App.account-placeholders.test.ts
+++ b/src/__tests__/unit/App.account-placeholders.test.ts
@@ -2,7 +2,11 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import App from "@/App.vue";
-import { listDownloadedApps, listFavoriteFolders } from "@/modules/backendApi";
+import {
+ fetchSyncedAppList,
+ listDownloadedApps,
+ listFavoriteFolders,
+} from "@/modules/backendApi";
import { setAuthSession } from "@/global/authState";
import type {
DownloadedAppList,
@@ -97,10 +101,11 @@ vi.mock("axios", () => {
}
return { data: [] };
});
+ const post = vi.fn(async () => ({ data: { ok: true } }));
return {
default: {
- create: () => ({ get }),
+ create: () => ({ get, post }),
},
};
});
@@ -138,9 +143,11 @@ vi.mock("@/modules/backendApi", () => ({
bulkDeleteFavoriteItems: vi.fn(),
createFavoriteFolder: vi.fn(),
exchangeFlarumToken: vi.fn(),
+ fetchSyncedAppList: vi.fn(async () => null),
listDownloadedApps: vi.fn(async () => downloadedList([])),
listFavoriteFolders: vi.fn(async () => favoriteFolders),
listFavoriteItems: vi.fn(async () => favoriteItems),
+ uploadSyncedAppList: vi.fn(),
setBackendToken: vi.fn(),
}));
@@ -437,4 +444,52 @@ describe("App account placeholders", () => {
expect(screen.queryByText("旧账号应用")).toBeNull();
expect(screen.getByText("暂无下载记录。")).toBeTruthy();
});
+
+ it("restores cloud apps by origin and package when category changed", async () => {
+ vi.mocked(fetchSyncedAppList).mockResolvedValueOnce({
+ snapshotName: "默认列表",
+ clientArch: "amd64",
+ distro: "deepin 25",
+ updatedAt: "2026-05-18T00:00:00Z",
+ items: [
+ {
+ pkgname: "wps",
+ origin: "apm",
+ category: "legacy-office",
+ version: "1.0.0",
+ packageArch: "amd64",
+ appName: "WPS Cloud",
+ iconUrl: "",
+ },
+ ],
+ });
+ invoke.mockImplementation(async (channel: string, payload?: unknown) => {
+ 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 === "check-installed") return false;
+ if (channel === "list-installed") {
+ const request = payload as { origin?: string };
+ if (request.origin === "apm") return { success: true, apps: [] };
+ }
+ return [];
+ });
+ render(App);
+
+ await fireEvent.click(await screen.findByText("应用管理"));
+ await fireEvent.click(
+ await screen.findByRole("button", { name: /从账号恢复/ }),
+ );
+ await fireEvent.click(await screen.findByLabelText("WPS Cloud"));
+ await fireEvent.click(screen.getByRole("button", { name: "加入安装队列" }));
+
+ await waitFor(() => {
+ expect(window.ipcRenderer.send).toHaveBeenCalledWith(
+ "queue-install",
+ expect.stringContaining('"pkgname":"wps"'),
+ );
+ });
+ });
});
diff --git a/src/__tests__/unit/AppListRestoreModal.test.ts b/src/__tests__/unit/AppListRestoreModal.test.ts
index dbe26fc5..b65098b2 100644
--- a/src/__tests__/unit/AppListRestoreModal.test.ts
+++ b/src/__tests__/unit/AppListRestoreModal.test.ts
@@ -54,4 +54,22 @@ describe("AppListRestoreModal", () => {
expect(screen.getByLabelText("Spark Notes")).toBeDisabled();
expect(screen.getByText("已安装")).toBeTruthy();
});
+
+ it("removes selected items when they become installed", async () => {
+ const rendered = render(AppListRestoreModal, {
+ props: {
+ show: true,
+ loading: false,
+ error: "",
+ items: [createItem()],
+ installedKeys: new Set(),
+ },
+ });
+
+ await fireEvent.click(screen.getByLabelText("Spark Notes"));
+ await rendered.rerender({ installedKeys: new Set(["spark:spark-notes"]) });
+
+ expect(screen.getByLabelText("Spark Notes")).toBeDisabled();
+ expect(screen.getByRole("button", { name: "加入安装队列" })).toBeDisabled();
+ });
});
diff --git a/src/components/AppListRestoreModal.vue b/src/components/AppListRestoreModal.vue
index 3b577c90..06feaab8 100644
--- a/src/components/AppListRestoreModal.vue
+++ b/src/components/AppListRestoreModal.vue
@@ -150,9 +150,17 @@ const isInstalled = (item: SyncedAppListItem): boolean =>
props.installedKeys.has(cloudItemKey(item));
const selectedItems = computed(() =>
- props.items.filter((item) => selectedKeys.value.has(cloudItemKey(item))),
+ props.items.filter(
+ (item) => selectedKeys.value.has(cloudItemKey(item)) && !isInstalled(item),
+ ),
);
+const pruneSelectedKeys = (): void => {
+ selectedKeys.value = new Set(
+ [...selectedKeys.value].filter((key) => !props.installedKeys.has(key)),
+ );
+};
+
const toggleSelection = (item: SyncedAppListItem): void => {
if (isInstalled(item)) return;
const key = cloudItemKey(item);
@@ -169,4 +177,12 @@ watch(
},
{ deep: true },
);
+
+watch(
+ () => props.installedKeys,
+ () => {
+ pruneSelectedKeys();
+ },
+ { deep: true },
+);
From 34551fce7b300be70879ed2c264cfb2761a8a491 Mon Sep 17 00:00:00 2001
From: momen
Date: Tue, 19 May 2026 02:03:29 +0800
Subject: [PATCH 22/26] fix(sync): isolate installed sync state
---
src/App.vue | 95 ++++++++++++++++++-
.../unit/App.account-placeholders.test.ts | 93 +++++++++++++++++-
src/__tests__/unit/accountSyncState.test.ts | 28 ++++++
src/__tests__/unit/appListSync.test.ts | 21 +++-
src/global/accountSyncState.ts | 25 +++--
src/modules/appListSync.ts | 19 ++++
6 files changed, 269 insertions(+), 12 deletions(-)
create mode 100644 src/__tests__/unit/accountSyncState.test.ts
diff --git a/src/App.vue b/src/App.vue
index e9d49cb9..8a5ad7e8 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -295,6 +295,7 @@ import {
} from "./global/downloadStatus";
import {
installedSyncEnabled,
+ loadInstalledSyncPreference,
setInstalledSyncEnabled,
} from "./global/accountSyncState";
import {
@@ -336,7 +337,11 @@ import {
parsePackageArch,
} from "./modules/appIdentity";
import { resolveFavoriteItems } from "./modules/favoriteAvailability";
-import { buildSyncItems, cloudItemKey } from "./modules/appListSync";
+import {
+ buildSyncItems,
+ cloudItemKey,
+ mergeInstalledApps,
+} from "./modules/appListSync";
import type {
App,
AppJson,
@@ -441,6 +446,8 @@ const downloadedLoading = ref(false);
const downloadedError = ref("");
const downloadedRequestGeneration = ref(0);
const syncLoading = ref(false);
+const syncRequestGeneration = ref(0);
+const syncCandidateApps = ref([]);
const restoreLoading = ref(false);
const restoreError = ref("");
const showRestoreModal = ref(false);
@@ -1477,6 +1484,10 @@ const handleLogout = () => {
clearFavoriteState();
clearDownloadedState();
clearRestoreState();
+ syncRequestGeneration.value += 1;
+ syncLoading.value = false;
+ syncCandidateApps.value = [];
+ loadInstalledSyncPreference(null);
showLoginModal.value = false;
showLoginPrompt.value = false;
isSidebarOpen.value = false;
@@ -1498,6 +1509,7 @@ const handleFlarumLogin = async (payload: FlarumLoginPayload) => {
flarumToken: flarumToken.token,
});
setAuthSession(session);
+ loadInstalledSyncPreference(session.user.id);
showLoginModal.value = false;
} catch (error: unknown) {
loginError.value = (error as Error)?.message || "登录失败,请稍后重试";
@@ -1530,25 +1542,87 @@ const loadDownloadedHistory = async (): Promise => {
};
const refreshInstalledSyncCandidates = async (): Promise => {
- await refreshFavoriteInstalledApps();
+ const origins: Array<"spark" | "apm"> = [];
+ if (isOriginEnabled(storeFilter.value, "spark") && sparkAvailable.value) {
+ origins.push("spark");
+ }
+ if (isOriginEnabled(storeFilter.value, "apm") && apmAvailable.value) {
+ origins.push("apm");
+ }
+
+ const refreshedApps: App[] = [];
+ await Promise.all(
+ origins.map(async (origin) => {
+ const pkgnameList =
+ origin === "spark"
+ ? apps.value
+ .filter((app) => app.origin === "spark")
+ .map((app) => app.pkgname)
+ : undefined;
+ const result = await window.ipcRenderer.invoke("list-installed", {
+ origin,
+ pkgnameList,
+ });
+ if (!result?.success) return;
+
+ for (const app of result.apps as InstalledAppInfo[]) {
+ const appInfo = mapInstalledAppToCatalogApp(app, origin);
+ if (appInfo) refreshedApps.push(appInfo);
+ }
+ }),
+ );
+
+ syncCandidateApps.value = mergeInstalledApps(
+ syncCandidateApps.value,
+ refreshedApps,
+ origins,
+ );
};
const syncInstalledAppsToAccount = async (): Promise => {
if (!requireLogin("云端同步需要登录星火账号。")) return;
+ if (syncLoading.value) return;
+ const userId = currentUser.value?.id;
+ if (userId === undefined) return;
+ const generation = syncRequestGeneration.value + 1;
+ syncRequestGeneration.value = generation;
syncLoading.value = true;
try {
await refreshInstalledSyncCandidates();
- const items = buildSyncItems(installedApps.value);
+ if (
+ syncRequestGeneration.value !== generation ||
+ currentUser.value?.id !== userId
+ ) {
+ return;
+ }
+ const items = buildSyncItems(syncCandidateApps.value);
await uploadSyncedAppList({
clientArch: window.apm_store.arch || "amd64",
distro: systemInfo.value.distro,
items,
});
+ if (
+ syncRequestGeneration.value !== generation ||
+ currentUser.value?.id !== userId
+ ) {
+ return;
+ }
downloadedError.value = "";
} catch (error: unknown) {
+ if (
+ syncRequestGeneration.value !== generation ||
+ currentUser.value?.id !== userId
+ ) {
+ return;
+ }
downloadedError.value = (error as Error)?.message || "同步已安装应用失败";
} finally {
- syncLoading.value = false;
+ if (
+ syncRequestGeneration.value === generation &&
+ currentUser.value?.id === userId
+ ) {
+ syncLoading.value = false;
+ }
}
};
@@ -2215,6 +2289,19 @@ onUnmounted(() => {
});
// 观察器
+watch(
+ () => currentUser.value?.id ?? null,
+ (userId, previousUserId) => {
+ loadInstalledSyncPreference(userId);
+ if (previousUserId !== undefined && userId !== previousUserId) {
+ syncRequestGeneration.value += 1;
+ syncLoading.value = false;
+ syncCandidateApps.value = [];
+ }
+ },
+ { immediate: true },
+);
+
watch(themeMode, (newVal) => {
localStorage.setItem("theme", newVal);
window.ipcRenderer.send(
diff --git a/src/__tests__/unit/App.account-placeholders.test.ts b/src/__tests__/unit/App.account-placeholders.test.ts
index 932d6d61..bcea8572 100644
--- a/src/__tests__/unit/App.account-placeholders.test.ts
+++ b/src/__tests__/unit/App.account-placeholders.test.ts
@@ -1,4 +1,10 @@
-import { fireEvent, render, screen, waitFor } from "@testing-library/vue";
+import {
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ within,
+} from "@testing-library/vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import App from "@/App.vue";
@@ -6,12 +12,14 @@ import {
fetchSyncedAppList,
listDownloadedApps,
listFavoriteFolders,
+ uploadSyncedAppList,
} from "@/modules/backendApi";
import { setAuthSession } from "@/global/authState";
import type {
DownloadedAppList,
FavoriteFolder,
FavoriteItem,
+ SyncedAppList,
} from "@/global/typedefinition";
const invoke = vi.fn();
@@ -56,6 +64,14 @@ const downloadedList = (
pageSize: 50,
});
+const syncedList = (items: SyncedAppList["items"]): SyncedAppList => ({
+ snapshotName: "默认列表",
+ clientArch: "amd64",
+ distro: "deepin 25",
+ updatedAt: "2026-05-18T00:00:00Z",
+ items,
+});
+
const setSecondUserSession = () => {
setAuthSession({
accessToken: "backend-token-b",
@@ -153,6 +169,7 @@ vi.mock("@/modules/backendApi", () => ({
describe("App account placeholders", () => {
beforeEach(() => {
+ vi.clearAllMocks();
invoke.mockReset();
invoke.mockImplementation(async (channel: string) => {
if (channel === "get-store-filter") return "both";
@@ -492,4 +509,78 @@ describe("App account placeholders", () => {
);
});
});
+
+ it("keeps the installed modal scoped to the active origin after sync", async () => {
+ invoke.mockImplementation(async (channel: string, payload?: unknown) => {
+ if (channel === "get-store-filter") return "both";
+ if (channel === "check-spark-available") return true;
+ 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") {
+ const request = payload as { origin?: string };
+ if (request.origin === "spark") {
+ return {
+ success: true,
+ apps: [
+ {
+ pkgname: "wps",
+ name: "WPS",
+ version: "1.0.0",
+ arch: "amd64",
+ flags: "installed",
+ origin: "spark",
+ },
+ ],
+ };
+ }
+ return { success: true, apps: [] };
+ }
+ return [];
+ });
+ vi.mocked(uploadSyncedAppList).mockResolvedValueOnce(syncedList([]));
+ render(App);
+
+ await fireEvent.click(await screen.findByText("应用管理"));
+ const modal = screen.getByText("已安装应用").closest(".fixed");
+ if (!(modal instanceof HTMLElement)) throw new Error("modal not found");
+ expect(within(modal).getByText("暂无已安装应用")).toBeTruthy();
+
+ await fireEvent.click(
+ within(modal).getByRole("button", { name: /同步到账号/ }),
+ );
+
+ await waitFor(() => {
+ expect(uploadSyncedAppList).toHaveBeenCalled();
+ });
+ expect(within(modal).queryByText("WPS")).toBeNull();
+ expect(within(modal).getByText("暂无已安装应用")).toBeTruthy();
+ });
+
+ it("ignores overlapping installed sync requests", 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 fireEvent.click(screen.getByRole("button", { name: "立即同步" }));
+
+ await waitFor(() => {
+ expect(uploadSyncedAppList).toHaveBeenCalledTimes(1);
+ });
+ syncUpload.resolve(syncedList([]));
+ });
});
diff --git a/src/__tests__/unit/accountSyncState.test.ts b/src/__tests__/unit/accountSyncState.test.ts
new file mode 100644
index 00000000..8d7b2fd8
--- /dev/null
+++ b/src/__tests__/unit/accountSyncState.test.ts
@@ -0,0 +1,28 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+describe("accountSyncState", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ localStorage.clear();
+ });
+
+ it("scopes installed sync preference to the current user", async () => {
+ const {
+ installedSyncEnabled,
+ loadInstalledSyncPreference,
+ setInstalledSyncEnabled,
+ } = await import("@/global/accountSyncState");
+
+ loadInstalledSyncPreference(1);
+ setInstalledSyncEnabled(true);
+
+ loadInstalledSyncPreference(2);
+
+ expect(installedSyncEnabled.value).toBeNull();
+
+ setInstalledSyncEnabled(false);
+ loadInstalledSyncPreference(1);
+
+ expect(installedSyncEnabled.value).toBe(true);
+ });
+});
diff --git a/src/__tests__/unit/appListSync.test.ts b/src/__tests__/unit/appListSync.test.ts
index 988e438b..c567339d 100644
--- a/src/__tests__/unit/appListSync.test.ts
+++ b/src/__tests__/unit/appListSync.test.ts
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
-import { buildSyncItems, cloudItemKey } from "@/modules/appListSync";
+import {
+ buildSyncItems,
+ cloudItemKey,
+ mergeInstalledApps,
+} from "@/modules/appListSync";
import type { App } from "@/global/typedefinition";
const createApp = (overrides: Partial = {}): App => ({
@@ -71,4 +75,19 @@ describe("appListSync", () => {
"apm:amber-ce",
);
});
+
+ it("merges refreshed apps without mutating active modal origin lists", () => {
+ const current = [createApp({ origin: "apm", pkgname: "apm-installed" })];
+ const refreshed = [
+ createApp({ origin: "spark", pkgname: "spark-installed" }),
+ ];
+
+ expect(mergeInstalledApps(current, refreshed, ["spark"])).toEqual([
+ expect.objectContaining({ origin: "apm", pkgname: "apm-installed" }),
+ expect.objectContaining({ origin: "spark", pkgname: "spark-installed" }),
+ ]);
+ expect(current).toEqual([
+ expect.objectContaining({ origin: "apm", pkgname: "apm-installed" }),
+ ]);
+ });
});
diff --git a/src/global/accountSyncState.ts b/src/global/accountSyncState.ts
index acb0846e..7b7d2958 100644
--- a/src/global/accountSyncState.ts
+++ b/src/global/accountSyncState.ts
@@ -1,26 +1,39 @@
import { ref, watch } from "vue";
const INSTALLED_SYNC_STORAGE_KEY = "spark-store-installed-sync-enabled";
+let activeSyncUserId: number | null = null;
-const readSyncEnabled = (): boolean | null => {
- const savedValue = localStorage.getItem(INSTALLED_SYNC_STORAGE_KEY);
+const syncStorageKey = (userId: number | null): string =>
+ userId === null
+ ? INSTALLED_SYNC_STORAGE_KEY
+ : `${INSTALLED_SYNC_STORAGE_KEY}:${userId}`;
+
+const readSyncEnabled = (userId: number | null): boolean | null => {
+ const savedValue = localStorage.getItem(syncStorageKey(userId));
if (savedValue === "true") return true;
if (savedValue === "false") return false;
return null;
};
-export const installedSyncEnabled = ref(readSyncEnabled());
+export const installedSyncEnabled = ref(
+ readSyncEnabled(activeSyncUserId),
+);
+
+export const loadInstalledSyncPreference = (userId: number | null): void => {
+ activeSyncUserId = userId;
+ installedSyncEnabled.value = readSyncEnabled(userId);
+};
export const setInstalledSyncEnabled = (enabled: boolean): void => {
installedSyncEnabled.value = enabled;
- localStorage.setItem(INSTALLED_SYNC_STORAGE_KEY, String(enabled));
+ localStorage.setItem(syncStorageKey(activeSyncUserId), String(enabled));
};
watch(installedSyncEnabled, (enabled) => {
if (enabled === null) {
- localStorage.removeItem(INSTALLED_SYNC_STORAGE_KEY);
+ localStorage.removeItem(syncStorageKey(activeSyncUserId));
return;
}
- localStorage.setItem(INSTALLED_SYNC_STORAGE_KEY, String(enabled));
+ localStorage.setItem(syncStorageKey(activeSyncUserId), String(enabled));
});
diff --git a/src/modules/appListSync.ts b/src/modules/appListSync.ts
index 6e26f78a..86d144de 100644
--- a/src/modules/appListSync.ts
+++ b/src/modules/appListSync.ts
@@ -28,3 +28,22 @@ export const buildSyncItems = (apps: App[]): SyncedAppListItem[] => {
export const cloudItemKey = (
item: Pick,
): string => `${item.origin}:${item.pkgname}`;
+
+export const mergeInstalledApps = (
+ currentApps: App[],
+ refreshedApps: App[],
+ refreshedOrigins: Array<"spark" | "apm">,
+): App[] => {
+ const refreshedKeys = new Set(
+ refreshedApps.map((app) => `${app.origin}:${app.pkgname}`),
+ );
+
+ return [
+ ...currentApps.filter(
+ (app) =>
+ !refreshedOrigins.includes(app.origin) &&
+ !refreshedKeys.has(`${app.origin}:${app.pkgname}`),
+ ),
+ ...refreshedApps,
+ ];
+};
From 839f4017f87ea10e2e8bbbdbfecb18190779730e Mon Sep 17 00:00:00 2001
From: momen
Date: Tue, 19 May 2026 02:16:39 +0800
Subject: [PATCH 23/26] fix(sync): guard stale installed refreshes
---
src/App.vue | 27 ++++---
.../unit/App.account-placeholders.test.ts | 79 +++++++++++++++++++
src/components/InstalledAppsModal.vue | 14 +++-
3 files changed, 109 insertions(+), 11 deletions(-)
diff --git a/src/App.vue b/src/App.vue
index 8a5ad7e8..447e24f0 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1541,7 +1541,9 @@ const loadDownloadedHistory = async (): Promise => {
}
};
-const refreshInstalledSyncCandidates = async (): Promise => {
+const refreshInstalledSyncCandidates = async (
+ isCurrentRequest: () => boolean,
+): Promise => {
const origins: Array<"spark" | "apm"> = [];
if (isOriginEnabled(storeFilter.value, "spark") && sparkAvailable.value) {
origins.push("spark");
@@ -1572,11 +1574,16 @@ const refreshInstalledSyncCandidates = async (): Promise => {
}),
);
+ if (!isCurrentRequest()) {
+ return false;
+ }
+
syncCandidateApps.value = mergeInstalledApps(
syncCandidateApps.value,
refreshedApps,
origins,
);
+ return true;
};
const syncInstalledAppsToAccount = async (): Promise => {
@@ -1588,13 +1595,12 @@ const syncInstalledAppsToAccount = async (): Promise => {
syncRequestGeneration.value = generation;
syncLoading.value = true;
try {
- await refreshInstalledSyncCandidates();
- if (
- syncRequestGeneration.value !== generation ||
- currentUser.value?.id !== userId
- ) {
- return;
- }
+ const refreshed = await refreshInstalledSyncCandidates(
+ () =>
+ syncRequestGeneration.value === generation &&
+ currentUser.value?.id === userId,
+ );
+ if (!refreshed) return;
const items = buildSyncItems(syncCandidateApps.value);
await uploadSyncedAppList({
clientArch: window.apm_store.arch || "amd64",
@@ -1641,7 +1647,10 @@ const openRestoreFromAccount = async (): Promise => {
restoreError.value = "";
restoreItems.value = [];
try {
- await refreshInstalledSyncCandidates();
+ const refreshed = await refreshInstalledSyncCandidates(() =>
+ isCurrentRestoreRequest(generation, userId),
+ );
+ if (!refreshed) return;
const result = await fetchSyncedAppList();
if (!isCurrentRestoreRequest(generation, userId)) return;
restoreItems.value = result?.items || [];
diff --git a/src/__tests__/unit/App.account-placeholders.test.ts b/src/__tests__/unit/App.account-placeholders.test.ts
index bcea8572..a3052621 100644
--- a/src/__tests__/unit/App.account-placeholders.test.ts
+++ b/src/__tests__/unit/App.account-placeholders.test.ts
@@ -583,4 +583,83 @@ describe("App account placeholders", () => {
});
syncUpload.resolve(syncedList([]));
});
+
+ it("does not upload stale sync candidates after logout", async () => {
+ const slowInstalled = createDeferred<{
+ success: true;
+ apps: Array<{
+ pkgname: string;
+ name: string;
+ version: string;
+ arch: string;
+ flags: string;
+ origin: "apm";
+ }>;
+ }>();
+ let listInstalledCalls = 0;
+ 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") {
+ listInstalledCalls += 1;
+ if (listInstalledCalls === 1) return slowInstalled.promise;
+ return { success: true, apps: [] };
+ }
+ return [];
+ });
+ vi.mocked(uploadSyncedAppList).mockResolvedValue(syncedList([]));
+ 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 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("退出登录"));
+
+ slowInstalled.resolve({
+ success: true,
+ apps: [
+ {
+ pkgname: "wps",
+ name: "WPS",
+ version: "1.0.0",
+ arch: "amd64",
+ flags: "installed",
+ origin: "apm",
+ },
+ ],
+ });
+ await slowInstalled.promise;
+ await Promise.resolve();
+
+ setSecondUserSession();
+ const secondUserButton = await screen.findByRole("button", {
+ name: /^Second User$/,
+ });
+ if (!screen.queryByText("用户管理")) {
+ await fireEvent.click(secondUserButton);
+ }
+ await fireEvent.click(await screen.findByText("用户管理"));
+ await fireEvent.click(
+ await screen.findByRole("button", { name: "立即同步" }),
+ );
+
+ await waitFor(() => {
+ expect(uploadSyncedAppList).toHaveBeenCalledWith(
+ expect.objectContaining({ items: [] }),
+ );
+ });
+ });
});
diff --git a/src/components/InstalledAppsModal.vue b/src/components/InstalledAppsModal.vue
index 280968e7..b2717d0d 100644
--- a/src/components/InstalledAppsModal.vue
+++ b/src/components/InstalledAppsModal.vue
@@ -254,11 +254,21 @@ const emit = defineEmits<{
}>();
const handleSyncClick = () => {
- emit(props.loggedIn ? "sync-to-account" : "request-login");
+ if (props.loggedIn) {
+ emit("sync-to-account");
+ return;
+ }
+
+ emit("request-login");
};
const handleRestoreClick = () => {
- emit(props.loggedIn ? "restore-from-account" : "request-login");
+ if (props.loggedIn) {
+ emit("restore-from-account");
+ return;
+ }
+
+ emit("request-login");
};
const onOverlayWheel = (e: WheelEvent) => {
From 341c740ced8293463f94f601159a52c228f4d93f Mon Sep 17 00:00:00 2001
From: momen
Date: Tue, 19 May 2026 02:20:48 +0800
Subject: [PATCH 24/26] test(sync): assert stale apps never upload
---
src/__tests__/unit/App.account-placeholders.test.ts | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/src/__tests__/unit/App.account-placeholders.test.ts b/src/__tests__/unit/App.account-placeholders.test.ts
index a3052621..5314f517 100644
--- a/src/__tests__/unit/App.account-placeholders.test.ts
+++ b/src/__tests__/unit/App.account-placeholders.test.ts
@@ -661,5 +661,11 @@ describe("App account placeholders", () => {
expect.objectContaining({ items: [] }),
);
});
+ const uploadedItemNames = vi
+ .mocked(uploadSyncedAppList)
+ .mock.calls.flatMap(([payload]) =>
+ payload.items.map((item) => item.pkgname),
+ );
+ expect(uploadedItemNames).not.toContain("wps");
});
});
From a8a00d816554d1f5c523d8e7b6f96252bdfb8013 Mon Sep 17 00:00:00 2001
From: momen
Date: Tue, 19 May 2026 08:16:57 +0800
Subject: [PATCH 25/26] fix(account): gate reviews and stale refreshes
---
src/App.vue | 16 ++-
.../unit/App.account-placeholders.test.ts | 102 ++++++++++++++++++
src/__tests__/unit/AppDetailPage.test.ts | 24 ++++-
src/__tests__/unit/ReviewsPanel.test.ts | 6 +-
src/components/AppDetailPage.vue | 21 +++-
src/components/ReviewsPanel.vue | 15 ++-
6 files changed, 175 insertions(+), 9 deletions(-)
diff --git a/src/App.vue b/src/App.vue
index 447e24f0..17b544e7 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1464,6 +1464,16 @@ const clearRestoreState = () => {
showRestoreModal.value = false;
};
+const nextFavoriteRequestGeneration = (): number => {
+ favoriteRequestGeneration.value += 1;
+ return favoriteRequestGeneration.value;
+};
+
+const nextDownloadedRequestGeneration = (): number => {
+ downloadedRequestGeneration.value += 1;
+ return downloadedRequestGeneration.value;
+};
+
const isCurrentFavoriteRequest = (generation: number): boolean =>
favoriteRequestGeneration.value === generation && isLoggedIn.value;
@@ -1522,7 +1532,7 @@ const loadDownloadedHistory = async (): Promise => {
if (!requireLogin("请登录后查看和管理账号信息。")) return;
const userId = currentUser.value?.id;
if (userId === undefined) return;
- const generation = downloadedRequestGeneration.value;
+ const generation = nextDownloadedRequestGeneration();
downloadedLoading.value = true;
downloadedError.value = "";
@@ -1741,7 +1751,7 @@ const loadActiveFavoriteItems = async (
};
const refreshFavorites = async (): Promise => {
- const generation = favoriteRequestGeneration.value;
+ const generation = nextFavoriteRequestGeneration();
favoriteLoading.value = true;
favoriteError.value = "";
try {
@@ -1806,7 +1816,7 @@ const openFavoriteManagement = async () => {
};
const selectFavoriteFolder = async (folderId: number) => {
- const generation = favoriteRequestGeneration.value;
+ const generation = nextFavoriteRequestGeneration();
activeFavoriteFolderId.value = folderId;
favoriteLoading.value = true;
favoriteError.value = "";
diff --git a/src/__tests__/unit/App.account-placeholders.test.ts b/src/__tests__/unit/App.account-placeholders.test.ts
index 5314f517..caadeb39 100644
--- a/src/__tests__/unit/App.account-placeholders.test.ts
+++ b/src/__tests__/unit/App.account-placeholders.test.ts
@@ -462,6 +462,108 @@ describe("App account placeholders", () => {
expect(screen.getByText("暂无下载记录。")).toBeTruthy();
});
+ it("ignores older downloaded history refreshes for the same user", async () => {
+ const firstHistory = createDeferred();
+ const secondHistory = createDeferred();
+ vi.mocked(listDownloadedApps)
+ .mockReturnValueOnce(firstHistory.promise)
+ .mockReturnValueOnce(secondHistory.promise);
+ render(App);
+
+ await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
+ await fireEvent.click(screen.getByText("用户管理"));
+ await fireEvent.click(screen.getByRole("button", { name: "刷新" }));
+
+ secondHistory.resolve(
+ downloadedList([
+ {
+ id: 88,
+ appKey: "app:office:new-app",
+ pkgname: "new-app",
+ name: "新下载应用",
+ category: "office",
+ selectedOrigin: "apm",
+ version: "2.0.0",
+ packageArch: "amd64",
+ downloadedAt: "2026-05-18T00:00:00Z",
+ },
+ ]),
+ );
+ expect(await screen.findByText("新下载应用")).toBeTruthy();
+
+ firstHistory.resolve(
+ downloadedList([
+ {
+ id: 77,
+ appKey: "app:office:old-app",
+ pkgname: "old-app",
+ name: "旧下载应用",
+ category: "office",
+ selectedOrigin: "apm",
+ version: "1.0.0",
+ packageArch: "amd64",
+ downloadedAt: "2026-05-18T00:00:00Z",
+ },
+ ]),
+ );
+ await firstHistory.promise;
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(screen.queryByText("旧下载应用")).toBeNull();
+ expect(screen.getByText("新下载应用")).toBeTruthy();
+ });
+
+ it("ignores older favorite folder refreshes for the same user", async () => {
+ const firstFolders = createDeferred();
+ const secondFolders = createDeferred();
+ vi.mocked(listFavoriteFolders)
+ .mockReturnValueOnce(firstFolders.promise)
+ .mockReturnValueOnce(secondFolders.promise);
+ render(App);
+
+ await fireEvent.click(
+ await screen.findByRole("button", { name: /^Momen$/ }),
+ );
+ await fireEvent.click(screen.getByRole("button", { name: "我的收藏" }));
+ await fireEvent.click(
+ await screen.findByRole("button", { name: /^Momen$/ }),
+ );
+ if (!screen.queryByRole("button", { name: "我的收藏" })) {
+ await fireEvent.click(
+ await screen.findByRole("button", { name: /^Momen$/ }),
+ );
+ }
+ await fireEvent.click(screen.getByRole("button", { name: "我的收藏" }));
+
+ secondFolders.resolve([
+ {
+ id: 42,
+ name: "新收藏夹",
+ itemCount: 0,
+ createdAt: "2026-05-18T00:00:00Z",
+ updatedAt: "2026-05-18T00:00:00Z",
+ },
+ ]);
+ expect(await screen.findByText("新收藏夹 (0)")).toBeTruthy();
+
+ firstFolders.resolve([
+ {
+ id: 41,
+ name: "旧收藏夹",
+ itemCount: 0,
+ createdAt: "2026-05-18T00:00:00Z",
+ updatedAt: "2026-05-18T00:00:00Z",
+ },
+ ]);
+ await firstFolders.promise;
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(screen.queryByText("旧收藏夹 (0)")).toBeNull();
+ expect(screen.getByText("新收藏夹 (0)")).toBeTruthy();
+ });
+
it("restores cloud apps by origin and package when category changed", async () => {
vi.mocked(fetchSyncedAppList).mockResolvedValueOnce({
snapshotName: "默认列表",
diff --git a/src/__tests__/unit/AppDetailPage.test.ts b/src/__tests__/unit/AppDetailPage.test.ts
index 36505fbd..f52f5fa7 100644
--- a/src/__tests__/unit/AppDetailPage.test.ts
+++ b/src/__tests__/unit/AppDetailPage.test.ts
@@ -91,6 +91,28 @@ describe("AppDetailPage", () => {
);
});
+ it("gates reviews for anonymous users", async () => {
+ const rendered = render(AppDetailPage, {
+ props: {
+ app,
+ screenshots: [],
+ sparkInstalled: false,
+ apmInstalled: false,
+ loggedIn: false,
+ reviewAppKey: "apm:amd64-apm:office:wps",
+ reviewTags: sparkTags,
+ },
+ });
+
+ expect(screen.queryByTestId("reviews-panel")).toBeNull();
+ await fireEvent.click(
+ screen.getByRole("button", { name: "登录后查看评价" }),
+ );
+ expect(rendered.emitted("request-login")?.[0]?.[0]).toBe(
+ "登录后查看和发表评论。",
+ );
+ });
+
it("updates review identity when switching a merged app origin", async () => {
render(AppDetailPage, {
props: {
@@ -98,7 +120,7 @@ describe("AppDetailPage", () => {
screenshots: [],
sparkInstalled: false,
apmInstalled: false,
- loggedIn: false,
+ loggedIn: true,
reviewAppKey: "spark:amd64-store:office:wps",
reviewTags: sparkTags,
},
diff --git a/src/__tests__/unit/ReviewsPanel.test.ts b/src/__tests__/unit/ReviewsPanel.test.ts
index fd6ef4d7..ca7a7bf3 100644
--- a/src/__tests__/unit/ReviewsPanel.test.ts
+++ b/src/__tests__/unit/ReviewsPanel.test.ts
@@ -52,6 +52,8 @@ describe("ReviewsPanel", () => {
expect(screen.getByText("登录后发表评论")).toBeTruthy();
expect(screen.getByText("1.0.0")).toBeTruthy();
expect(screen.getByText("deepin 25")).toBeTruthy();
+ expect(fetchRatingSummary).not.toHaveBeenCalled();
+ expect(fetchReviews).not.toHaveBeenCalled();
});
it("ignores stale review responses after app key changes", async () => {
@@ -84,10 +86,10 @@ describe("ReviewsPanel", () => {
);
const rendered = render(ReviewsPanel, {
- props: { appKey: "first", tags, loggedIn: false },
+ props: { appKey: "first", tags, loggedIn: true },
});
- await rendered.rerender({ appKey: "second", tags, loggedIn: false });
+ await rendered.rerender({ appKey: "second", tags, loggedIn: true });
resolveSecondSummary({ averageRating: 5, reviewCount: 1, starCounts: {} });
resolveSecondReviews([
diff --git a/src/components/AppDetailPage.vue b/src/components/AppDetailPage.vue
index e3b26ca4..7b1c4bd8 100644
--- a/src/components/AppDetailPage.vue
+++ b/src/components/AppDetailPage.vue
@@ -196,12 +196,31 @@
+
+
+
+ 应用评价
+
+
+ 登录星火账号后可查看评价并发表评论。
+
+
+
diff --git a/src/components/ReviewsPanel.vue b/src/components/ReviewsPanel.vue
index eefc2382..b6ae6049 100644
--- a/src/components/ReviewsPanel.vue
+++ b/src/components/ReviewsPanel.vue
@@ -152,8 +152,19 @@ const ratingText = computed(() => {
return `${summary.value.averageRating.toFixed(1)} / 5 (${summary.value.reviewCount})`;
});
+const clearReviewState = () => {
+ loadGeneration.value += 1;
+ reviews.value = [];
+ summary.value = null;
+ loading.value = false;
+ error.value = "";
+};
+
const loadReviews = async () => {
- if (!props.appKey) return;
+ if (!props.loggedIn || !props.appKey) {
+ clearReviewState();
+ return;
+ }
const generation = loadGeneration.value + 1;
loadGeneration.value = generation;
const appKey = props.appKey;
@@ -202,5 +213,5 @@ const submit = async () => {
};
onMounted(loadReviews);
-watch(() => props.appKey, loadReviews);
+watch(() => [props.appKey, props.loggedIn], loadReviews);
From deff1c20c47d9a2a6dcc185e3cada44e00bd0186 Mon Sep 17 00:00:00 2001
From: momen
Date: Tue, 19 May 2026 10:50:42 +0800
Subject: [PATCH 26/26] fix(auth): clarify flarum login failures
---
.gitignore | 7 +++
electron/main/index.ts | 47 ++++++++++++-----
src/__tests__/unit/backendApi.test.ts | 74 +++++++++++++++++++++++++++
src/__tests__/unit/flarumAuth.test.ts | 25 +++++++++
src/modules/backendApi.ts | 49 ++++++++++++++++--
src/modules/flarumAuth.ts | 44 ++++++++++++++--
6 files changed, 224 insertions(+), 22 deletions(-)
create mode 100644 src/__tests__/unit/backendApi.test.ts
diff --git a/.gitignore b/.gitignore
index c07a2ded..f4a7205f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,6 +14,13 @@ dist-electron
release
*.local
+# Local secrets and databases
+.env
+.env.*.local
+*.sqlite
+*.sqlite3
+*.db
+
# Test coverage
coverage
.nyc_output
diff --git a/electron/main/index.ts b/electron/main/index.ts
index 5d060a23..2b4b8035 100644
--- a/electron/main/index.ts
+++ b/electron/main/index.ts
@@ -150,20 +150,35 @@ ipcMain.handle("request-flarum-token", async (_event, payload: unknown) => {
throw new Error("登录信息格式不正确,请重新输入。");
}
- const response = await fetch(FLARUM_TOKEN_URL, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Accept: "application/json",
- "User-Agent": getUserAgent(),
- },
- body: JSON.stringify({
- identification: credentials.identification,
- password: credentials.password,
- }),
- });
+ logger.info({ endpoint: FLARUM_TOKEN_URL }, "Requesting Flarum login token");
+
+ let response: Response;
+ try {
+ response = await fetch(FLARUM_TOKEN_URL, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ "User-Agent": getUserAgent(),
+ },
+ body: JSON.stringify({
+ identification: credentials.identification,
+ password: credentials.password,
+ }),
+ });
+ } catch (err) {
+ logger.error(
+ { err, endpoint: FLARUM_TOKEN_URL },
+ "Flarum token request failed before response",
+ );
+ throw new Error("无法连接星火论坛,请检查网络后重试。");
+ }
if (!response.ok) {
+ logger.warn(
+ { endpoint: FLARUM_TOKEN_URL, status: response.status },
+ "Flarum rejected login token request",
+ );
throw new Error("论坛登录失败,请检查账号和密码。");
}
@@ -174,6 +189,14 @@ ipcMain.handle("request-flarum-token", async (_event, payload: unknown) => {
userId === undefined ||
userId === null
) {
+ logger.warn(
+ {
+ endpoint: FLARUM_TOKEN_URL,
+ hasToken: typeof data.token === "string" && data.token.length > 0,
+ hasUserId: userId !== undefined && userId !== null,
+ },
+ "Flarum token response missing required fields",
+ );
throw new Error("论坛登录响应异常,请稍后重试。");
}
diff --git a/src/__tests__/unit/backendApi.test.ts b/src/__tests__/unit/backendApi.test.ts
new file mode 100644
index 00000000..33f05106
--- /dev/null
+++ b/src/__tests__/unit/backendApi.test.ts
@@ -0,0 +1,74 @@
+import axios from "axios";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { exchangeFlarumToken } from "@/modules/backendApi";
+
+const axiosMocks = vi.hoisted(() => {
+ const post = vi.fn();
+ return {
+ instance: {
+ defaults: { headers: { common: {} as Record } },
+ get: vi.fn(),
+ post,
+ },
+ post,
+ };
+});
+
+const loggerMocks = vi.hoisted(() => ({
+ error: vi.fn(),
+}));
+
+vi.mock("axios", () => ({
+ default: {
+ create: vi.fn(() => axiosMocks.instance),
+ isAxiosError: (error: unknown) =>
+ Boolean((error as { isAxiosError?: boolean }).isAxiosError),
+ },
+}));
+
+vi.mock("pino", () => ({
+ default: () => loggerMocks,
+}));
+
+describe("backend API auth exchange", () => {
+ beforeEach(() => {
+ vi.mocked(axios.create).mockClear();
+ axiosMocks.post.mockReset();
+ loggerMocks.error.mockReset();
+ });
+
+ it("maps backend connection failures to a user-actionable login error", async () => {
+ const error = Object.assign(new Error("Network Error"), {
+ code: "ERR_NETWORK",
+ isAxiosError: true,
+ request: {},
+ });
+ axiosMocks.post.mockRejectedValue(error);
+
+ await expect(
+ exchangeFlarumToken({ flarumUserId: "42", flarumToken: "forum-token" }),
+ ).rejects.toThrow("无法连接星火账号服务,请确认后端服务已启动或稍后重试。");
+ expect(loggerMocks.error).toHaveBeenCalledWith(
+ {
+ code: "ERR_NETWORK",
+ message: "Network Error",
+ status: undefined,
+ },
+ "Spark backend auth exchange failed",
+ );
+ expect(JSON.stringify(loggerMocks.error.mock.calls)).not.toContain("forum-token");
+ });
+
+ it("maps backend server failures to an update-required login error", async () => {
+ const error = Object.assign(new Error("Request failed with status code 500"), {
+ isAxiosError: true,
+ response: { status: 500 },
+ });
+ axiosMocks.post.mockRejectedValue(error);
+
+ await expect(
+ exchangeFlarumToken({ flarumUserId: "42", flarumToken: "forum-token" }),
+ ).rejects.toThrow("星火账号服务异常,请确认后端数据库迁移已执行后重试。");
+ });
+});
diff --git a/src/__tests__/unit/flarumAuth.test.ts b/src/__tests__/unit/flarumAuth.test.ts
index 3026e47f..db8bca7b 100644
--- a/src/__tests__/unit/flarumAuth.test.ts
+++ b/src/__tests__/unit/flarumAuth.test.ts
@@ -32,4 +32,29 @@ describe("requestFlarumToken", () => {
expect(axios.post).not.toHaveBeenCalled();
expect(token).toEqual({ token: "forum-token", userId: "42" });
});
+
+ it("rejects malformed token responses from main-process IPC", async () => {
+ vi.mocked(window.ipcRenderer.invoke).mockResolvedValue({
+ token: "",
+ user_id: 42,
+ });
+
+ await expect(
+ requestFlarumToken({ identification: "momen", password: "secret" }),
+ ).rejects.toThrow("论坛登录响应异常,请稍后重试。");
+ });
+
+ it("strips Electron IPC wrapper text from known login errors", async () => {
+ vi.mocked(window.ipcRenderer.invoke).mockRejectedValue(
+ new Error(
+ "Error invoking remote method 'request-flarum-token': Error: 无法连接星火论坛,请检查网络后重试。",
+ ),
+ );
+
+ await expect(
+ requestFlarumToken({ identification: "momen", password: "secret" }),
+ ).rejects.toMatchObject({
+ message: "无法连接星火论坛,请检查网络后重试。",
+ });
+ });
});
diff --git a/src/modules/backendApi.ts b/src/modules/backendApi.ts
index e07eed6f..fbd61d40 100644
--- a/src/modules/backendApi.ts
+++ b/src/modules/backendApi.ts
@@ -1,4 +1,5 @@
-import axios from "axios";
+import axios, { type AxiosResponse } from "axios";
+import pino from "pino";
import { SPARK_BACKEND_BASE_URL } from "@/global/storeConfig";
import type {
@@ -19,9 +20,42 @@ const backend = axios.create({
baseURL: SPARK_BACKEND_BASE_URL,
timeout: 10000,
});
+const logger = pino({ name: "backendApi" });
type ApiRecord = Record;
+const normalizeBackendAuthError = (error: unknown): Error => {
+ if (!axios.isAxiosError(error)) {
+ return error instanceof Error ? error : new Error("登录失败,请稍后重试。");
+ }
+
+ logger.error(
+ {
+ code: error.code,
+ message: error.message,
+ status: error.response?.status,
+ },
+ "Spark backend auth exchange failed",
+ );
+
+ if (!error.response) {
+ return new Error("无法连接星火账号服务,请确认后端服务已启动或稍后重试。");
+ }
+
+ const status = error.response.status;
+ if (status === 401) {
+ return new Error("论坛登录失败,请检查账号和密码。");
+ }
+ if (status === 503) {
+ return new Error("星火账号服务暂时无法连接论坛,请稍后重试。");
+ }
+ if (status === 500) {
+ return new Error("星火账号服务异常,请确认后端数据库迁移已执行后重试。");
+ }
+
+ return new Error(`星火账号服务返回异常 (${status}),请稍后重试。`);
+};
+
const asApiRecord = (value: unknown): ApiRecord => {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as ApiRecord;
@@ -145,10 +179,15 @@ export const exchangeFlarumToken = async (payload: {
flarumUserId: string;
flarumToken: string;
}): Promise => {
- const response = await backend.post("/auth/flarum", {
- flarum_user_id: payload.flarumUserId,
- flarum_token: payload.flarumToken,
- });
+ let response: AxiosResponse;
+ try {
+ response = await backend.post("/auth/flarum", {
+ flarum_user_id: payload.flarumUserId,
+ flarum_token: payload.flarumToken,
+ });
+ } catch (error) {
+ throw normalizeBackendAuthError(error);
+ }
const data = asApiRecord(response.data);
return {
diff --git a/src/modules/flarumAuth.ts b/src/modules/flarumAuth.ts
index 08b1b52c..b136c24b 100644
--- a/src/modules/flarumAuth.ts
+++ b/src/modules/flarumAuth.ts
@@ -12,15 +12,49 @@ const asRecord = (value: unknown): Record => {
return {};
};
+const knownLoginErrorMessages = [
+ "无法连接星火论坛,请检查网络后重试。",
+ "论坛登录失败,请检查账号和密码。",
+ "论坛登录响应异常,请稍后重试。",
+ "登录信息格式不正确,请重新输入。",
+];
+
+const normalizeIpcError = (error: unknown): Error => {
+ if (!(error instanceof Error)) {
+ return new Error("登录失败,请稍后重试");
+ }
+
+ const knownMessage = knownLoginErrorMessages.find((message) =>
+ error.message.includes(message),
+ );
+ return knownMessage ? new Error(knownMessage) : error;
+};
+
export const requestFlarumToken = async (
payload: FlarumLoginPayload,
): Promise => {
- const data = asRecord(
- await window.ipcRenderer.invoke("request-flarum-token", payload),
- );
+ let data: Record;
+ try {
+ data = asRecord(
+ await window.ipcRenderer.invoke("request-flarum-token", payload),
+ );
+ } catch (error) {
+ throw normalizeIpcError(error);
+ }
+
+ const token = data.token;
+ const userId = data.userId ?? data.user_id;
+ if (
+ typeof token !== "string" ||
+ !token ||
+ userId === undefined ||
+ userId === null
+ ) {
+ throw new Error("论坛登录响应异常,请稍后重试。");
+ }
return {
- token: String(data.token || ""),
- userId: String(data.userId || data.user_id || ""),
+ token,
+ userId: String(userId),
};
};