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 @@ 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 @@ + + + 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" />
-