Files
spark-store/docs/superpowers/plans/2026-05-18-spark-client-account-collections.md

108 KiB
Raw Permalink Blame History

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 and window.ipcRenderer.invoke usage remains unchanged.
  • src/__tests__/setup.ts - keep window.apm_store.arch as bare architecture amd64 and 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 replacing AppDetailModal.
  • 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:

  1. Import ref, AccountQuickMenu, and SparkUser:
import { computed, ref } from "vue";
import AccountQuickMenu from "./AccountQuickMenu.vue";
import type { SidebarEntry, SparkUser } from "../global/typedefinition";
  1. Add prop:
  currentUser: SparkUser | null;
  1. 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;
  1. Add state and handler:
const showAccountMenu = ref(false);

const handleAccountClick = () => {
  if (!props.currentUser) {
    emit("request-login");
    return;
  }
  showAccountMenu.value = !showAccountMenu.value;
};
  1. 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:

  1. 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";
  1. Add state:
const showLoginModal = ref(false);
const loginLoading = ref(false);
const loginError = ref("");
const showLoginPrompt = ref(false);
const loginPromptMessage = ref("该功能需要登录星火账号后使用。");
  1. 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"
  1. 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)"
/>
  1. 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:

  1. Import AppDetailPage instead of AppDetailModal.
  2. Replace const showModal = ref(false); with:
const currentView = ref<"home" | "list" | "detail" | "account" | "favorites">("home");
const detailPreviousView = ref<"home" | "list">("home");
  1. Replace the content template branch at src/App.vue:57-77 with:
<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>
  1. In selectTab, after setting activeTab, add:
currentView.value = tab === "home" ? "home" : "list";
  1. In openDetail, replace showModal.value = true; with:
detailPreviousView.value = activeTab.value === "home" ? "home" : "list";
currentView.value = "detail";
  1. Replace closeDetail with:
const closeDetail = () => {
  currentView.value = detailPreviousView.value;
  currentApp.value = null;
};
  1. Replace if (showModal.value && currentApp.value) checks with if (currentView.value === "detail" && currentApp.value).
  2. 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:

  1. 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";
  1. 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("");
  1. Add computed resolver:
const resolvedFavoriteItems = computed<ResolvedFavoriteItem[]>(() =>
  resolveFavoriteItems(
    favoriteItems.value,
    apps.value,
    installedApps.value,
    availableSources.value,
    storeFilter.value,
    window.apm_store.arch || "amd64",
  ),
);
  1. 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);
};
  1. Add FavoriteFolderSelector near modals:
<FavoriteFolderSelector
  :show="showFavoriteSelector"
  :folders="favoriteFolders"
  @close="showFavoriteSelector = false"
  @select-folder="addCurrentFavoriteToFolder"
/>
  1. 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:

  1. Import ReviewsPanel and ReviewTags.
  2. 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:

  1. Change signature:
export const handleInstall = async (appObj?: App): Promise<DownloadItem | null> => {
  1. Replace early bare return; statements with return null;.
  2. After window.ipcRenderer.send("queue-install", JSON.stringify(download));, add:
  return download;
  1. 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:

  1. Extend existing imports:
import { buildFavoriteAppKey, buildReviewAppKey, buildReviewTags, getDisplayApp, parsePackageArch } from "./modules/appIdentity";
import { recordDownloadedApp } from "./modules/backendApi";
import type { SystemInfo } from "./global/typedefinition";
  1. Add system info state:
const systemInfo = ref<SystemInfo>({ distro: "unknown" });
  1. 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;
});
  1. On mount, before data loading completes, fetch system info:
systemInfo.value = await window.ipcRenderer.invoke("get-system-info").catch(() => ({ distro: "unknown" }));
  1. Replace onDetailInstall with:
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:

  1. Import:
import UserManagementView from "./components/UserManagementView.vue";
import { installedSyncEnabled, setInstalledSyncEnabled } from "./global/accountSyncState";
import { listDownloadedApps } from "./modules/backendApi";
import type { DownloadedAppRecord } from "./global/typedefinition";
  1. Add state:
const downloadedApps = ref<DownloadedAppRecord[]>([]);
const downloadedLoading = ref(false);
const downloadedError = ref("");
  1. 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;
  }
};
  1. Change openUserManagement to:
const openUserManagement = async () => {
  if (!requireLogin("用户管理需要登录星火账号。")) return;
  currentView.value = "account";
  activeTab.value = "account";
  await loadDownloadedHistory();
};
  1. 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:

  1. Add props:
  loggedIn: boolean;
  syncing: boolean;
  1. Add emits:
  (e: "sync-to-account"): void;
  (e: "restore-from-account"): void;
  (e: "request-login"): void;
  1. 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>
  1. Update every InstalledAppsModal.test.ts render props to include loggedIn: false and syncing: false.
  • Step 7: Wire sync/restore and startup prompt in App.vue

Modify src/App.vue:

  1. 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";
  1. Add state:
const syncLoading = ref(false);
const restoreLoading = ref(false);
const restoreError = ref("");
const showRestoreModal = ref(false);
const restoreItems = ref<SyncedAppListItem[]>([]);
  1. Add computed installed keys:
const installedCloudKeys = computed(() => installedApps.value.map((app) => `${app.origin}:${app.pkgname}`));
  1. Pass props/events to InstalledAppsModal:
:logged-in="isLoggedIn"
:syncing="syncLoading"
@sync-to-account="syncInstalledAppsToAccount"
@restore-from-account="openRestoreFromAccount"
@request-login="requireLogin('云端同步需要登录星火账号。')"
  1. Mount restore modal:
<AppListRestoreModal
  :show="showRestoreModal"
  :loading="restoreLoading"
  :error="restoreError"
  :items="restoreItems"
  :installed-keys="installedCloudKeys"
  @close="showRestoreModal = false"
  @install-selected="installCloudItems"
/>
  1. 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);
  }
};
  1. Add @sync-now="syncInstalledAppsToAccount" to the existing UserManagementView branch created in Task 6.

  2. 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 getHybridDefaultOrigin and 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 handleInstall and 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.arch is treated as bare architecture such as amd64.
  • Store filter uses existing StoreFilter = "spark" | "apm" | "both".