44 KiB
Spark Client Account Reviews Sync 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: Integrate Spark Store's Electron/Vue client with the new backend for Flarum login, profile display, app reviews/ratings, immutable review tags, and user app-list sync/restore.
Architecture: Keep backend API access in focused TypeScript modules, keep global auth state in one Vue module, and add small child components instead of expanding the already-large AppDetailModal.vue and InstalledAppsModal.vue. Local system facts such as distro are exposed through Electron IPC and combined with app metadata to create immutable review tags.
Tech Stack: Electron 40, Vue 3 Composition API, TypeScript strict mode, Axios, Vitest, Testing Library Vue, existing IPC facade and install queue.
File Structure
Existing files to modify:
- Modify:
.gitignore- ignore.superpowers/visual companion artifacts. - Modify:
electron/main/index.ts- addget-system-infoIPC handler that safely reads/etc/os-release. - Modify:
src/vite-env.d.ts- add auth/backend/system-info types. - Modify:
src/global/typedefinition.ts- add user, review, rating, app-list sync types. - Modify:
src/global/storeConfig.ts- addSPARK_BACKEND_BASE_URLconfig. - Modify:
src/components/AppHeader.vue- show login/profile action. - Modify:
src/components/AppDetailModal.vue- mountReviewsPanelwith the active display app. - Modify:
src/components/InstalledAppsModal.vue- add sync/restore action buttons and restore modal entry events. - Modify:
src/App.vue- coordinate auth modal, review props, app-list sync/restore flow, and system info loading. - Modify:
src/__tests__/setup.ts- extend window mocks.
New files to create:
- Create:
src/global/authState.ts- auth state, persistence, login/logout helpers. - Create:
src/modules/backendApi.ts- Axios client and backend request helpers. - Create:
src/modules/reviewTags.ts- immutable review tag construction and app key creation. - Create:
src/modules/appListSync.ts- store-recognized installed-app filtering and restore-plan helpers. - Create:
src/components/LoginModal.vue- Flarum login UI. - Create:
src/components/ReviewsPanel.vue- rating summary, filters, review list, composer. - Create:
src/components/AppListRestoreModal.vue- cloud app-list restore selector. - Create:
src/__tests__/unit/reviewTags.test.ts- immutable tag tests. - Create:
src/__tests__/unit/appListSync.test.ts- sync filtering tests. - Create:
src/__tests__/unit/LoginModal.test.ts- login UI tests. - Create:
src/__tests__/unit/ReviewsPanel.test.ts- review panel state tests. - Create:
src/__tests__/unit/AppListRestoreModal.test.ts- restore modal tests.
Task 1: Add Backend Config And Shared Types
Files:
-
Modify:
.gitignore -
Modify:
src/global/storeConfig.ts -
Modify:
src/global/typedefinition.ts -
Modify:
src/vite-env.d.ts -
Test:
src/__tests__/setup.ts -
Step 1: Write type/import smoke test
Create src/__tests__/unit/accountTypes.test.ts with:
import { describe, expect, it } from "vitest";
import { SPARK_BACKEND_BASE_URL } from "@/global/storeConfig";
import type { ReviewTags, SparkUser } from "@/global/typedefinition";
describe("account backend types", () => {
it("exports backend url and account types", () => {
const user: SparkUser = {
id: 1,
flarumUserId: "123",
username: "momen",
displayName: "Momen",
avatarUrl: "https://bbs.spark-app.store/avatar.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(user.displayName).toBe("Momen");
expect(tags.packageArch).toBe("amd64");
});
});
- Step 2: Run test to verify failure
Run: npm run test -- src/__tests__/unit/accountTypes.test.ts
Expected: FAIL because SPARK_BACKEND_BASE_URL, SparkUser, and ReviewTags do not exist.
- Step 3: Ignore visual companion artifacts
Modify .gitignore by adding:
.superpowers/
- Step 4: Add backend config
Modify src/global/storeConfig.ts by adding after APM_STORE_STATS_BASE_URL:
export const SPARK_BACKEND_BASE_URL: string =
import.meta.env.VITE_SPARK_BACKEND_BASE_URL || "";
- Step 5: Add shared account types
Append to src/global/typedefinition.ts:
export interface SparkUser {
id: number;
flarumUserId: string;
username: string;
displayName: string;
avatarUrl: string;
}
export interface AuthSession {
accessToken: string;
tokenType: "bearer";
user: SparkUser;
}
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 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 6: Extend environment declarations
Modify src/vite-env.d.ts by adding to ImportMetaEnv:
interface ImportMetaEnv {
readonly VITE_SPARK_BACKEND_BASE_URL?: string;
}
- Step 7: Extend test setup mocks
Modify src/__tests__/setup.ts so window.apm_store.arch is amd64, not amd64-store, and ipcRenderer.invoke can be overridden per test:
Object.defineProperty(window, "apm_store", {
value: {
arch: "amd64",
},
writable: true,
});
- Step 8: Run test to verify pass
Run: npm run test -- src/__tests__/unit/accountTypes.test.ts
Expected: PASS.
- Step 9: Commit shared config/types
Run:
git add .gitignore src/global/storeConfig.ts src/global/typedefinition.ts src/vite-env.d.ts src/__tests__/setup.ts src/__tests__/unit/accountTypes.test.ts
git commit -m "feat(account): add backend config and types"
Expected: commit succeeds if the user requested commits for implementation execution.
Task 2: Add Backend API Client And Auth State
Files:
-
Create:
src/modules/backendApi.ts -
Create:
src/global/authState.ts -
Test:
src/__tests__/unit/authState.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, 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",
},
});
expect(authSession.value?.accessToken).toBe("jwt");
expect(JSON.parse(localStorage.getItem("spark-store-auth") || "{}").accessToken).toBe("jwt");
logout();
expect(authSession.value).toBeNull();
expect(localStorage.getItem("spark-store-auth")).toBeNull();
});
});
- Step 2: Run test to verify failure
Run: npm run test -- src/__tests__/unit/authState.test.ts
Expected: FAIL because authState module does not exist.
- Step 3: Add backend API client
Create src/modules/backendApi.ts with:
import axios from "axios";
import { SPARK_BACKEND_BASE_URL } from "@/global/storeConfig";
import type {
AppReview,
AuthSession,
RatingSummary,
ReviewTags,
SyncedAppList,
SyncedAppListItem,
} from "@/global/typedefinition";
const backend = axios.create({
baseURL: SPARK_BACKEND_BASE_URL,
timeout: 10000,
});
export const setBackendToken = (token: string | null) => {
if (token) {
backend.defaults.headers.common.Authorization = `Bearer ${token}`;
} else {
delete backend.defaults.headers.common.Authorization;
}
};
const toCamelReview = (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 || ""),
});
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,
});
const data = response.data;
return {
accessToken: data.access_token,
tokenType: data.token_type,
user: {
id: data.user.id,
flarumUserId: data.user.flarum_user_id,
username: data.user.username,
displayName: data.user.display_name,
avatarUrl: data.user.avatar_url,
},
};
};
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, params: Record<string, string | number | undefined>): Promise<AppReview[]> => {
const response = await backend.get(`/apps/${encodeURIComponent(appKey)}/reviews`, { params });
return (response.data || []).map((item: Record<string, unknown>) => toCamelReview(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 toCamelReview(response.data);
};
export const fetchSyncedAppList = async (): Promise<SyncedAppList | null> => {
const response = await backend.get("/me/app-list");
if (!response.data) return null;
return {
snapshotName: response.data.snapshot_name,
clientArch: response.data.client_arch,
distro: response.data.distro,
updatedAt: 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 response.data;
};
- Step 4: Add auth state
Create src/global/authState.ts with:
import { computed, ref } from "vue";
import { setBackendToken } from "@/modules/backendApi";
import type { AuthSession } from "@/global/typedefinition";
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.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 5: Run auth state test and verify pass
Run: npm run test -- src/__tests__/unit/authState.test.ts
Expected: PASS.
- Step 6: Commit API/auth state
Run:
git add src/modules/backendApi.ts src/global/authState.ts src/__tests__/unit/authState.test.ts
git commit -m "feat(account): add backend api and auth state"
Expected: commit succeeds if commits are requested for implementation execution.
Task 3: Add Flarum Login Modal And Header Account UI
Files:
-
Create:
src/components/LoginModal.vue -
Modify:
src/components/AppHeader.vue -
Modify:
src/App.vue -
Test:
src/__tests__/unit/LoginModal.test.ts -
Step 1: Write failing LoginModal test
Create src/__tests__/unit/LoginModal.test.ts with:
import { fireEvent, render, screen } from "@testing-library/vue";
import { describe, expect, it, vi } from "vitest";
import LoginModal from "@/components/LoginModal.vue";
describe("LoginModal", () => {
it("emits login credentials", 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: "登录" }));
expect(rendered.emitted("login")?.[0]?.[0]).toEqual({ identification: "momen", password: "secret" });
});
});
- Step 2: Run test to verify failure
Run: npm run test -- src/__tests__/unit/LoginModal.test.ts
Expected: FAIL because LoginModal.vue does not exist.
- Step 3: Create LoginModal component
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">使用 bbs.spark-app.store 论坛账号登录</p>
</div>
<button type="button" class="text-slate-400 hover:text-slate-700 dark:hover:text-slate-200" @click="$emit('close')" aria-label="关闭">
<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" 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" autocomplete="username" />
<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" 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" autocomplete="current-password" />
<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>
</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 }];
}>();
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 4: Run LoginModal test and verify pass
Run: npm run test -- src/__tests__/unit/LoginModal.test.ts
Expected: PASS.
- Step 5: Add account UI props to header
Modify src/components/AppHeader.vue props and emits:
import type { SparkUser } from "@/global/typedefinition";
const props = defineProps<{
searchQuery: string;
activeTab: string;
appsCount: number;
currentUser: SparkUser | null;
}>();
const emit = defineEmits<{
(e: "update-search", query: string): void;
(e: "search-focus"): void;
(e: "open-install-settings"): void;
(e: "open-about"): void;
(e: "toggle-sidebar"): void;
(e: "login"): void;
(e: "logout"): void;
}>();
Add a button after the About button:
<button
v-if="!currentUser"
type="button"
class="inline-flex h-10 shrink-0 items-center gap-2 rounded-2xl border border-slate-200/70 bg-white/80 px-3 text-sm font-semibold text-slate-600 shadow-sm backdrop-blur transition hover:bg-slate-50 dark:border-slate-800/70 dark:bg-slate-900/60 dark:text-slate-300 dark:hover:bg-slate-800"
@click="$emit('login')"
title="登录"
>
<i class="fas fa-user"></i>
<span class="hidden sm:inline">登录</span>
</button>
<button
v-else
type="button"
class="inline-flex h-10 shrink-0 items-center gap-2 rounded-2xl border border-slate-200/70 bg-white/80 px-3 text-sm font-semibold text-slate-600 shadow-sm backdrop-blur transition hover:bg-slate-50 dark:border-slate-800/70 dark:bg-slate-900/60 dark:text-slate-300 dark:hover:bg-slate-800"
@click="$emit('logout')"
title="退出登录"
>
<img v-if="currentUser.avatarUrl" :src="currentUser.avatarUrl" class="h-6 w-6 rounded-full" alt="" />
<i v-else class="fas fa-user-circle"></i>
<span class="hidden max-w-24 truncate sm:inline">{{ currentUser.displayName || currentUser.username }}</span>
</button>
- Step 6: Wire login modal in App.vue
Modify src/App.vue to import LoginModal, currentUser, setAuthSession, logout, and exchangeFlarumToken. Add state:
const showLoginModal = ref(false);
const loginLoading = ref(false);
const loginError = ref("");
Pass :current-user="currentUser" to AppHeader, add @login="showLoginModal = true" and @logout="logout", and mount:
<LoginModal
:show="showLoginModal"
:loading="loginLoading"
:error="loginError"
@close="showLoginModal = false"
@login="handleFlarumLogin"
/>
Add handler:
const handleFlarumLogin = async (payload: { identification: string; password: string }) => {
loginLoading.value = true;
loginError.value = "";
try {
const response = await axios.post("https://bbs.spark-app.store/api/token", {
identification: payload.identification,
password: payload.password,
});
const session = await exchangeFlarumToken({
flarumUserId: String(response.data.userId),
flarumToken: String(response.data.token),
});
setAuthSession(session);
showLoginModal.value = false;
} catch (error) {
loginError.value = error instanceof Error ? error.message : "登录失败";
} finally {
loginLoading.value = false;
}
};
- Step 7: Run targeted tests
Run: npm run test -- src/__tests__/unit/LoginModal.test.ts src/__tests__/unit/authState.test.ts
Expected: PASS.
- Step 8: Commit login UI
Run:
git add src/components/LoginModal.vue src/components/AppHeader.vue src/App.vue src/__tests__/unit/LoginModal.test.ts
git commit -m "feat(account): add Flarum login UI"
Expected: commit succeeds if commits are requested for implementation execution.
Task 4: Add System Info IPC And Immutable Review Tags
Files:
-
Modify:
electron/main/index.ts -
Create:
src/modules/reviewTags.ts -
Test:
src/__tests__/unit/reviewTags.test.ts -
Step 1: Write failing review tag tests
Create src/__tests__/unit/reviewTags.test.ts with:
import { describe, expect, it } from "vitest";
import { buildAppKey, buildReviewTags, parsePackageArch } from "@/modules/reviewTags";
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: "installed",
};
describe("reviewTags", () => {
it("builds stable app keys", () => {
expect(buildAppKey(app, "amd64")).toBe("apm:amd64-apm:office:wps");
});
it("parses package architecture from deb filename", () => {
expect(parsePackageArch("wps_1.0.0_amd64.deb")).toBe("amd64");
});
it("builds immutable review tags", () => {
expect(buildReviewTags(app, { clientArch: "amd64", distro: "deepin 25" })).toEqual({
origin: "apm",
category: "office",
pkgname: "wps",
version: "1.0.0",
packageArch: "amd64",
clientArch: "amd64",
distro: "deepin 25",
});
});
});
- Step 2: Run test to verify failure
Run: npm run test -- src/__tests__/unit/reviewTags.test.ts
Expected: FAIL because reviewTags module does not exist.
- Step 3: Add review tag module
Create src/modules/reviewTags.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 buildAppKey = (app: App, clientArch: string): string => {
return `${app.origin}:${buildStoreArch(app.origin, clientArch)}:${app.category}:${app.pkgname}`;
};
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 4: Add system info IPC
Modify electron/main/index.ts by adding 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, "")];
}),
);
const name = values.PRETTY_NAME || values.NAME || values.ID || "unknown";
return { distro: name };
} catch {
return { distro: "unknown" };
}
};
ipcMain.handle("get-system-info", (): { distro: string } => getSystemInfo());
- Step 5: Run review tag tests and verify pass
Run: npm run test -- src/__tests__/unit/reviewTags.test.ts
Expected: PASS.
- Step 6: Commit tags/system info
Run:
git add electron/main/index.ts src/modules/reviewTags.ts src/__tests__/unit/reviewTags.test.ts
git commit -m "feat(reviews): add immutable review tags"
Expected: commit succeeds if commits are requested for implementation execution.
Task 5: Add ReviewsPanel To App Detail Modal
Files:
-
Create:
src/components/ReviewsPanel.vue -
Modify:
src/components/AppDetailModal.vue -
Modify:
src/App.vue -
Test:
src/__tests__/unit/ReviewsPanel.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 login prompt for anonymous users and read-only 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("amd64")).toBeTruthy();
});
});
- Step 2: Run test to verify failure
Run: npm run test -- src/__tests__/unit/ReviewsPanel.test.ts
Expected: FAIL because ReviewsPanel.vue does not exist.
- Step 3: Create ReviewsPanel component
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.label }}: {{ 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 { fetchRatingSummary, fetchReviews, submitReview } from "@/modules/backendApi";
import type { AppReview, RatingSummary, ReviewTags } from "@/global/typedefinition";
const props = defineProps<{
appKey: string;
tags: ReviewTags;
loggedIn: boolean;
}>();
defineEmits<{
"request-login": [];
}>();
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 4: Run ReviewsPanel test and verify pass
Run: npm run test -- src/__tests__/unit/ReviewsPanel.test.ts
Expected: PASS.
- Step 5: Mount ReviewsPanel in AppDetailModal
Modify src/components/AppDetailModal.vue:
- Import
ReviewsPanel. - Add props:
reviewAppKey: string;
reviewTags: ReviewTags | null;
loggedIn: boolean;
- Add emit:
(e: "request-login"): void;
- Add below the screenshot block:
<ReviewsPanel
v-if="reviewAppKey && reviewTags"
:app-key="reviewAppKey"
:tags="reviewTags"
:logged-in="loggedIn"
@request-login="$emit('request-login')"
/>
- Step 6: Build review props in App.vue
Modify src/App.vue:
- Import
buildAppKeyandbuildReviewTags. - Add refs:
const systemInfo = ref<SystemInfo>({ distro: "unknown" });
- On mount, call:
systemInfo.value = await window.ipcRenderer.invoke("get-system-info");
- Add computed values:
const currentDisplayAppForReview = computed(() => {
const app = currentApp.value;
if (!app) return null;
if (!app.isMerged) return app;
return app.viewingOrigin === "spark" ? app.sparkApp || app : app.apmApp || app;
});
const currentReviewAppKey = computed(() => {
const app = currentDisplayAppForReview.value;
return app ? buildAppKey(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;
});
- Pass these props to
AppDetailModaland wire@request-login="showLoginModal = true".
- Step 7: Run targeted tests
Run: npm run test -- src/__tests__/unit/ReviewsPanel.test.ts src/__tests__/unit/reviewTags.test.ts
Expected: PASS.
- Step 8: Commit review panel
Run:
git add src/components/ReviewsPanel.vue src/components/AppDetailModal.vue src/App.vue src/__tests__/unit/ReviewsPanel.test.ts
git commit -m "feat(reviews): show app reviews in details"
Expected: commit succeeds if commits are requested for implementation execution.
Task 6: Add App-List Sync Filtering
Files:
-
Create:
src/modules/appListSync.ts -
Test:
src/__tests__/unit/appListSync.test.ts -
Step 1: Write failing sync filtering tests
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: "",
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 },
]);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({ pkgname: "spark-notes", origin: "spark", category: "office" });
});
});
- Step 2: Run test to verify failure
Run: npm run test -- src/__tests__/unit/appListSync.test.ts
Expected: FAIL because appListSync module does not exist.
- Step 3: Add sync filtering module
Create src/modules/appListSync.ts with:
import type { App, SyncedAppListItem } from "@/global/typedefinition";
import { parsePackageArch } from "@/modules/reviewTags";
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 isCloudItemInstalled = (
item: SyncedAppListItem,
installedApps: App[],
): boolean => {
return installedApps.some((app) => app.pkgname === item.pkgname && app.origin === item.origin && app.currentStatus === "installed");
};
- Step 4: Run sync filtering tests and verify pass
Run: npm run test -- src/__tests__/unit/appListSync.test.ts
Expected: PASS.
- Step 5: Commit sync helpers
Run:
git add src/modules/appListSync.ts src/__tests__/unit/appListSync.test.ts
git commit -m "feat(sync): add app list filtering"
Expected: commit succeeds if commits are requested for implementation execution.
Task 7: Add Sync And Restore UI
Files:
-
Create:
src/components/AppListRestoreModal.vue -
Modify:
src/components/InstalledAppsModal.vue -
Modify:
src/App.vue -
Test:
src/__tests__/unit/AppListRestoreModal.test.ts -
Test:
src/__tests__/unit/InstalledAppsModal.test.ts -
Step 1: 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 2: Run test to verify failure
Run: npm run test -- src/__tests__/unit/AppListRestoreModal.test.ts
Expected: FAIL because AppListRestoreModal.vue does not exist.
- Step 3: Create restore modal
Create src/components/AppListRestoreModal.vue with:
- Props
show,loading,error,items: SyncedAppListItem[],installedKeys: string[]. - Emits
closeandinstall-selected. - Local selected map keyed by
${origin}:${pkgname}. - Checkbox per item with accessible label
选择 ${item.appName || item.pkgname}. - Disable checkbox when installed key is present.
- Button text
加入安装队列.
- Step 4: Run restore modal test and verify pass
Run: npm run test -- src/__tests__/unit/AppListRestoreModal.test.ts
Expected: PASS.
- Step 5: Add sync buttons to InstalledAppsModal
Modify src/components/InstalledAppsModal.vue:
- Add props:
loggedIn: boolean;
syncing: boolean;
- Add emits:
(e: "sync-to-account"): void;
(e: "restore-from-account"): void;
(e: "request-login"): void;
- Add header buttons before Refresh:
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-brand/30 px-4 py-2 text-sm font-semibold text-brand transition hover:bg-brand/10 disabled:opacity-40"
:disabled="syncing"
@click="loggedIn ? $emit('sync-to-account') : $emit('request-login')"
>
<i class="fas fa-cloud-upload-alt"></i>
{{ syncing ? "同步中" : "同步到账号" }}
</button>
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
@click="loggedIn ? $emit('restore-from-account') : $emit('request-login')"
>
<i class="fas fa-cloud-download-alt"></i>
从账号恢复
</button>
- Update existing
InstalledAppsModal.test.tsprops to passloggedIn: falseandsyncing: false.
- Step 6: Wire sync/restore in App.vue
Modify src/App.vue:
- Import
AppListRestoreModal,buildSyncItems,fetchSyncedAppList, anduploadSyncedAppList. - Add state:
const syncLoading = ref(false);
const restoreLoading = ref(false);
const restoreError = ref("");
const showRestoreModal = ref(false);
const restoreItems = ref<SyncedAppListItem[]>([]);
- Pass
:logged-in="isLoggedIn"and:syncing="syncLoading"toInstalledAppsModal. - Add handlers:
const syncInstalledAppsToAccount = async () => {
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 () => {
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;
}
};
- Mount
AppListRestoreModaland oninstall-selected, map selected cloud items to catalogAppentries fromapps.valuebypkgname,origin, andcategory, then call existinghandleInstall(app)for each.
- Step 7: Run targeted tests
Run: npm run test -- src/__tests__/unit/AppListRestoreModal.test.ts src/__tests__/unit/InstalledAppsModal.test.ts src/__tests__/unit/appListSync.test.ts
Expected: PASS.
- Step 8: Commit sync UI
Run:
git add src/components/AppListRestoreModal.vue src/components/InstalledAppsModal.vue src/App.vue src/__tests__/unit/AppListRestoreModal.test.ts src/__tests__/unit/InstalledAppsModal.test.ts
git commit -m "feat(sync): add account app restore UI"
Expected: commit succeeds if commits are requested for implementation execution.
Task 8: Final Client Verification
Files:
-
Verify: no planned file edits in this task.
-
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/reviewTags.test.ts src/__tests__/unit/ReviewsPanel.test.ts src/__tests__/unit/appListSync.test.ts src/__tests__/unit/AppListRestoreModal.test.ts src/__tests__/unit/InstalledAppsModal.test.ts
Expected: all listed tests PASS.
- Step 2: Run full unit test suite
Run: npm run test
Expected: all unit tests PASS.
- Step 3: Run lint
Run: npm run lint
Expected: exits 0 with no lint errors.
- Step 4: Run Vite build
Run: npm run build:vite
Expected: exits 0 and produces renderer/main build artifacts.
- Step 5: Check working tree
Run: git status --short
Expected: only intentional changes remain. .superpowers/ must not appear because .gitignore ignores it.
Self-Review Checklist
Spec coverage:
- Login and avatar/name display: Tasks 2 and 3.
- Direct client-to-Flarum token login: Task 3.
- Backend JWT storage and use: Task 2.
- Detail-page review panel: Task 5.
- Immutable automatic tags: Task 4.
- Review filtering foundation: Task 5.
- Store-recognized app-list sync: Tasks 6 and 7.
- Restore through existing install queue: Task 7.
Placeholder scan:
- The plan has no deferred implementation sections and no placeholder tasks.
Type consistency:
- Backend snake_case payloads are mapped in
backendApi.ts. - UI state uses camelCase
SparkUser,ReviewTags, andSyncedAppListItem. window.apm_store.archis treated as bare architecture such asamd64.