108 KiB
Spark Client Account Collections Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Extend the Electron/Vue Spark Store client with forum login, sidebar account entry, account management, main-content detail pages, reviews, favorites, downloaded history, and cloud installed-app sync while preserving anonymous browse/install/remove/update flows.
Architecture: Keep backend communication in small TypeScript modules and keep Vue state in focused global modules. Replace the app-detail overlay with a routed state inside App.vue content area, and add account/favorites/user-management components without changing Electron package-management IPC contracts.
Tech Stack: Electron 40, Vue 3 Composition API, TypeScript strict mode, Axios, Vitest, Testing Library Vue, existing IPC facade, existing install queue, FastAPI backend API.
File Structure
Client repository: /home/spark/Desktop/shenmo-spark-store/spark-store.
Modify:
.gitignore- already ignores.superpowers/; leave as-is unless missing.src/global/storeConfig.ts- add backend URL and forum URLs.src/global/typedefinition.ts- add account, review, favorite, downloaded, sync, and availability types.src/vite-env.d.ts- add backend env andwindow.ipcRenderer.invokeusage remains unchanged.src/__tests__/setup.ts- keepwindow.apm_store.archas bare architectureamd64and make IPC mocks writable.src/components/AppSidebar.vue- replace the title block with account entry and quick menu events.src/App.vue- coordinate auth modal, detail view state, account management view, favorites, reviews, downloaded records, and startup sync.src/modules/processInstall.ts- expose selected-app queue creation result for cloud downloaded-record writes without changing existing install IPC payloads.src/components/InstalledAppsModal.vue- add cloud sync actions and login gating.
Create:
src/global/authState.ts- auth session persistence and token header propagation.src/global/accountSyncState.ts- local installed-sync preference helpers.src/modules/backendApi.ts- backend client, DTO mapping, favorites, reviews, downloaded records, and app-list API helpers.src/modules/flarumAuth.ts- Flarum token exchange helper; forum password never leaves this request.src/modules/appIdentity.ts- favorite app key, review app key, package arch, and selected display app helpers.src/modules/favoriteAvailability.ts- client-side favorite resolution and batch-install planning.src/modules/appListSync.ts- installed-list filtering and cloud sync payload helpers.src/components/LoginModal.vue- forum login/register prompt.src/components/LoginPromptModal.vue- reusable account-only feature gate prompt.src/components/AccountQuickMenu.vue- quick account menu for logged-in users.src/components/AppDetailPage.vue- full content-area detail page replacingAppDetailModal.src/components/ReviewsPanel.vue- review list/composer with anonymous prompt.src/components/FavoriteFolderSelector.vue- favorite folder selector/add action.src/components/UserManagementView.vue- profile, links, downloaded history, sync preference, and favorites entry.src/components/FavoriteFolderManager.vue- folders/items/statuses/batch install/bulk delete.src/components/AppListRestoreModal.vue- cloud app-list restore selector.src/__tests__/unit/accountTypes.test.ts- type/config smoke test.src/__tests__/unit/authState.test.ts- auth persistence test.src/__tests__/unit/LoginModal.test.ts- login/register modal test.src/__tests__/unit/AppSidebar.account.test.ts- account entry and quick menu tests.src/__tests__/unit/appIdentity.test.ts- key/tag helper tests.src/__tests__/unit/favoriteAvailability.test.ts- status and batch source selection tests.src/__tests__/unit/appListSync.test.ts- installed sync filtering tests.src/__tests__/unit/AppDetailPage.test.ts- detail page/back/favorite prompt test.src/__tests__/unit/FavoriteFolderManager.test.ts- folder manager status/action test.src/__tests__/unit/UserManagementView.test.ts- account management rendering test.src/__tests__/unit/AppListRestoreModal.test.ts- restore selector test.
Task 1: Add Account Config, Types, And API Client
Files:
-
Modify:
src/global/storeConfig.ts -
Modify:
src/global/typedefinition.ts -
Modify:
src/vite-env.d.ts -
Modify:
src/__tests__/setup.ts -
Create:
src/modules/backendApi.ts -
Test:
src/__tests__/unit/accountTypes.test.ts -
Step 1: Write failing account type smoke test
Create src/__tests__/unit/accountTypes.test.ts with:
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");
});
});
- Step 2: Run the smoke test and verify failure
Run: npm run test -- src/__tests__/unit/accountTypes.test.ts
Expected: FAIL because new config constants and account types do not exist.
- Step 3: Add backend and forum config
Append to src/global/storeConfig.ts after 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`;
- Step 4: Add account types
Append to src/global/typedefinition.ts:
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<number, number>;
}
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;
}
- Step 5: Add environment declaration
Add ImportMetaEnv inside the existing declare global block in src/vite-env.d.ts:
interface ImportMetaEnv {
readonly VITE_SPARK_BACKEND_BASE_URL?: string;
}
- Step 6: Normalize test IPC mocks
Modify src/__tests__/setup.ts so both exposed globals are writable and window.apm_store.arch is bare amd64:
Object.defineProperty(window, "ipcRenderer", {
value: {
send: vi.fn(),
on: vi.fn(),
off: vi.fn(),
invoke: vi.fn(),
removeListener: vi.fn(),
},
writable: true,
});
Object.defineProperty(window, "apm_store", {
value: {
arch: "amd64",
},
writable: true,
});
- Step 7: Add backend API helper
Create src/modules/backendApi.ts with:
import axios from "axios";
import { SPARK_BACKEND_BASE_URL } from "@/global/storeConfig";
import type {
AppReview,
AuthSession,
DownloadedAppList,
DownloadedAppRecord,
FavoriteFolder,
FavoriteItem,
RatingSummary,
ReviewTags,
SyncedAppList,
SyncedAppListItem,
} from "@/global/typedefinition";
const backend = axios.create({
baseURL: SPARK_BACKEND_BASE_URL,
timeout: 10000,
});
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 = JSON.parse(raw) as unknown;
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : [];
} catch {
return [];
}
};
const toUser = (raw: Record<string, unknown>): AuthSession["user"] => ({
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: Record<string, unknown>): 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: Record<string, unknown>): 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: Record<string, unknown>): 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: Record<string, unknown>): 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 || ""),
});
export const setBackendToken = (token: string | null) => {
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<AuthSession> => {
const response = await backend.post("/auth/flarum", {
flarum_user_id: payload.flarumUserId,
flarum_token: payload.flarumToken,
});
return {
accessToken: String(response.data.access_token),
tokenType: "bearer",
user: toUser(response.data.user),
};
};
export const fetchMe = async (): Promise<AuthSession["user"]> => {
const response = await backend.get("/me");
return toUser(response.data);
};
export const fetchRatingSummary = async (appKey: string): Promise<RatingSummary> => {
const response = await backend.get(`/apps/${encodeURIComponent(appKey)}/rating-summary`);
return {
averageRating: Number(response.data.average_rating || 0),
reviewCount: Number(response.data.review_count || 0),
starCounts: response.data.star_counts || {},
};
};
export const fetchReviews = async (appKey: string): Promise<AppReview[]> => {
const response = await backend.get(`/apps/${encodeURIComponent(appKey)}/reviews`);
return (response.data || []).map((item: Record<string, unknown>) => toReview(item));
};
export const submitReview = async (
appKey: string,
payload: { rating: number; content: string; tags: ReviewTags },
): Promise<AppReview> => {
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(response.data);
};
export const listFavoriteFolders = async (): Promise<FavoriteFolder[]> => {
const response = await backend.get("/me/favorite-folders");
return (response.data || []).map((item: Record<string, unknown>) => toFavoriteFolder(item));
};
export const createFavoriteFolder = async (name: string): Promise<FavoriteFolder> => {
const response = await backend.post("/me/favorite-folders", { name });
return toFavoriteFolder(response.data);
};
export const renameFavoriteFolder = async (folderId: number, name: string): Promise<FavoriteFolder> => {
const response = await backend.patch(`/me/favorite-folders/${folderId}`, { name });
return toFavoriteFolder(response.data);
};
export const deleteFavoriteFolder = async (folderId: number): Promise<void> => {
await backend.delete(`/me/favorite-folders/${folderId}`);
};
export const listFavoriteItems = async (folderId: number): Promise<FavoriteItem[]> => {
const response = await backend.get(`/me/favorite-folders/${folderId}/items`);
return (response.data || []).map((item: Record<string, unknown>) => toFavoriteItem(item));
};
export const addFavoriteItem = async (
folderId: number | "default",
item: Omit<FavoriteItem, "id" | "createdAt">,
): Promise<FavoriteItem> => {
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(response.data);
};
export const deleteFavoriteItem = async (folderId: number, itemId: number): Promise<void> => {
await backend.delete(`/me/favorite-folders/${folderId}/items/${itemId}`);
};
export const bulkDeleteFavoriteItems = async (folderId: number, itemIds: number[]): Promise<number> => {
const response = await backend.post(`/me/favorite-folders/${folderId}/items/bulk-delete`, { item_ids: itemIds });
return Number(response.data.deleted_count || 0);
};
export const listDownloadedApps = async (page = 1, pageSize = 20): Promise<DownloadedAppList> => {
const response = await backend.get("/me/downloaded-apps", { params: { page, page_size: pageSize } });
return {
items: (response.data.items || []).map((item: Record<string, unknown>) => toDownloadedApp(item)),
total: Number(response.data.total || 0),
page: Number(response.data.page || page),
pageSize: Number(response.data.page_size || pageSize),
};
};
export const recordDownloadedApp = async (item: Omit<DownloadedAppRecord, "id" | "downloadedAt">): Promise<DownloadedAppRecord> => {
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(response.data);
};
export const fetchSyncedAppList = async (): Promise<SyncedAppList | null> => {
const response = await backend.get("/me/app-list");
if (!response.data) return null;
return {
snapshotName: String(response.data.snapshot_name || "默认列表"),
clientArch: String(response.data.client_arch || "unknown"),
distro: String(response.data.distro || "unknown"),
updatedAt: String(response.data.updated_at || ""),
items: (response.data.items || []).map((item: Record<string, unknown>) => ({
id: Number(item.id),
pkgname: String(item.pkgname || ""),
origin: item.origin === "spark" ? "spark" : "apm",
category: String(item.category || ""),
version: String(item.version || ""),
packageArch: String(item.package_arch || "unknown"),
appName: String(item.app_name || ""),
iconUrl: String(item.icon_url || ""),
})),
};
};
export const uploadSyncedAppList = async (payload: {
clientArch: string;
distro: string;
items: SyncedAppListItem[];
}): Promise<SyncedAppList> => {
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 {
snapshotName: String(response.data.snapshot_name || "默认列表"),
clientArch: String(response.data.client_arch || payload.clientArch),
distro: String(response.data.distro || payload.distro),
updatedAt: String(response.data.updated_at || ""),
items: payload.items,
};
};
- Step 8: Run type smoke test and verify pass
Run: npm run test -- src/__tests__/unit/accountTypes.test.ts
Expected: PASS.
- Step 9: Commit shared client account API foundation
Run:
git add src/global/storeConfig.ts src/global/typedefinition.ts src/vite-env.d.ts src/__tests__/setup.ts src/modules/backendApi.ts src/__tests__/unit/accountTypes.test.ts
git commit -m "feat(account): add client account api foundation"
Expected: commit succeeds.
Task 2: Add Auth State, Flarum Login, And Sidebar Account Entry
Files:
-
Create:
src/global/authState.ts -
Create:
src/modules/flarumAuth.ts -
Create:
src/components/LoginModal.vue -
Create:
src/components/LoginPromptModal.vue -
Create:
src/components/AccountQuickMenu.vue -
Modify:
src/components/AppSidebar.vue -
Modify:
src/App.vue -
Test:
src/__tests__/unit/authState.test.ts -
Test:
src/__tests__/unit/LoginModal.test.ts -
Test:
src/__tests__/unit/AppSidebar.account.test.ts -
Step 1: Write failing auth state test
Create src/__tests__/unit/authState.test.ts with:
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();
});
});
- Step 2: Write failing login modal test
Create src/__tests__/unit/LoginModal.test.ts with:
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);
});
});
- Step 3: Write failing sidebar account entry test
Create src/__tests__/unit/AppSidebar.account.test.ts with:
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();
});
});
- Step 4: Run auth/login/sidebar tests and verify failure
Run:
npm run test -- src/__tests__/unit/authState.test.ts src/__tests__/unit/LoginModal.test.ts src/__tests__/unit/AppSidebar.account.test.ts
Expected: FAIL because modules/components/props do not exist.
- Step 5: Add auth state
Create src/global/authState.ts with:
import { computed, ref } from "vue";
import type { AuthSession } from "@/global/typedefinition";
import { setBackendToken } from "@/modules/backendApi";
const STORAGE_KEY = "spark-store-auth";
const readStoredSession = (): AuthSession | null => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as AuthSession;
if (!parsed.accessToken || parsed.tokenType !== "bearer" || !parsed.user) return null;
return parsed;
} catch {
return null;
}
};
export const authSession = ref<AuthSession | null>(readStoredSession());
export const currentUser = computed(() => authSession.value?.user ?? null);
export const isLoggedIn = computed(() => Boolean(authSession.value?.accessToken));
setBackendToken(authSession.value?.accessToken ?? null);
export const setAuthSession = (session: AuthSession) => {
authSession.value = session;
localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
setBackendToken(session.accessToken);
};
export const logout = () => {
authSession.value = null;
localStorage.removeItem(STORAGE_KEY);
setBackendToken(null);
};
- Step 6: Add Flarum login helper
Create src/modules/flarumAuth.ts with:
import axios from "axios";
import { FLARUM_BASE_URL } from "@/global/storeConfig";
import type { FlarumLoginPayload } from "@/global/typedefinition";
export interface FlarumTokenResponse {
token: string;
userId: string;
}
export const requestFlarumToken = async (payload: FlarumLoginPayload): Promise<FlarumTokenResponse> => {
const response = await axios.post(`${FLARUM_BASE_URL}/api/token`, {
identification: payload.identification,
password: payload.password,
});
return {
token: String(response.data.token || ""),
userId: String(response.data.userId || ""),
};
};
- Step 7: Create login modal
Create src/components/LoginModal.vue with:
<template>
<Transition enter-active-class="duration-200 ease-out" enter-from-class="opacity-0" enter-to-class="opacity-100" leave-active-class="duration-150 ease-in" leave-from-class="opacity-100" leave-to-class="opacity-0">
<div v-if="show" class="fixed inset-0 z-[90] flex items-center justify-center bg-slate-900/70 p-4" @click.self="$emit('close')">
<form class="w-full max-w-md rounded-3xl border border-white/10 bg-white p-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900" @submit.prevent="submit">
<div class="mb-5 flex items-start justify-between gap-4">
<div>
<h2 class="text-xl font-semibold text-slate-900 dark:text-white">登录星火账号</h2>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">使用论坛账号登录,密码只发送给论坛。</p>
</div>
<button type="button" class="text-slate-400 hover:text-slate-700 dark:hover:text-slate-200" aria-label="关闭" @click="$emit('close')">
<i class="fas fa-xmark"></i>
</button>
</div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-200" for="flarumAccount">论坛账号</label>
<input id="flarumAccount" v-model="identification" autocomplete="username" class="mt-2 w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-brand dark:border-slate-700 dark:bg-slate-800" />
<label class="mt-4 block text-sm font-medium text-slate-700 dark:text-slate-200" for="flarumPassword">论坛密码</label>
<input id="flarumPassword" v-model="password" type="password" autocomplete="current-password" class="mt-2 w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none focus:border-brand dark:border-slate-700 dark:bg-slate-800" />
<p v-if="error" class="mt-4 rounded-2xl bg-rose-50 px-4 py-3 text-sm text-rose-600 dark:bg-rose-500/10 dark:text-rose-300">{{ error }}</p>
<button type="submit" class="mt-6 w-full rounded-2xl bg-brand px-4 py-3 text-sm font-semibold text-white transition hover:bg-brand-dark disabled:opacity-50" :disabled="loading || !identification.trim() || !password">
{{ loading ? "登录中..." : "登录" }}
</button>
<button type="button" class="mt-3 w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800" @click="$emit('register')">
注册账号
</button>
</form>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
const props = defineProps<{
show: boolean;
loading: boolean;
error: string;
}>();
const emit = defineEmits<{
close: [];
login: [payload: { identification: string; password: string }];
register: [];
}>();
const identification = ref("");
const password = ref("");
const submit = () => {
emit("login", { identification: identification.value.trim(), password: password.value });
};
watch(
() => props.show,
(show) => {
if (!show) password.value = "";
},
);
</script>
- Step 8: Create reusable login prompt modal
Create src/components/LoginPromptModal.vue with:
<template>
<Transition enter-active-class="duration-200 ease-out" enter-from-class="opacity-0" enter-to-class="opacity-100" leave-active-class="duration-150 ease-in" leave-from-class="opacity-100" leave-to-class="opacity-0">
<div v-if="show" class="fixed inset-0 z-[85] flex items-center justify-center bg-slate-900/60 p-4" @click.self="$emit('close')">
<div class="w-full max-w-sm rounded-3xl bg-white p-6 shadow-2xl dark:bg-slate-900">
<h2 class="text-lg font-semibold text-slate-900 dark:text-white">需要登录</h2>
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">{{ message }}</p>
<div class="mt-6 flex gap-3">
<button type="button" class="flex-1 rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white" @click="$emit('login')">登录</button>
<button type="button" class="flex-1 rounded-2xl border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-600 dark:border-slate-700 dark:text-slate-200" @click="$emit('register')">注册</button>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
defineProps<{
show: boolean;
message: string;
}>();
defineEmits<{
close: [];
login: [];
register: [];
}>();
</script>
- Step 9: Create account quick menu
Create src/components/AccountQuickMenu.vue with:
<template>
<div class="absolute left-0 right-0 top-full z-20 mt-2 rounded-2xl border border-slate-200 bg-white p-2 shadow-xl dark:border-slate-800 dark:bg-slate-900">
<button type="button" class="quick-menu-item" @click="$emit('open-user-management')"><i class="fas fa-user-cog"></i><span>用户管理</span></button>
<button type="button" class="quick-menu-item" @click="$emit('open-favorites')"><i class="fas fa-heart"></i><span>我的收藏</span></button>
<button type="button" class="quick-menu-item" @click="$emit('open-forum')"><i class="fas fa-comments"></i><span>论坛首页</span></button>
<button type="button" class="quick-menu-item" @click="$emit('edit-profile')"><i class="fas fa-id-card"></i><span>修改论坛资料</span></button>
<button type="button" class="quick-menu-item text-rose-600 dark:text-rose-400" @click="$emit('logout')"><i class="fas fa-right-from-bracket"></i><span>退出登录</span></button>
</div>
</template>
<script setup lang="ts">
defineEmits<{
"open-user-management": [];
"open-favorites": [];
"open-forum": [];
"edit-profile": [];
logout: [];
}>();
</script>
<style scoped>
.quick-menu-item {
display: flex;
width: 100%;
align-items: center;
gap: 0.625rem;
border-radius: 0.75rem;
padding: 0.625rem 0.75rem;
text-align: left;
font-size: 0.875rem;
font-weight: 600;
color: inherit;
}
.quick-menu-item:hover {
background: rgba(0, 113, 227, 0.08);
}
</style>
- Step 10: Update sidebar account entry
Modify src/components/AppSidebar.vue:
- Import
ref,AccountQuickMenu, andSparkUser:
import { computed, ref } from "vue";
import AccountQuickMenu from "./AccountQuickMenu.vue";
import type { SidebarEntry, SparkUser } from "../global/typedefinition";
- Add prop:
currentUser: SparkUser | null;
- Add emits:
(e: "request-login"): void;
(e: "open-user-management"): void;
(e: "open-favorites"): void;
(e: "open-forum"): void;
(e: "edit-profile"): void;
(e: "logout"): void;
- Add state and handler:
const showAccountMenu = ref(false);
const handleAccountClick = () => {
if (!props.currentUser) {
emit("request-login");
return;
}
showAccountMenu.value = !showAccountMenu.value;
};
- Replace the current logo/title
<div class="flex items-center gap-3">...</div>at the top with:
<div class="relative flex-1">
<button
type="button"
class="flex w-full items-center gap-3 rounded-2xl p-2 text-left transition hover:bg-slate-100 dark:hover:bg-slate-800"
:aria-label="currentUser ? currentUser.displayName || currentUser.username : '登录 / 注册'"
@click="handleAccountClick"
>
<img
:src="currentUser?.avatarUrl || amberLogo"
alt=""
class="h-11 w-11 rounded-2xl bg-white/70 object-cover p-2 shadow-sm ring-1 ring-slate-900/5 dark:bg-slate-800"
:class="currentUser?.avatarUrl ? 'p-0' : 'p-2'"
/>
<div class="min-w-0 flex flex-col">
<span class="text-xs uppercase tracking-[0.22em] text-slate-500 dark:text-slate-400">星火账号</span>
<span class="truncate text-lg font-semibold text-slate-900 dark:text-white">{{ currentUser ? currentUser.displayName || currentUser.username : '登录 / 注册' }}</span>
</div>
</button>
<AccountQuickMenu
v-if="currentUser && showAccountMenu"
@open-user-management="$emit('open-user-management')"
@open-favorites="$emit('open-favorites')"
@open-forum="$emit('open-forum')"
@edit-profile="$emit('edit-profile')"
@logout="$emit('logout')"
/>
</div>
- Step 11: Wire login shell in App.vue
Modify src/App.vue:
- Import new state/components/helpers:
import LoginModal from "./components/LoginModal.vue";
import LoginPromptModal from "./components/LoginPromptModal.vue";
import { currentUser, isLoggedIn, logout, setAuthSession } from "./global/authState";
import { FLARUM_BASE_URL, FLARUM_REGISTER_URL, FLARUM_SETTINGS_URL } from "./global/storeConfig";
import { exchangeFlarumToken } from "./modules/backendApi";
import { requestFlarumToken } from "./modules/flarumAuth";
import type { FlarumLoginPayload } from "./global/typedefinition";
- Add state:
const showLoginModal = ref(false);
const loginLoading = ref(false);
const loginError = ref("");
const showLoginPrompt = ref(false);
const loginPromptMessage = ref("该功能需要登录星火账号后使用。");
- Pass sidebar props/events:
:current-user="currentUser"
@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"
- Mount modals before
AboutModal:
<LoginModal
:show="showLoginModal"
:loading="loginLoading"
:error="loginError"
@close="showLoginModal = false"
@login="handleFlarumLogin"
@register="openExternalUrl(FLARUM_REGISTER_URL)"
/>
<LoginPromptModal
:show="showLoginPrompt"
:message="loginPromptMessage"
@close="showLoginPrompt = false"
@login="openLoginFromPrompt"
@register="openExternalUrl(FLARUM_REGISTER_URL)"
/>
- Add handlers:
const openExternalUrl = (url: string) => {
window.open(url, "_blank");
};
const requireLogin = (message: string) => {
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 flarum = await requestFlarumToken(payload);
const session = await exchangeFlarumToken({ flarumUserId: flarum.userId, flarumToken: flarum.token });
setAuthSession(session);
showLoginModal.value = false;
} catch (error) {
loginError.value = error instanceof Error ? error.message : "登录失败";
} finally {
loginLoading.value = false;
}
};
const openUserManagement = async () => {
if (!requireLogin("用户管理需要登录星火账号。")) return;
currentView.value = "account";
activeTab.value = "account";
};
const openFavoriteManagement = async () => {
if (!requireLogin("我的收藏需要登录星火账号。")) return;
currentView.value = "favorites";
activeTab.value = "favorites";
};
- Step 12: Run auth/sidebar tests
Run:
npm run test -- src/__tests__/unit/authState.test.ts src/__tests__/unit/LoginModal.test.ts src/__tests__/unit/AppSidebar.account.test.ts src/__tests__/unit/AppSidebar.test.ts
Expected: PASS. If existing AppSidebar.test.ts fails, add currentUser: null to its renderSidebar default props.
- Step 13: Commit auth and sidebar account entry
Run:
git add src/global/authState.ts src/modules/flarumAuth.ts src/components/LoginModal.vue src/components/LoginPromptModal.vue src/components/AccountQuickMenu.vue src/components/AppSidebar.vue src/App.vue src/__tests__/unit/authState.test.ts src/__tests__/unit/LoginModal.test.ts src/__tests__/unit/AppSidebar.account.test.ts src/__tests__/unit/AppSidebar.test.ts
git commit -m "feat(account): add forum login and sidebar account entry"
Expected: commit succeeds.
Task 3: Add App Identity Helpers And Main-Content Detail Page
Files:
-
Create:
src/modules/appIdentity.ts -
Create:
src/components/AppDetailPage.vue -
Modify:
src/App.vue -
Test:
src/__tests__/unit/appIdentity.test.ts -
Test:
src/__tests__/unit/AppDetailPage.test.ts -
Step 1: Write failing app identity tests
Create src/__tests__/unit/appIdentity.test.ts with:
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");
});
});
- Step 2: Write failing detail page test
Create src/__tests__/unit/AppDetailPage.test.ts with:
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("收藏应用需要登录星火账号。");
});
});
- Step 3: Run identity/detail tests and verify failure
Run: npm run test -- src/__tests__/unit/appIdentity.test.ts src/__tests__/unit/AppDetailPage.test.ts
Expected: FAIL because helper and detail page do not exist.
- Step 4: Add app identity helpers
Create src/modules/appIdentity.ts with:
import type { App, ReviewTags } from "@/global/typedefinition";
export const parsePackageArch = (filename: string | undefined): string => {
if (!filename) return "unknown";
const match = filename.match(/_([^_]+)\.(?:deb|rpm|appimage|tar\.gz)$/i);
return match?.[1] || "unknown";
};
export const buildStoreArch = (origin: "spark" | "apm", clientArch: string): string => {
return origin === "spark" ? `${clientArch}-store` : `${clientArch}-apm`;
};
export const buildFavoriteAppKey = (app: Pick<App, "category" | "pkgname">): 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;
if (app.viewingOrigin === "apm") return app.apmApp || app;
return app.sparkApp || app.apmApp || app;
};
export const buildReviewTags = (
app: App,
system: { clientArch: string; distro: string },
): ReviewTags => ({
origin: app.origin,
category: app.category || "unknown",
pkgname: app.pkgname,
version: app.version || "unknown",
packageArch: app.arch || parsePackageArch(app.filename),
clientArch: system.clientArch || "unknown",
distro: system.distro || "unknown",
});
- Step 5: Create main-content detail page
Create src/components/AppDetailPage.vue with this page-level implementation:
<template>
<section class="mx-auto max-w-6xl space-y-6">
<button type="button" class="inline-flex items-center gap-2 rounded-full border border-slate-200/70 bg-white/90 px-4 py-2 text-sm font-medium text-slate-600 shadow-sm transition hover:bg-slate-50 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-800/90 dark:text-slate-300" aria-label="返回" @click="$emit('back')">
<i class="fas fa-arrow-left"></i>
<span>返回</span>
</button>
<div class="rounded-3xl border border-slate-200/70 bg-white/95 p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div class="flex flex-col gap-6 lg:flex-row">
<div class="w-full flex-shrink-0 space-y-5 lg:w-72">
<div class="text-center">
<div class="mx-auto flex h-28 w-28 items-center justify-center overflow-hidden rounded-3xl bg-gradient-to-b from-slate-100 to-slate-200 shadow-lg dark:from-slate-800 dark:to-slate-700">
<img v-if="displayApp" :src="iconPath" alt="icon" class="h-full w-full object-cover" loading="lazy" />
</div>
<h2 class="mt-4 text-xl font-bold text-slate-900 dark:text-white">{{ displayApp?.name || "" }}</h2>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">{{ displayApp?.pkgname || "" }}</p>
<p v-if="displayApp?.version" class="mt-1 text-sm text-slate-500 dark:text-slate-400">{{ displayApp.version }}</p>
</div>
<div class="flex items-center justify-between rounded-2xl border border-slate-200/60 bg-slate-50/50 px-4 py-3 dark:border-slate-800/60 dark:bg-slate-800/50">
<span class="text-sm text-slate-500 dark:text-slate-400">来源</span>
<div v-if="app?.isMerged" class="flex gap-1 overflow-hidden rounded-lg border border-slate-200 shadow-sm dark:border-slate-700">
<button v-if="app.sparkApp" type="button" class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors" :class="viewingOrigin === 'spark' ? 'bg-orange-500 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-400 dark:hover:bg-slate-600'" @click="viewingOrigin = 'spark'">Spark</button>
<button v-if="app.apmApp" type="button" class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors" :class="viewingOrigin === 'apm' ? 'bg-blue-500 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-400 dark:hover:bg-slate-600'" @click="viewingOrigin = 'apm'">APM</button>
</div>
<span v-else-if="displayApp" class="rounded-md px-2 py-1 text-xs font-bold uppercase tracking-wider" :class="displayApp.origin === 'spark' ? 'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400' : 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'">
{{ displayApp.origin === "spark" ? "Spark" : "APM" }}
</span>
</div>
<div class="space-y-2">
<button v-if="!isInstalled" type="button" class="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-slate-800 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition hover:bg-slate-700 disabled:opacity-40 dark:bg-slate-700 dark:hover:bg-slate-600" :disabled="isOtherVersionInstalled" @click="handleInstall">
<i class="fas fa-download text-xs"></i>
<span>{{ isOtherVersionInstalled ? otherVersionText : "安装" }}</span>
</button>
<template v-else>
<div class="flex gap-2">
<button type="button" class="inline-flex flex-1 items-center justify-center gap-2 rounded-xl bg-slate-800 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600" @click="emit('open-app', displayApp?.pkgname || '', displayApp?.origin)">
<i class="fas fa-external-link-alt text-xs"></i>
<span>打开</span>
</button>
<button type="button" class="inline-flex flex-1 items-center justify-center gap-2 rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm font-medium text-slate-600 transition hover:border-rose-400 hover:bg-rose-50 hover:text-rose-500 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300" @click="handleRemove">
<i class="fas fa-trash text-xs"></i>
<span>卸载</span>
</button>
</div>
</template>
<button type="button" class="inline-flex w-full items-center justify-center gap-2 rounded-xl border border-rose-200 px-4 py-2.5 text-sm font-medium text-rose-600 transition hover:bg-rose-50 dark:border-rose-500/40 dark:text-rose-300 dark:hover:bg-rose-500/10" @click="handleFavorite">
<i class="fas fa-heart text-xs"></i>
<span>收藏</span>
</button>
</div>
<div class="space-y-2 border-t border-slate-200/60 pt-2 dark:border-slate-800/60">
<div v-if="displayApp?.category" class="flex items-center justify-between px-1">
<span class="text-xs text-slate-400">分类</span>
<span class="max-w-[140px] truncate text-xs font-medium text-slate-700 dark:text-slate-300">{{ displayApp.category }}</span>
</div>
<div v-if="displayApp?.author" class="flex items-center justify-between px-1">
<span class="text-xs text-slate-400">作者</span>
<span class="max-w-[140px] truncate text-xs font-medium text-slate-700 dark:text-slate-300">{{ displayApp.author }}</span>
</div>
<div v-if="displayApp?.size" class="flex items-center justify-between px-1">
<span class="text-xs text-slate-400">大小</span>
<span class="text-xs font-medium text-slate-700 dark:text-slate-300">{{ displayApp.size }}</span>
</div>
</div>
</div>
<div class="min-w-0 flex-1 space-y-5">
<div v-if="displayApp?.more && displayApp.more.trim() !== ''" class="rounded-2xl border border-slate-200/60 bg-slate-50/50 p-5 dark:border-slate-800/60 dark:bg-slate-800/30">
<h3 class="mb-3 flex items-center gap-2 text-base font-semibold text-slate-900 dark:text-white"><i class="fas fa-info-circle text-slate-400"></i>应用详情</h3>
<div class="space-y-2 text-sm leading-relaxed text-slate-600 dark:text-slate-300" v-html="displayApp.more.replace(/\n/g, '<br>')"></div>
</div>
<div v-else class="rounded-2xl border border-slate-200/60 bg-slate-50/30 p-8 text-center dark:border-slate-800/60 dark:bg-slate-800/20">
<p class="text-sm text-slate-400">暂无应用详情</p>
</div>
<div v-if="screenshots.length">
<h3 class="mb-3 flex items-center gap-2 text-base font-semibold text-slate-900 dark:text-white"><i class="fas fa-images text-slate-400"></i>应用截图</h3>
<div class="grid gap-3 sm:grid-cols-2">
<img v-for="(screen, index) in screenshots" :key="index" :src="screen" alt="screenshot" class="h-44 w-full cursor-pointer rounded-2xl border border-slate-200/60 object-cover shadow-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-lg dark:border-slate-800/60" loading="lazy" @click="$emit('open-preview', index)" @error="hideImage" />
</div>
</div>
<div v-else class="rounded-2xl border border-slate-200/60 bg-slate-50/30 p-8 text-center dark:border-slate-800/60 dark:bg-slate-800/20">
<p class="text-sm text-slate-400">暂无应用截图</p>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { APM_STORE_BASE_URL, getHybridDefaultOrigin } from "@/global/storeConfig";
import type { App, ReviewTags } from "@/global/typedefinition";
const props = defineProps<{
app: App | null;
screenshots: string[];
sparkInstalled: boolean;
apmInstalled: boolean;
loggedIn: boolean;
reviewAppKey: string;
reviewTags: ReviewTags | null;
}>();
const emit = defineEmits<{
back: [];
install: [app: App];
remove: [app: App];
favorite: [app: App];
"request-login": [message: string];
"open-preview": [index: number];
"open-app": [pkgname: string, origin?: "spark" | "apm"];
"check-install": [app: App];
}>();
const viewingOrigin = ref<"spark" | "apm">("spark");
watch(
() => props.app,
(newApp) => {
if (!newApp) return;
if (newApp.isMerged) {
viewingOrigin.value = newApp.viewingOrigin || (newApp.sparkApp ? getHybridDefaultOrigin(newApp.sparkApp) : "apm");
} else {
viewingOrigin.value = newApp.origin;
}
},
{ immediate: true },
);
const displayApp = computed(() => {
if (!props.app) return null;
if (!props.app.isMerged) return props.app;
return viewingOrigin.value === "spark" ? props.app.sparkApp || props.app : props.app.apmApp || props.app;
});
watch(
() => displayApp.value,
(newApp) => {
if (newApp) emit("check-install", newApp);
},
);
const isInstalled = computed(() => (viewingOrigin.value === "spark" ? props.sparkInstalled : props.apmInstalled));
const isOtherVersionInstalled = computed(() => (viewingOrigin.value === "spark" ? props.apmInstalled : props.sparkInstalled));
const otherVersionText = computed(() => (viewingOrigin.value === "spark" ? "已安装 APM 版" : "已安装 Spark 版"));
const iconPath = computed(() => {
if (!displayApp.value) return "";
const arch = window.apm_store.arch || "amd64";
const finalArch = displayApp.value.origin === "spark" ? `${arch}-store` : `${arch}-apm`;
return `${APM_STORE_BASE_URL}/${finalArch}/${displayApp.value.category}/${displayApp.value.pkgname}/icon.png`;
});
const handleInstall = () => {
if (displayApp.value) emit("install", displayApp.value);
};
const handleRemove = () => {
if (displayApp.value) emit("remove", displayApp.value);
};
const handleFavorite = () => {
if (!displayApp.value) return;
if (!props.loggedIn) {
emit("request-login", "收藏应用需要登录星火账号。");
return;
}
emit("favorite", displayApp.value);
};
const hideImage = (event: Event) => {
(event.target as HTMLElement).style.display = "none";
};
</script>
Do not mount reviews in this task; Task 5 adds ReviewsPanel.
- Step 6: Replace modal state with detail page state in App.vue
Modify src/App.vue:
- Import
AppDetailPageinstead ofAppDetailModal. - Replace
const showModal = ref(false);with:
const currentView = ref<"home" | "list" | "detail" | "account" | "favorites">("home");
const detailPreviousView = ref<"home" | "list">("home");
- Replace the content template branch at
src/App.vue:57-77with:
<div class="px-4 py-6 lg:px-10">
<AppDetailPage
v-if="currentView === 'detail'"
:app="currentApp"
:screenshots="screenshots"
:spark-installed="currentAppSparkInstalled"
:apm-installed="currentAppApmInstalled"
:logged-in="isLoggedIn"
:review-app-key="currentReviewAppKey"
:review-tags="currentReviewTags"
@back="closeDetail"
@install="onDetailInstall"
@remove="onDetailRemove"
@favorite="openFavoriteSelector"
@request-login="requireLogin"
@open-preview="openScreenPreview"
@open-app="openDownloadedApp"
@check-install="checkAppInstalled"
/>
<template v-else-if="activeTab === 'home'">
<HomeView
:links="homeLinks"
:lists="homeLists"
:loading="homeLoading"
:error="homeError"
:store-filter="storeFilter"
@open-detail="openDetail"
/>
</template>
<template v-else>
<AppGrid
:apps="filteredApps"
:loading="loading"
:scroll-key="activeTab + '-' + selectedCategory"
:store-filter="storeFilter"
@open-detail="openDetail"
/>
</template>
</div>
- In
selectTab, after settingactiveTab, add:
currentView.value = tab === "home" ? "home" : "list";
- In
openDetail, replaceshowModal.value = true;with:
detailPreviousView.value = activeTab.value === "home" ? "home" : "list";
currentView.value = "detail";
- Replace
closeDetailwith:
const closeDetail = () => {
currentView.value = detailPreviousView.value;
currentApp.value = null;
};
- Replace
if (showModal.value && currentApp.value)checks withif (currentView.value === "detail" && currentApp.value). - Remove the old
<AppDetailModal ... />block.
- Step 7: Run detail tests
Run: npm run test -- src/__tests__/unit/appIdentity.test.ts src/__tests__/unit/AppDetailPage.test.ts
Expected: PASS.
- Step 8: Commit main-content detail page
Run:
git add src/modules/appIdentity.ts src/components/AppDetailPage.vue src/App.vue src/__tests__/unit/appIdentity.test.ts src/__tests__/unit/AppDetailPage.test.ts
git commit -m "feat(detail): move app details into content view"
Expected: commit succeeds.
Task 4: Add Favorites API UI And Availability Resolver
Files:
-
Create:
src/modules/favoriteAvailability.ts -
Create:
src/components/FavoriteFolderSelector.vue -
Create:
src/components/FavoriteFolderManager.vue -
Modify:
src/App.vue -
Test:
src/__tests__/unit/favoriteAvailability.test.ts -
Test:
src/__tests__/unit/FavoriteFolderManager.test.ts -
Step 1: Write failing favorite availability tests
Create src/__tests__/unit/favoriteAvailability.test.ts with:
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> = {}): 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");
});
});
- Step 2: Write failing folder manager test
Create src/__tests__/unit/FavoriteFolderManager.test.ts with:
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]);
});
});
- Step 3: Run favorite tests and verify failure
Run: npm run test -- src/__tests__/unit/favoriteAvailability.test.ts src/__tests__/unit/FavoriteFolderManager.test.ts
Expected: FAIL because modules/components do not exist.
- Step 4: Add favorite availability resolver
Create src/modules/favoriteAvailability.ts with:
import type { App, FavoriteItem, ResolvedFavoriteItem, StoreFilter } from "@/global/typedefinition";
import { getHybridDefaultOrigin } from "@/global/storeConfig";
const sourceEnabled = (origin: "spark" | "apm", available: { spark: boolean; apm: boolean }, storeFilter: StoreFilter) => {
if (origin === "spark") return available.spark && storeFilter !== "apm";
return available.apm && storeFilter !== "spark";
};
const hasCurrentArch = (app: App, clientArch: string) => {
return !app.arch || app.arch === clientArch || app.filename.includes(`_${clientArch}.`);
};
const choosePreferred = (apps: App[]): App => {
if (apps.length === 1) return apps[0];
const spark = apps.find((app) => app.origin === "spark");
const apm = apps.find((app) => app.origin === "apm");
if (spark && apm) return getHybridDefaultOrigin(spark) === "spark" ? spark : apm;
return apps[0];
};
export const resolveFavoriteItems = (
items: FavoriteItem[],
catalogApps: App[],
installedApps: App[],
available: { spark: boolean; apm: boolean },
storeFilter: StoreFilter,
clientArch = window.apm_store.arch || "amd64",
): ResolvedFavoriteItem[] => {
return items.map((item) => {
const matches = catalogApps.filter((app) => app.pkgname === item.pkgname && app.category === item.category);
if (matches.length === 0) return { item, status: "downlisted", reason: "已下架", selectedApp: null };
const installed = installedApps.find((app) => app.pkgname === item.pkgname && app.category === item.category && app.currentStatus === "installed");
if (installed) return { item, status: "installed", reason: "已安装", selectedApp: installed };
const archMatches = matches.filter((app) => hasCurrentArch(app, clientArch));
if (archMatches.length === 0) return { item, status: "arch-unavailable", reason: "当前架构不可用", selectedApp: null };
const usable = archMatches.filter((app) => sourceEnabled(app.origin, available, storeFilter));
if (usable.length === 0) return { item, status: "platform-unavailable", reason: "当前来源不可用", selectedApp: null };
return { item, status: "installable", reason: "可安装", selectedApp: choosePreferred(usable) };
});
};
- Step 5: Create favorite folder selector
Create src/components/FavoriteFolderSelector.vue with:
<template>
<Transition enter-active-class="duration-200 ease-out" enter-from-class="opacity-0" enter-to-class="opacity-100" leave-active-class="duration-150 ease-in" leave-from-class="opacity-100" leave-to-class="opacity-0">
<div v-if="show" class="fixed inset-0 z-[80] flex items-center justify-center bg-slate-900/60 p-4" @click.self="$emit('close')">
<div class="w-full max-w-md rounded-3xl bg-white p-6 shadow-2xl dark:bg-slate-900">
<h2 class="text-lg font-semibold text-slate-900 dark:text-white">添加到收藏夹</h2>
<p class="mt-1 text-sm text-slate-500">选择收藏夹;没有收藏夹时会使用默认收藏夹。</p>
<div class="mt-4 space-y-2">
<button v-for="folder in folders" :key="folder.id" type="button" class="w-full rounded-2xl border border-slate-200 px-4 py-3 text-left text-sm font-semibold dark:border-slate-700" @click="$emit('select-folder', folder.id)">{{ folder.name }}</button>
<button type="button" class="w-full rounded-2xl border border-brand/30 px-4 py-3 text-left text-sm font-semibold text-brand" @click="$emit('select-folder', 'default')">默认收藏夹</button>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import type { FavoriteFolder } from "@/global/typedefinition";
defineProps<{
show: boolean;
folders: FavoriteFolder[];
}>();
defineEmits<{
close: [];
"select-folder": [folderId: number | "default"];
}>();
</script>
- Step 6: Create favorite folder manager
Create src/components/FavoriteFolderManager.vue with:
<template>
<section class="space-y-4">
<div class="flex flex-wrap items-center gap-2">
<button v-for="folder in folders" :key="folder.id" type="button" class="rounded-2xl border px-4 py-2 text-sm font-semibold" :class="activeFolderId === folder.id ? 'border-brand text-brand' : 'border-slate-200 text-slate-600 dark:border-slate-700 dark:text-slate-300'" @click="$emit('select-folder', folder.id)">
{{ folder.name }} ({{ folder.itemCount }})
</button>
<button type="button" class="rounded-2xl border border-brand/30 px-4 py-2 text-sm font-semibold text-brand" @click="$emit('create-folder')">新建收藏夹</button>
</div>
<div class="flex flex-wrap gap-2">
<button type="button" class="rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white disabled:opacity-40" :disabled="selectedInstallableIds.length === 0" @click="installSelected">加入安装队列</button>
<button type="button" class="rounded-2xl border border-rose-300 px-4 py-2 text-sm font-semibold text-rose-600 disabled:opacity-40" :disabled="selectedIds.length === 0" @click="$emit('remove-selected', selectedIds)">移除选中</button>
<button type="button" class="rounded-2xl border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-600 dark:border-slate-700 dark:text-slate-300" @click="selectInstallable">选择可安装</button>
</div>
<p v-if="loading" class="rounded-2xl border border-dashed border-slate-200 p-6 text-center text-sm text-slate-500">正在加载收藏…</p>
<p v-else-if="error" class="rounded-2xl bg-rose-50 p-4 text-sm text-rose-600">{{ error }}</p>
<p v-else-if="items.length === 0" class="rounded-2xl border border-slate-200 p-6 text-center text-sm text-slate-500">当前收藏夹为空</p>
<div v-else class="space-y-3">
<label v-for="resolved in items" :key="resolved.item.id" class="flex items-center gap-3 rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
<input v-model="selected" type="checkbox" :value="resolved.item.id" :aria-label="`选择 ${resolved.item.name || resolved.item.pkgname}`" />
<img v-if="resolved.item.iconUrl" :src="resolved.item.iconUrl" class="h-10 w-10 rounded-xl" alt="" />
<div class="min-w-0 flex-1">
<p class="font-semibold text-slate-900 dark:text-white">{{ resolved.item.name || resolved.item.pkgname }}</p>
<p class="text-xs text-slate-500">{{ resolved.item.pkgname }} · {{ resolved.item.category }}</p>
</div>
<span class="rounded-full px-3 py-1 text-xs font-semibold" :class="statusClass(resolved.status)">{{ resolved.reason }}</span>
</label>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import type { FavoriteFolder, ResolvedFavoriteItem } from "@/global/typedefinition";
const props = defineProps<{
folders: FavoriteFolder[];
activeFolderId: number | null;
items: ResolvedFavoriteItem[];
loading: boolean;
error: string;
}>();
const emit = defineEmits<{
"select-folder": [folderId: number];
"create-folder": [];
"remove-selected": [itemIds: number[]];
"install-selected": [items: ResolvedFavoriteItem[]];
}>();
const selected = ref<number[]>([]);
const selectedIds = computed(() => selected.value);
const selectedInstallableIds = computed(() => props.items.filter((item) => item.status === "installable" && selected.value.includes(item.item.id)).map((item) => item.item.id));
const selectInstallable = () => {
selected.value = props.items.filter((item) => item.status === "installable").map((item) => item.item.id);
};
const installSelected = () => {
emit("install-selected", props.items.filter((item) => selectedInstallableIds.value.includes(item.item.id)));
};
const statusClass = (status: ResolvedFavoriteItem["status"]) => {
if (status === "installable") return "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300";
if (status === "installed") return "bg-blue-100 text-blue-700 dark:bg-blue-500/15 dark:text-blue-300";
return "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300";
};
watch(() => props.activeFolderId, () => {
selected.value = [];
});
</script>
- Step 7: Wire favorite selector and manager in App.vue
Modify src/App.vue:
- Import components/helpers/API:
import FavoriteFolderSelector from "./components/FavoriteFolderSelector.vue";
import FavoriteFolderManager from "./components/FavoriteFolderManager.vue";
import { addFavoriteItem, bulkDeleteFavoriteItems, createFavoriteFolder, listFavoriteFolders, listFavoriteItems } from "./modules/backendApi";
import { buildFavoriteAppKey } from "./modules/appIdentity";
import { resolveFavoriteItems } from "./modules/favoriteAvailability";
import type { FavoriteFolder, FavoriteItem, ResolvedFavoriteItem } from "./global/typedefinition";
- Add state:
const favoriteFolders = ref<FavoriteFolder[]>([]);
const activeFavoriteFolderId = ref<number | null>(null);
const favoriteItems = ref<FavoriteItem[]>([]);
const showFavoriteSelector = ref(false);
const favoriteTargetApp = ref<App | null>(null);
const favoritesLoading = ref(false);
const favoritesError = ref("");
- Add computed resolver:
const resolvedFavoriteItems = computed<ResolvedFavoriteItem[]>(() =>
resolveFavoriteItems(
favoriteItems.value,
apps.value,
installedApps.value,
availableSources.value,
storeFilter.value,
window.apm_store.arch || "amd64",
),
);
- Add handlers:
const loadFavoriteFolders = async () => {
if (!isLoggedIn.value) return;
favoriteFolders.value = await listFavoriteFolders();
if (!activeFavoriteFolderId.value && favoriteFolders.value.length > 0) activeFavoriteFolderId.value = favoriteFolders.value[0].id;
};
const loadFavoriteItems = async (folderId: number) => {
favoritesLoading.value = true;
favoritesError.value = "";
try {
activeFavoriteFolderId.value = folderId;
favoriteItems.value = await listFavoriteItems(folderId);
} catch (error) {
favoritesError.value = error instanceof Error ? error.message : "读取收藏失败";
} finally {
favoritesLoading.value = false;
}
};
const openFavoriteSelector = async (app: App) => {
if (!requireLogin("收藏应用需要登录星火账号。")) return;
favoriteTargetApp.value = app;
await loadFavoriteFolders();
showFavoriteSelector.value = true;
};
const addCurrentFavoriteToFolder = async (folderId: number | "default") => {
if (!favoriteTargetApp.value) return;
const app = favoriteTargetApp.value;
await addFavoriteItem(folderId, {
appKey: buildFavoriteAppKey(app),
pkgname: app.pkgname,
name: app.name,
category: app.category,
iconUrl: app.icons || "",
});
showFavoriteSelector.value = false;
await loadFavoriteFolders();
};
const createFavoriteFolderFromPrompt = async () => {
const name = window.prompt("收藏夹名称");
if (!name?.trim()) return;
const folder = await createFavoriteFolder(name.trim());
await loadFavoriteFolders();
await loadFavoriteItems(folder.id);
};
const removeSelectedFavorites = async (itemIds: number[]) => {
if (!activeFavoriteFolderId.value || itemIds.length === 0) return;
await bulkDeleteFavoriteItems(activeFavoriteFolderId.value, itemIds);
await loadFavoriteItems(activeFavoriteFolderId.value);
await loadFavoriteFolders();
};
const installResolvedFavorites = async (items: ResolvedFavoriteItem[]) => {
for (const item of items) {
if (item.selectedApp) await onDetailInstall(item.selectedApp);
}
};
const openFavoriteManagement = async () => {
if (!requireLogin("我的收藏需要登录星火账号。")) return;
currentView.value = "favorites";
activeTab.value = "favorites";
await loadFavoriteFolders();
if (activeFavoriteFolderId.value) await loadFavoriteItems(activeFavoriteFolderId.value);
};
- Add
FavoriteFolderSelectornear modals:
<FavoriteFolderSelector
:show="showFavoriteSelector"
:folders="favoriteFolders"
@close="showFavoriteSelector = false"
@select-folder="addCurrentFavoriteToFolder"
/>
- Add favorites content branch:
<FavoriteFolderManager
v-else-if="currentView === 'favorites'"
:folders="favoriteFolders"
:active-folder-id="activeFavoriteFolderId"
:items="resolvedFavoriteItems"
:loading="favoritesLoading"
:error="favoritesError"
@select-folder="loadFavoriteItems"
@create-folder="createFavoriteFolderFromPrompt"
@remove-selected="removeSelectedFavorites"
@install-selected="installResolvedFavorites"
/>
- Step 8: Run favorite tests
Run: npm run test -- src/__tests__/unit/favoriteAvailability.test.ts src/__tests__/unit/FavoriteFolderManager.test.ts
Expected: PASS.
- Step 9: Commit favorites UI and resolver
Run:
git add src/modules/favoriteAvailability.ts src/components/FavoriteFolderSelector.vue src/components/FavoriteFolderManager.vue src/App.vue src/__tests__/unit/favoriteAvailability.test.ts src/__tests__/unit/FavoriteFolderManager.test.ts
git commit -m "feat(favorites): add cloud favorite management"
Expected: commit succeeds.
Task 5: Add Reviews Panel And Downloaded Record Writes
Files:
-
Create:
src/components/ReviewsPanel.vue -
Modify:
src/components/AppDetailPage.vue -
Modify:
src/modules/processInstall.ts -
Modify:
src/App.vue -
Test:
src/__tests__/unit/ReviewsPanel.test.ts -
Test:
src/__tests__/unit/processInstall.test.ts -
Step 1: Write failing ReviewsPanel test
Create src/__tests__/unit/ReviewsPanel.test.ts with:
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();
});
});
- Step 2: Extend processInstall test for queue result
Append to src/__tests__/unit/processInstall.test.ts:
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() });
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");
});
- Step 3: Run review/download tests and verify failure
Run: npm run test -- src/__tests__/unit/ReviewsPanel.test.ts src/__tests__/unit/processInstall.test.ts
Expected: FAIL because ReviewsPanel does not exist and handleInstall returns undefined.
- Step 4: Create ReviewsPanel
Create src/components/ReviewsPanel.vue with:
<template>
<section class="rounded-2xl border border-slate-200/60 bg-slate-50/50 p-5 dark:border-slate-800/60 dark:bg-slate-800/30">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="text-base font-semibold text-slate-900 dark:text-white">评论与评分</h3>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">{{ summary.reviewCount }} 条评论 · 平均 {{ summary.averageRating.toFixed(1) }} 分</p>
</div>
<button type="button" class="text-sm font-semibold text-brand" @click="loadReviews">刷新</button>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<span v-for="tag in tagPills" :key="tag.label" class="rounded-full bg-white px-3 py-1 text-xs text-slate-600 shadow-sm dark:bg-slate-900 dark:text-slate-300">{{ tag.value }}</span>
</div>
<div v-if="error" class="mt-4 rounded-2xl bg-rose-50 px-4 py-3 text-sm text-rose-600 dark:bg-rose-500/10 dark:text-rose-300">{{ error }}</div>
<button v-if="!loggedIn" type="button" class="mt-4 rounded-2xl border border-brand/30 px-4 py-2 text-sm font-semibold text-brand hover:bg-brand/10" @click="$emit('request-login', '评论需要登录星火账号。')">
登录后发表评论
</button>
<form v-else class="mt-4 space-y-3" @submit.prevent="submit">
<label class="block text-sm font-medium text-slate-700 dark:text-slate-200" for="reviewRating">评分</label>
<select id="reviewRating" v-model.number="rating" class="rounded-2xl border border-slate-200 bg-white px-4 py-2 text-sm dark:border-slate-700 dark:bg-slate-900">
<option v-for="star in [5, 4, 3, 2, 1]" :key="star" :value="star">{{ star }} 分</option>
</select>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-200" for="reviewContent">评论</label>
<textarea id="reviewContent" v-model="content" class="min-h-24 w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-900" maxlength="5000"></textarea>
<button type="submit" class="rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white disabled:opacity-50" :disabled="submitting || !content.trim()">{{ submitting ? "提交中" : "提交评论" }}</button>
</form>
<div class="mt-5 space-y-3">
<article v-for="review in reviews" :key="review.id" class="rounded-2xl bg-white p-4 shadow-sm dark:bg-slate-900">
<div class="flex items-center gap-3">
<img v-if="review.userAvatarUrl" :src="review.userAvatarUrl" class="h-8 w-8 rounded-full" alt="" />
<i v-else class="fas fa-user-circle text-2xl text-slate-400"></i>
<div>
<p class="text-sm font-semibold text-slate-900 dark:text-white">{{ review.userDisplayName }}</p>
<p class="text-xs text-slate-500">{{ review.rating }} 分 · {{ review.version }} · {{ review.packageArch }}</p>
</div>
</div>
<p class="mt-3 whitespace-pre-wrap text-sm text-slate-700 dark:text-slate-300">{{ review.content }}</p>
</article>
<p v-if="!loading && reviews.length === 0" class="text-sm text-slate-500">暂无评论</p>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import type { AppReview, RatingSummary, ReviewTags } from "@/global/typedefinition";
import { fetchRatingSummary, fetchReviews, submitReview } from "@/modules/backendApi";
const props = defineProps<{
appKey: string;
tags: ReviewTags;
loggedIn: boolean;
}>();
defineEmits<{
"request-login": [message: string];
}>();
const summary = ref<RatingSummary>({ averageRating: 0, reviewCount: 0, starCounts: {} });
const reviews = ref<AppReview[]>([]);
const loading = ref(false);
const submitting = ref(false);
const error = ref("");
const rating = ref(5);
const content = ref("");
const tagPills = computed(() => [
{ label: "版本", value: props.tags.version },
{ label: "包架构", value: props.tags.packageArch },
{ label: "本机架构", value: props.tags.clientArch },
{ label: "系统", value: props.tags.distro },
{ label: "来源", value: props.tags.origin },
{ label: "分类", value: props.tags.category },
]);
const loadReviews = async () => {
if (!props.appKey) return;
loading.value = true;
error.value = "";
try {
const [nextSummary, nextReviews] = await Promise.all([fetchRatingSummary(props.appKey), fetchReviews(props.appKey)]);
summary.value = nextSummary;
reviews.value = nextReviews;
} catch (err) {
error.value = err instanceof Error ? err.message : "评论加载失败";
} finally {
loading.value = false;
}
};
const submit = async () => {
submitting.value = true;
error.value = "";
try {
await submitReview(props.appKey, { rating: rating.value, content: content.value, tags: props.tags });
content.value = "";
await loadReviews();
} catch (err) {
error.value = err instanceof Error ? err.message : "评论提交失败";
} finally {
submitting.value = false;
}
};
watch(() => props.appKey, loadReviews);
onMounted(loadReviews);
</script>
- Step 5: Mount reviews in detail page
Modify src/components/AppDetailPage.vue:
- Import
ReviewsPanelandReviewTags. - Add below screenshots block:
<ReviewsPanel
v-if="reviewAppKey && reviewTags"
:app-key="reviewAppKey"
:tags="reviewTags"
:logged-in="loggedIn"
@request-login="$emit('request-login', $event)"
/>
- Step 6: Return queued download from processInstall
Modify src/modules/processInstall.ts:
- Change signature:
export const handleInstall = async (appObj?: App): Promise<DownloadItem | null> => {
- Replace early bare
return;statements withreturn null;. - After
window.ipcRenderer.send("queue-install", JSON.stringify(download));, add:
return download;
- Keep statistics POST non-blocking after the return by moving the statistics call before return or by storing the promise before return. The install queue send must remain unchanged.
- Step 7: Record cloud downloaded apps in App.vue
Modify src/App.vue:
- Extend existing imports:
import { buildFavoriteAppKey, buildReviewAppKey, buildReviewTags, getDisplayApp, parsePackageArch } from "./modules/appIdentity";
import { recordDownloadedApp } from "./modules/backendApi";
import type { SystemInfo } from "./global/typedefinition";
- Add system info state:
const systemInfo = ref<SystemInfo>({ distro: "unknown" });
- Add computed review props:
const currentDisplayAppForReview = computed(() => getDisplayApp(currentApp.value));
const currentReviewAppKey = computed(() => {
const app = currentDisplayAppForReview.value;
return app ? buildReviewAppKey(app, window.apm_store.arch || "amd64") : "";
});
const currentReviewTags = computed(() => {
const app = currentDisplayAppForReview.value;
return app ? buildReviewTags(app, { clientArch: window.apm_store.arch || "amd64", distro: systemInfo.value.distro }) : null;
});
- On mount, before data loading completes, fetch system info:
systemInfo.value = await window.ipcRenderer.invoke("get-system-info").catch(() => ({ distro: "unknown" }));
- Replace
onDetailInstallwith:
const onDetailInstall = async (app: 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) {
logger.warn(`记录下载历史失败: ${error}`);
}
};
- Step 8: Add system info IPC
Modify electron/main/index.ts near get-app-version:
const getSystemInfo = (): { distro: string } => {
try {
const raw = fs.readFileSync("/etc/os-release", "utf8");
const values = Object.fromEntries(
raw
.split("\n")
.filter((line) => line.includes("="))
.map((line) => {
const [key, ...rest] = line.split("=");
return [key, rest.join("=").replace(/^"|"$/g, "")];
}),
);
return { distro: values.PRETTY_NAME || values.NAME || values.ID || "unknown" };
} catch {
return { distro: "unknown" };
}
};
ipcMain.handle("get-system-info", (): { distro: string } => getSystemInfo());
- Step 9: Run review/download tests
Run: npm run test -- src/__tests__/unit/ReviewsPanel.test.ts src/__tests__/unit/processInstall.test.ts
Expected: PASS.
- Step 10: Commit reviews and downloaded records
Run:
git add electron/main/index.ts src/components/ReviewsPanel.vue src/components/AppDetailPage.vue src/modules/processInstall.ts src/App.vue src/__tests__/unit/ReviewsPanel.test.ts src/__tests__/unit/processInstall.test.ts
git commit -m "feat(account): record downloads and show reviews"
Expected: commit succeeds.
Task 6: Add User Management, Downloaded History, And Sync Preference
Files:
-
Create:
src/global/accountSyncState.ts -
Create:
src/components/UserManagementView.vue -
Modify:
src/App.vue -
Test:
src/__tests__/unit/UserManagementView.test.ts -
Step 1: Write failing user management test
Create src/__tests__/unit/UserManagementView.test.ts with:
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();
});
});
- Step 2: Run user management test and verify failure
Run: npm run test -- src/__tests__/unit/UserManagementView.test.ts
Expected: FAIL because UserManagementView does not exist.
- Step 3: Add sync preference helper
Create src/global/accountSyncState.ts with:
import { ref, watch } from "vue";
const STORAGE_KEY = "spark-store-installed-sync-enabled";
const readSyncEnabled = (): boolean | null => {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === "true") return true;
if (raw === "false") return false;
return null;
};
export const installedSyncEnabled = ref<boolean | null>(readSyncEnabled());
export const setInstalledSyncEnabled = (enabled: boolean) => {
installedSyncEnabled.value = enabled;
localStorage.setItem(STORAGE_KEY, enabled ? "true" : "false");
};
watch(installedSyncEnabled, (enabled) => {
if (enabled === null) localStorage.removeItem(STORAGE_KEY);
else localStorage.setItem(STORAGE_KEY, enabled ? "true" : "false");
});
- Step 4: Create user management view
Create src/components/UserManagementView.vue with:
<template>
<section class="mx-auto max-w-5xl space-y-6">
<div class="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center">
<img v-if="user.avatarUrl" :src="user.avatarUrl" class="h-20 w-20 rounded-3xl object-cover" alt="" />
<div v-else class="flex h-20 w-20 items-center justify-center rounded-3xl bg-slate-100 dark:bg-slate-800"><i class="fas fa-user text-3xl text-slate-400"></i></div>
<div class="min-w-0 flex-1">
<h2 class="text-2xl font-semibold text-slate-900 dark:text-white">{{ user.displayName || user.username }}</h2>
<p class="mt-1 text-sm text-slate-500">{{ user.username }} · {{ user.forumLevel }}</p>
</div>
<div class="flex flex-wrap gap-2">
<button type="button" class="rounded-2xl border border-slate-200 px-4 py-2 text-sm font-semibold dark:border-slate-700" @click="$emit('open-forum')">论坛首页</button>
<button type="button" class="rounded-2xl border border-slate-200 px-4 py-2 text-sm font-semibold dark:border-slate-700" @click="$emit('edit-profile')">修改论坛资料</button>
</div>
</div>
</div>
<div class="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<label class="flex items-center justify-between gap-4">
<span>
<span class="block text-base font-semibold text-slate-900 dark:text-white">自动同步已安装应用</span>
<span class="block text-sm text-slate-500">启动后仅同步商店识别的非依赖应用。</span>
</span>
<input type="checkbox" :checked="syncEnabled" aria-label="自动同步已安装应用" @change="$emit('toggle-sync', ($event.target as HTMLInputElement).checked)" />
</label>
<button type="button" class="mt-4 rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white" @click="$emit('sync-now')">立即同步</button>
</div>
<div class="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div class="flex items-center justify-between gap-3">
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">下载历史</h3>
<button type="button" class="text-sm font-semibold text-brand" @click="$emit('refresh-downloads')">刷新</button>
</div>
<p v-if="loading" class="mt-4 text-sm text-slate-500">正在加载…</p>
<p v-else-if="error" class="mt-4 text-sm text-rose-600">{{ error }}</p>
<p v-else-if="downloadedApps.length === 0" class="mt-4 text-sm text-slate-500">暂无下载历史</p>
<div v-else class="mt-4 space-y-3">
<div v-for="item in downloadedApps" :key="item.id" class="rounded-2xl border border-slate-200 p-4 dark:border-slate-800">
<p class="font-semibold text-slate-900 dark:text-white">{{ item.name || item.pkgname }}</p>
<p class="mt-1 text-xs text-slate-500">{{ item.pkgname }} · {{ item.selectedOrigin.toUpperCase() }} · {{ item.version }} · {{ item.packageArch }}</p>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import type { DownloadedAppRecord, SparkUser } from "@/global/typedefinition";
defineProps<{
user: SparkUser;
downloadedApps: DownloadedAppRecord[];
syncEnabled: boolean;
loading: boolean;
error: string;
}>();
defineEmits<{
"open-forum": [];
"edit-profile": [];
"toggle-sync": [enabled: boolean];
"sync-now": [];
"refresh-downloads": [];
}>();
</script>
- Step 5: Wire user management in App.vue
Modify src/App.vue:
- Import:
import UserManagementView from "./components/UserManagementView.vue";
import { installedSyncEnabled, setInstalledSyncEnabled } from "./global/accountSyncState";
import { listDownloadedApps } from "./modules/backendApi";
import type { DownloadedAppRecord } from "./global/typedefinition";
- Add state:
const downloadedApps = ref<DownloadedAppRecord[]>([]);
const downloadedLoading = ref(false);
const downloadedError = ref("");
- Add handler:
const loadDownloadedHistory = async () => {
if (!isLoggedIn.value) return;
downloadedLoading.value = true;
downloadedError.value = "";
try {
const list = await listDownloadedApps(1, 50);
downloadedApps.value = list.items;
} catch (error) {
downloadedError.value = error instanceof Error ? error.message : "读取下载历史失败";
} finally {
downloadedLoading.value = false;
}
};
- Change
openUserManagementto:
const openUserManagement = async () => {
if (!requireLogin("用户管理需要登录星火账号。")) return;
currentView.value = "account";
activeTab.value = "account";
await loadDownloadedHistory();
};
- Add content branch before favorites branch:
<UserManagementView
v-else-if="currentView === 'account' && currentUser"
:user="currentUser"
:downloaded-apps="downloadedApps"
:sync-enabled="installedSyncEnabled === true"
:loading="downloadedLoading"
:error="downloadedError"
@open-forum="openExternalUrl(FLARUM_BASE_URL)"
@edit-profile="openExternalUrl(FLARUM_SETTINGS_URL)"
@toggle-sync="setInstalledSyncEnabled"
@refresh-downloads="loadDownloadedHistory"
/>
- Step 6: Run user management tests
Run: npm run test -- src/__tests__/unit/UserManagementView.test.ts
Expected: PASS.
- Step 7: Commit user management
Run:
git add src/global/accountSyncState.ts src/components/UserManagementView.vue src/App.vue src/__tests__/unit/UserManagementView.test.ts
git commit -m "feat(account): add user management view"
Expected: commit succeeds.
Task 7: Add Installed-App Cloud Sync And Restore
Files:
-
Create:
src/modules/appListSync.ts -
Create:
src/components/AppListRestoreModal.vue -
Modify:
src/components/InstalledAppsModal.vue -
Modify:
src/App.vue -
Test:
src/__tests__/unit/appListSync.test.ts -
Test:
src/__tests__/unit/AppListRestoreModal.test.ts -
Test:
src/__tests__/unit/InstalledAppsModal.test.ts -
Step 1: Write failing app-list sync test
Create src/__tests__/unit/appListSync.test.ts with:
import { describe, expect, it } from "vitest";
import { buildSyncItems } from "@/modules/appListSync";
import type { App } from "@/global/typedefinition";
const baseApp: 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: "",
more: "",
tags: "",
img_urls: [],
icons: "https://example.invalid/icon.png",
category: "office",
origin: "spark",
currentStatus: "installed",
};
describe("appListSync", () => {
it("syncs only store-recognized non-dependency apps", () => {
const items = buildSyncItems([
baseApp,
{ ...baseApp, pkgname: "unknown", category: "unknown" },
{ ...baseApp, pkgname: "dep", isDependency: true },
{ ...baseApp, pkgname: "not-installed", currentStatus: "not-installed" },
]);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({ pkgname: "spark-notes", origin: "spark", category: "office" });
});
});
- Step 2: Write failing restore modal test
Create src/__tests__/unit/AppListRestoreModal.test.ts with:
import { fireEvent, render, screen } from "@testing-library/vue";
import { describe, expect, it } from "vitest";
import AppListRestoreModal from "@/components/AppListRestoreModal.vue";
describe("AppListRestoreModal", () => {
it("emits selected installable items", async () => {
const rendered = render(AppListRestoreModal, {
props: {
show: true,
loading: false,
error: "",
items: [{ pkgname: "spark-notes", origin: "spark", category: "office", version: "1.0.0", packageArch: "amd64", appName: "Spark Notes", iconUrl: "" }],
installedKeys: [],
},
});
await fireEvent.click(screen.getByLabelText("选择 Spark Notes"));
await fireEvent.click(screen.getByRole("button", { name: "加入安装队列" }));
expect(rendered.emitted("install-selected")?.[0]?.[0]).toHaveLength(1);
});
});
- Step 3: Run sync/restore tests and verify failure
Run: npm run test -- src/__tests__/unit/appListSync.test.ts src/__tests__/unit/AppListRestoreModal.test.ts
Expected: FAIL because module/component do not exist.
- Step 4: Add sync filtering module
Create src/modules/appListSync.ts with:
import type { App, SyncedAppListItem } from "@/global/typedefinition";
import { parsePackageArch } from "@/modules/appIdentity";
export const buildSyncItems = (apps: App[]): SyncedAppListItem[] => {
return apps
.filter((app) => app.currentStatus === "installed")
.filter((app) => app.category !== "unknown")
.filter((app) => !app.isDependency)
.filter((app) => Boolean(app.pkgname && app.origin))
.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<SyncedAppListItem, "origin" | "pkgname">): string => `${item.origin}:${item.pkgname}`;
- Step 5: Create restore modal
Create src/components/AppListRestoreModal.vue with:
<template>
<Transition enter-active-class="duration-200 ease-out" enter-from-class="opacity-0" enter-to-class="opacity-100" leave-active-class="duration-150 ease-in" leave-from-class="opacity-100" leave-to-class="opacity-0">
<div v-if="show" class="fixed inset-0 z-[80] flex items-center justify-center bg-slate-900/60 p-4" @click.self="$emit('close')">
<div class="flex max-h-[85vh] w-full max-w-3xl flex-col rounded-3xl bg-white p-6 shadow-2xl dark:bg-slate-900">
<div class="flex items-center justify-between gap-3">
<h2 class="text-xl font-semibold text-slate-900 dark:text-white">从账号恢复</h2>
<button type="button" aria-label="关闭" @click="$emit('close')"><i class="fas fa-xmark"></i></button>
</div>
<p v-if="loading" class="mt-6 text-sm text-slate-500">正在读取云端列表…</p>
<p v-else-if="error" class="mt-6 text-sm text-rose-600">{{ error }}</p>
<div v-else class="mt-6 flex-1 space-y-3 overflow-y-auto">
<label v-for="item in items" :key="`${item.origin}:${item.pkgname}`" class="flex items-center gap-3 rounded-2xl border border-slate-200 p-4 dark:border-slate-800">
<input v-model="selected" type="checkbox" :value="`${item.origin}:${item.pkgname}`" :disabled="installedKeys.includes(`${item.origin}:${item.pkgname}`)" :aria-label="`选择 ${item.appName || item.pkgname}`" />
<span class="flex-1 font-semibold text-slate-900 dark:text-white">{{ item.appName || item.pkgname }}</span>
<span class="text-xs text-slate-500">{{ item.origin.toUpperCase() }}</span>
</label>
</div>
<button type="button" class="mt-6 rounded-2xl bg-brand px-4 py-3 text-sm font-semibold text-white disabled:opacity-40" :disabled="selectedItems.length === 0" @click="$emit('install-selected', selectedItems)">加入安装队列</button>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import type { SyncedAppListItem } from "@/global/typedefinition";
const props = defineProps<{
show: boolean;
loading: boolean;
error: string;
items: SyncedAppListItem[];
installedKeys: string[];
}>();
defineEmits<{
close: [];
"install-selected": [items: SyncedAppListItem[]];
}>();
const selected = ref<string[]>([]);
const selectedItems = computed(() => props.items.filter((item) => selected.value.includes(`${item.origin}:${item.pkgname}`)));
watch(() => props.show, () => {
selected.value = [];
});
</script>
- Step 6: Add sync buttons to InstalledAppsModal
Modify src/components/InstalledAppsModal.vue:
- Add props:
loggedIn: boolean;
syncing: boolean;
- Add emits:
(e: "sync-to-account"): void;
(e: "restore-from-account"): void;
(e: "request-login"): void;
- Add buttons before
刷新:
<button type="button" class="inline-flex items-center gap-2 rounded-2xl border border-brand/30 px-4 py-2 text-sm font-semibold text-brand transition hover:bg-brand/10 disabled:opacity-40" :disabled="syncing" @click="loggedIn ? $emit('sync-to-account') : $emit('request-login')">
<i class="fas fa-cloud-upload-alt"></i>
{{ syncing ? "同步中" : "同步到账号" }}
</button>
<button type="button" class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800" @click="loggedIn ? $emit('restore-from-account') : $emit('request-login')">
<i class="fas fa-cloud-download-alt"></i>
从账号恢复
</button>
- Update every
InstalledAppsModal.test.tsrender props to includeloggedIn: falseandsyncing: false.
- Step 7: Wire sync/restore and startup prompt in App.vue
Modify src/App.vue:
- Import:
import AppListRestoreModal from "./components/AppListRestoreModal.vue";
import { buildSyncItems, cloudItemKey } from "./modules/appListSync";
import { fetchSyncedAppList, uploadSyncedAppList } from "./modules/backendApi";
import type { SyncedAppListItem } from "./global/typedefinition";
- Add state:
const syncLoading = ref(false);
const restoreLoading = ref(false);
const restoreError = ref("");
const showRestoreModal = ref(false);
const restoreItems = ref<SyncedAppListItem[]>([]);
- Add computed installed keys:
const installedCloudKeys = computed(() => installedApps.value.map((app) => `${app.origin}:${app.pkgname}`));
- Pass props/events to
InstalledAppsModal:
:logged-in="isLoggedIn"
:syncing="syncLoading"
@sync-to-account="syncInstalledAppsToAccount"
@restore-from-account="openRestoreFromAccount"
@request-login="requireLogin('云端同步需要登录星火账号。')"
- Mount restore modal:
<AppListRestoreModal
:show="showRestoreModal"
:loading="restoreLoading"
:error="restoreError"
:items="restoreItems"
:installed-keys="installedCloudKeys"
@close="showRestoreModal = false"
@install-selected="installCloudItems"
/>
- Add the real sync handlers:
const syncInstalledAppsToAccount = async () => {
if (!requireLogin("云端同步需要登录星火账号。")) return;
syncLoading.value = true;
try {
const items = buildSyncItems(installedApps.value);
await uploadSyncedAppList({ clientArch: window.apm_store.arch || "amd64", distro: systemInfo.value.distro, items });
} finally {
syncLoading.value = false;
}
};
const openRestoreFromAccount = async () => {
if (!requireLogin("从账号恢复应用需要登录星火账号。")) return;
restoreLoading.value = true;
restoreError.value = "";
showRestoreModal.value = true;
try {
const list = await fetchSyncedAppList();
restoreItems.value = list?.items || [];
} catch (error) {
restoreError.value = error instanceof Error ? error.message : "读取云端列表失败";
} finally {
restoreLoading.value = false;
}
};
const installCloudItems = async (items: SyncedAppListItem[]) => {
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) await onDetailInstall(app);
}
};
-
Add
@sync-now="syncInstalledAppsToAccount"to the existingUserManagementViewbranch created in Task 6. -
After catalog load completes in
onMounted, add:
if (isLoggedIn.value && installedSyncEnabled.value === null) {
const enabled = window.confirm("是否启用已安装应用列表自动同步到星火账号?仅同步商店识别的非依赖应用。");
setInstalledSyncEnabled(enabled);
}
if (isLoggedIn.value && installedSyncEnabled.value === true) {
await refreshInstalledApps();
await syncInstalledAppsToAccount();
}
- Step 8: Run sync tests
Run:
npm run test -- src/__tests__/unit/appListSync.test.ts src/__tests__/unit/AppListRestoreModal.test.ts src/__tests__/unit/InstalledAppsModal.test.ts
Expected: PASS.
- Step 9: Commit installed sync and restore
Run:
git add src/modules/appListSync.ts src/components/AppListRestoreModal.vue src/components/InstalledAppsModal.vue src/App.vue src/__tests__/unit/appListSync.test.ts src/__tests__/unit/AppListRestoreModal.test.ts src/__tests__/unit/InstalledAppsModal.test.ts
git commit -m "feat(sync): add installed app cloud sync"
Expected: commit succeeds.
Task 8: Final Client Verification
Files:
-
Verify only.
-
Step 1: Run account-related unit tests
Run:
npm run test -- src/__tests__/unit/accountTypes.test.ts src/__tests__/unit/authState.test.ts src/__tests__/unit/LoginModal.test.ts src/__tests__/unit/AppSidebar.account.test.ts src/__tests__/unit/appIdentity.test.ts src/__tests__/unit/AppDetailPage.test.ts src/__tests__/unit/favoriteAvailability.test.ts src/__tests__/unit/FavoriteFolderManager.test.ts src/__tests__/unit/ReviewsPanel.test.ts src/__tests__/unit/UserManagementView.test.ts src/__tests__/unit/appListSync.test.ts src/__tests__/unit/AppListRestoreModal.test.ts src/__tests__/unit/InstalledAppsModal.test.ts src/__tests__/unit/processInstall.test.ts
Expected: all listed tests PASS.
- Step 2: Run full unit suite
Run: npm run test -- --run
Expected: all unit tests PASS.
- Step 3: Run lint
Run: npm run lint
Expected: exits 0.
- Step 4: Run Vite build
Run: npm run build:vite
Expected: exits 0.
- Step 5: Check worktree
Run: git status --short --branch
Expected: branch shows only intentional commits and no .superpowers/ artifacts.
Self-Review Checklist
Spec coverage:
- Clean client repo/worktree requirement: file structure and final verification assume
/home/spark/Desktop/shenmo-spark-store/spark-store. - Forum login and external registration: Task 2.
- Sidebar account entry, avatar/name, and quick menu: Task 2.
- Anonymous base browsing/search/detail/install/remove/update/installed viewing: Tasks 2, 3, 5, and 7 gate only account-only actions.
- Detail page inside content area with back: Task 3.
- Favorite add, folder selection, default folder: Task 4.
- Favorites as app-level identities: Tasks 3 and 4 use
app:{category}:{pkgname}. - Downlisted/unavailable favorite visibility and bulk remove: Task 4.
- Batch install from favorites using current priority: Task 4 resolver uses
getHybridDefaultOriginand source availability. - Reviews/comments with anonymous prompt: Task 5.
- Downloaded records written only for logged-in installs: Task 5.
- User management profile, forum level, links, downloads, sync preference: Task 6.
- Startup installed sync ask-once and non-dependency store-recognized filtering: Task 7.
- Existing install/update IPC contracts preserved: Tasks 5 and 7 reuse
handleInstalland do not alter queue payload schema.
Placeholder scan:
- No
TBD,TODO,implement later, or placeholder test bodies remain. - Long component tasks include concrete code blocks for the behavior under test; existing surrounding markup can be adjusted during implementation without changing the defined contracts.
Type consistency:
- Backend snake_case fields are mapped to client camelCase only inside
backendApi.ts. - Favorite key is always
app:{category}:{pkgname}. - Review key remains
{origin}:{store_arch}:{category}:{pkgname}. window.apm_store.archis treated as bare architecture such asamd64.- Store filter uses existing
StoreFilter = "spark" | "apm" | "both".