mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-06-22 14:13:49 +08:00
merge: account collections implementation
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
VITE_APM_STORE_LOCAL_MODE=true
|
VITE_APM_STORE_LOCAL_MODE=true
|
||||||
VITE_APM_STORE_BASE_URL=/local_amd64-store
|
VITE_APM_STORE_BASE_URL=/local_amd64-store
|
||||||
VITE_APM_STORE_STATS_BASE_URL=/local_stats
|
VITE_APM_STORE_STATS_BASE_URL=/local_stats
|
||||||
|
VITE_SPARK_BACKEND_BASE_URL=http://127.0.0.1:8000
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ dist-electron
|
|||||||
release
|
release
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# Local secrets and databases
|
||||||
|
.env
|
||||||
|
.env.*.local
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
|
||||||
# Test coverage
|
# Test coverage
|
||||||
coverage
|
coverage
|
||||||
.nyc_output
|
.nyc_output
|
||||||
|
|||||||
@@ -40,6 +40,23 @@ function getAppVersion(): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSystemInfo(): { distro: string } {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync("/etc/os-release", "utf8");
|
||||||
|
const fields = Object.fromEntries(
|
||||||
|
raw
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.match(/^([A-Z_]+)=(.*)$/))
|
||||||
|
.filter((match): match is RegExpMatchArray => match !== null)
|
||||||
|
.map((match) => [match[1], match[2].replace(/^"|"$/g, "")]),
|
||||||
|
);
|
||||||
|
const distro = fields.PRETTY_NAME || fields.NAME || "unknown";
|
||||||
|
return { distro };
|
||||||
|
} catch {
|
||||||
|
return { distro: "unknown" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 处理 --version 参数(在单实例检查之前)
|
// 处理 --version 参数(在单实例检查之前)
|
||||||
if (process.argv.includes("--version") || process.argv.includes("-v")) {
|
if (process.argv.includes("--version") || process.argv.includes("-v")) {
|
||||||
console.log(getAppVersion());
|
console.log(getAppVersion());
|
||||||
@@ -55,6 +72,7 @@ import "./backend/install-manager.js";
|
|||||||
import "./handle-url-scheme.js";
|
import "./handle-url-scheme.js";
|
||||||
|
|
||||||
const logger = pino({ name: "index.ts" });
|
const logger = pino({ name: "index.ts" });
|
||||||
|
const FLARUM_TOKEN_URL = "https://bbs.spark-app.store/api/token";
|
||||||
|
|
||||||
// The built directory structure
|
// The built directory structure
|
||||||
//
|
//
|
||||||
@@ -117,6 +135,76 @@ ipcMain.handle("get-store-filter", (): "spark" | "apm" | "both" =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
ipcMain.handle("get-app-version", (): string => getAppVersion());
|
ipcMain.handle("get-app-version", (): string => getAppVersion());
|
||||||
|
ipcMain.handle("get-system-info", (): { distro: string } => getSystemInfo());
|
||||||
|
|
||||||
|
ipcMain.handle("request-flarum-token", async (_event, payload: unknown) => {
|
||||||
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||||
|
throw new Error("登录信息格式不正确,请重新输入。");
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = payload as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
typeof credentials.identification !== "string" ||
|
||||||
|
typeof credentials.password !== "string"
|
||||||
|
) {
|
||||||
|
throw new Error("登录信息格式不正确,请重新输入。");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ endpoint: FLARUM_TOKEN_URL }, "Requesting Flarum login token");
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(FLARUM_TOKEN_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
"User-Agent": getUserAgent(),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
identification: credentials.identification,
|
||||||
|
password: credentials.password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
{ err, endpoint: FLARUM_TOKEN_URL },
|
||||||
|
"Flarum token request failed before response",
|
||||||
|
);
|
||||||
|
throw new Error("无法连接星火论坛,请检查网络后重试。");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.warn(
|
||||||
|
{ endpoint: FLARUM_TOKEN_URL, status: response.status },
|
||||||
|
"Flarum rejected login token request",
|
||||||
|
);
|
||||||
|
throw new Error("论坛登录失败,请检查账号和密码。");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as Record<string, unknown>;
|
||||||
|
const userId = data.userId ?? data.user_id;
|
||||||
|
if (
|
||||||
|
typeof data.token !== "string" ||
|
||||||
|
userId === undefined ||
|
||||||
|
userId === null
|
||||||
|
) {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
endpoint: FLARUM_TOKEN_URL,
|
||||||
|
hasToken: typeof data.token === "string" && data.token.length > 0,
|
||||||
|
hasUserId: userId !== undefined && userId !== null,
|
||||||
|
},
|
||||||
|
"Flarum token response missing required fields",
|
||||||
|
);
|
||||||
|
throw new Error("论坛登录响应异常,请稍后重试。");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: data.token,
|
||||||
|
userId: String(userId),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const getMainWindowCloseGuardState = (): MainWindowCloseGuardState => ({
|
const getMainWindowCloseGuardState = (): MainWindowCloseGuardState => ({
|
||||||
installTaskCount: tasks.size,
|
installTaskCount: tasks.size,
|
||||||
|
|||||||
+875
-31
File diff suppressed because it is too large
Load Diff
@@ -20,11 +20,13 @@ Object.defineProperty(window, "ipcRenderer", {
|
|||||||
invoke: vi.fn(),
|
invoke: vi.fn(),
|
||||||
removeListener: vi.fn(),
|
removeListener: vi.fn(),
|
||||||
},
|
},
|
||||||
|
writable: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock window.apm_store
|
// Mock window.apm_store
|
||||||
Object.defineProperty(window, "apm_store", {
|
Object.defineProperty(window, "apm_store", {
|
||||||
value: {
|
value: {
|
||||||
arch: "amd64-store",
|
arch: "amd64",
|
||||||
},
|
},
|
||||||
|
writable: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,773 @@
|
|||||||
|
import {
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
within,
|
||||||
|
} from "@testing-library/vue";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import App from "@/App.vue";
|
||||||
|
import {
|
||||||
|
fetchSyncedAppList,
|
||||||
|
listDownloadedApps,
|
||||||
|
listFavoriteFolders,
|
||||||
|
uploadSyncedAppList,
|
||||||
|
} from "@/modules/backendApi";
|
||||||
|
import { setAuthSession } from "@/global/authState";
|
||||||
|
import type {
|
||||||
|
DownloadedAppList,
|
||||||
|
FavoriteFolder,
|
||||||
|
FavoriteItem,
|
||||||
|
SyncedAppList,
|
||||||
|
} from "@/global/typedefinition";
|
||||||
|
|
||||||
|
const invoke = vi.fn();
|
||||||
|
|
||||||
|
const favoriteFolders: FavoriteFolder[] = [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
name: "默认收藏夹",
|
||||||
|
itemCount: 1,
|
||||||
|
createdAt: "2026-05-18T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-18T00:00:00Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const favoriteItems: FavoriteItem[] = [
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
appKey: "app:office:wps",
|
||||||
|
pkgname: "wps",
|
||||||
|
name: "WPS",
|
||||||
|
category: "office",
|
||||||
|
iconUrl: "",
|
||||||
|
createdAt: "2026-05-18T00:00:00Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const createDeferred = <T>() => {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((promiseResolve) => {
|
||||||
|
resolve = promiseResolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { promise, resolve };
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadedList = (
|
||||||
|
items: DownloadedAppList["items"],
|
||||||
|
): DownloadedAppList => ({
|
||||||
|
items,
|
||||||
|
total: items.length,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncedList = (items: SyncedAppList["items"]): SyncedAppList => ({
|
||||||
|
snapshotName: "默认列表",
|
||||||
|
clientArch: "amd64",
|
||||||
|
distro: "deepin 25",
|
||||||
|
updatedAt: "2026-05-18T00:00:00Z",
|
||||||
|
items,
|
||||||
|
});
|
||||||
|
|
||||||
|
const setSecondUserSession = () => {
|
||||||
|
setAuthSession({
|
||||||
|
accessToken: "backend-token-b",
|
||||||
|
tokenType: "bearer",
|
||||||
|
user: {
|
||||||
|
id: 2,
|
||||||
|
flarumUserId: "84",
|
||||||
|
username: "second",
|
||||||
|
displayName: "Second User",
|
||||||
|
avatarUrl: "https://bbs.spark-app.store/avatar-b.png",
|
||||||
|
forumLevel: "用户",
|
||||||
|
forumGroups: ["用户"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("axios", () => {
|
||||||
|
const get = vi.fn(async (url: string) => {
|
||||||
|
if (url.includes("categories.json")) {
|
||||||
|
return { data: { office: { zh: "办公" } } };
|
||||||
|
}
|
||||||
|
if (url.includes("/office/applist.json")) {
|
||||||
|
return {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
Name: "WPS",
|
||||||
|
Pkgname: "wps",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Filename: "wps_1.0.0_amd64.deb",
|
||||||
|
Torrent_address: "",
|
||||||
|
Author: "",
|
||||||
|
Contributor: "",
|
||||||
|
Website: "",
|
||||||
|
Update: "",
|
||||||
|
Size: "",
|
||||||
|
More: "",
|
||||||
|
Tags: "",
|
||||||
|
img_urls: [],
|
||||||
|
icons: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { data: [] };
|
||||||
|
});
|
||||||
|
const post = vi.fn(async () => ({ data: { ok: true } }));
|
||||||
|
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
create: () => ({ get, post }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/modules/updateCenter", () => ({
|
||||||
|
createUpdateCenterStore: () => ({
|
||||||
|
isOpen: { value: false },
|
||||||
|
showCloseConfirm: { value: false },
|
||||||
|
showMigrationConfirm: { value: false },
|
||||||
|
searchQuery: { value: "" },
|
||||||
|
selectedTaskKeys: { value: new Set<string>() },
|
||||||
|
snapshot: {
|
||||||
|
value: { items: [], tasks: [], warnings: [], hasRunningTasks: false },
|
||||||
|
},
|
||||||
|
filteredItems: { value: [] },
|
||||||
|
allSelected: { value: false },
|
||||||
|
someSelected: { value: false },
|
||||||
|
bind: vi.fn(),
|
||||||
|
unbind: vi.fn(),
|
||||||
|
open: vi.fn(),
|
||||||
|
refresh: vi.fn(),
|
||||||
|
ignoreItem: vi.fn(),
|
||||||
|
unignoreItem: vi.fn(),
|
||||||
|
toggleSelection: vi.fn(),
|
||||||
|
toggleSelectAll: vi.fn(),
|
||||||
|
getSelectedItems: vi.fn(() => []),
|
||||||
|
closeNow: vi.fn(),
|
||||||
|
startSelected: vi.fn(),
|
||||||
|
requestClose: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/modules/backendApi", () => ({
|
||||||
|
addFavoriteItem: vi.fn(),
|
||||||
|
bulkDeleteFavoriteItems: vi.fn(),
|
||||||
|
createFavoriteFolder: vi.fn(),
|
||||||
|
exchangeFlarumToken: vi.fn(),
|
||||||
|
fetchSyncedAppList: vi.fn(async () => null),
|
||||||
|
listDownloadedApps: vi.fn(async () => downloadedList([])),
|
||||||
|
listFavoriteFolders: vi.fn(async () => favoriteFolders),
|
||||||
|
listFavoriteItems: vi.fn(async () => favoriteItems),
|
||||||
|
uploadSyncedAppList: vi.fn(),
|
||||||
|
setBackendToken: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("App account placeholders", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
invoke.mockReset();
|
||||||
|
invoke.mockImplementation(async (channel: string) => {
|
||||||
|
if (channel === "get-store-filter") return "both";
|
||||||
|
if (channel === "check-spark-available") return true;
|
||||||
|
if (channel === "check-apm-available") return true;
|
||||||
|
if (channel === "get-app-version") return "5.0.0";
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.assign(window.ipcRenderer, {
|
||||||
|
invoke,
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
send: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
window.apm_store.arch = "amd64";
|
||||||
|
localStorage.clear();
|
||||||
|
setAuthSession({
|
||||||
|
accessToken: "backend-token",
|
||||||
|
tokenType: "bearer",
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
flarumUserId: "42",
|
||||||
|
username: "momen",
|
||||||
|
displayName: "Momen",
|
||||||
|
avatarUrl: "https://bbs.spark-app.store/avatar.png",
|
||||||
|
forumLevel: "管理员",
|
||||||
|
forumGroups: ["管理员"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
"matchMedia",
|
||||||
|
vi.fn(() => ({
|
||||||
|
matches: false,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("scrollTo", vi.fn());
|
||||||
|
class MockIntersectionObserver {
|
||||||
|
observe = vi.fn();
|
||||||
|
disconnect = vi.fn();
|
||||||
|
unobserve = vi.fn();
|
||||||
|
}
|
||||||
|
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the user management placeholder from the logged-in quick menu", async () => {
|
||||||
|
render(App);
|
||||||
|
|
||||||
|
await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
|
||||||
|
await fireEvent.click(screen.getByText("用户管理"));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("heading", { name: "用户管理" }),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(screen.queryByText("请登录后查看和管理账号信息。")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the favorites placeholder from the logged-in quick menu", async () => {
|
||||||
|
render(App);
|
||||||
|
|
||||||
|
await fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: /^Momen$/ }),
|
||||||
|
);
|
||||||
|
await fireEvent.click(screen.getByText("我的收藏"));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("heading", { name: "我的收藏" }),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(screen.queryByText("请登录后查看我的收藏。")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes installed apps before resolving favorite management state", async () => {
|
||||||
|
invoke.mockImplementation(async (channel: string) => {
|
||||||
|
if (channel === "get-store-filter") return "both";
|
||||||
|
if (channel === "check-spark-available") return true;
|
||||||
|
if (channel === "check-apm-available") return true;
|
||||||
|
if (channel === "get-app-version") return "5.0.0";
|
||||||
|
if (channel === "list-installed") {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
pkgname: "wps",
|
||||||
|
name: "WPS",
|
||||||
|
version: "1.0.0",
|
||||||
|
arch: "amd64",
|
||||||
|
flags: "installed",
|
||||||
|
origin: "apm",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
render(App);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("2")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
|
||||||
|
await fireEvent.click(screen.getByText("我的收藏"));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("heading", { name: "我的收藏" }),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(await screen.findByText("已安装")).toBeTruthy();
|
||||||
|
expect(invoke).toHaveBeenCalledWith("list-installed", {
|
||||||
|
origin: "apm",
|
||||||
|
pkgnameList: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes Spark installed state for favorites in both mode", async () => {
|
||||||
|
invoke.mockImplementation(async (channel: string, payload?: unknown) => {
|
||||||
|
if (channel === "get-store-filter") return "both";
|
||||||
|
if (channel === "check-spark-available") return true;
|
||||||
|
if (channel === "check-apm-available") return true;
|
||||||
|
if (channel === "get-app-version") return "5.0.0";
|
||||||
|
if (channel === "list-installed") {
|
||||||
|
const request = payload as { origin?: string };
|
||||||
|
if (request.origin === "spark") {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
pkgname: "wps",
|
||||||
|
name: "WPS",
|
||||||
|
version: "1.0.0",
|
||||||
|
arch: "amd64",
|
||||||
|
flags: "installed",
|
||||||
|
origin: "spark",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { success: true, apps: [] };
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
render(App);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("2")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
|
||||||
|
await fireEvent.click(screen.getByText("我的收藏"));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("heading", { name: "我的收藏" }),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(await screen.findByText("已安装")).toBeTruthy();
|
||||||
|
expect(invoke).toHaveBeenCalledWith("list-installed", {
|
||||||
|
origin: "spark",
|
||||||
|
pkgnameList: ["wps"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears favorite data and leaves protected favorites view after logout", async () => {
|
||||||
|
render(App);
|
||||||
|
|
||||||
|
await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
|
||||||
|
await fireEvent.click(screen.getByText("我的收藏"));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("heading", { name: "我的收藏" }),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(await screen.findByText("默认收藏夹 (1)")).toBeTruthy();
|
||||||
|
expect(await screen.findByText("wps · office")).toBeTruthy();
|
||||||
|
|
||||||
|
await fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: /^Momen$/ }),
|
||||||
|
);
|
||||||
|
if (!screen.queryByText("退出登录")) {
|
||||||
|
await fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: /^Momen$/ }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await fireEvent.click(screen.getByText("退出登录"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("button", { name: "登录 / 注册" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
expect(screen.queryByText("默认收藏夹 (1)")).toBeNull();
|
||||||
|
expect(screen.queryByText("wps · office")).toBeNull();
|
||||||
|
expect(screen.queryByRole("heading", { name: "我的收藏" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reopen the favorite selector when folder loading resolves after logout", async () => {
|
||||||
|
const slowFolders = createDeferred<FavoriteFolder[]>();
|
||||||
|
vi.mocked(listFavoriteFolders).mockReturnValueOnce(slowFolders.promise);
|
||||||
|
render(App);
|
||||||
|
|
||||||
|
await fireEvent.click(await screen.findByText("全部应用"));
|
||||||
|
await fireEvent.click(await screen.findByText("wps · 1.0.0"));
|
||||||
|
expect(await screen.findByRole("heading", { name: "WPS" })).toBeTruthy();
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "收藏" }));
|
||||||
|
|
||||||
|
await fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: /^Momen$/ }),
|
||||||
|
);
|
||||||
|
if (!screen.queryByText("退出登录")) {
|
||||||
|
await fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: /^Momen$/ }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await fireEvent.click(screen.getByText("退出登录"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("button", { name: "登录 / 注册" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
slowFolders.resolve([
|
||||||
|
{
|
||||||
|
id: 42,
|
||||||
|
name: "旧账号收藏夹",
|
||||||
|
itemCount: 1,
|
||||||
|
createdAt: "2026-05-18T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-18T00:00:00Z",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await slowFolders.promise;
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(screen.queryByText("旧账号收藏夹 (1)")).toBeNull();
|
||||||
|
expect(screen.queryByText("wps · office")).toBeNull();
|
||||||
|
expect(screen.queryByText("旧账号收藏夹")).toBeNull();
|
||||||
|
expect(screen.queryByRole("dialog", { name: "选择收藏夹" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores downloaded history that resolves after switching users", async () => {
|
||||||
|
const firstHistory = createDeferred<DownloadedAppList>();
|
||||||
|
const secondHistory = createDeferred<DownloadedAppList>();
|
||||||
|
vi.mocked(listDownloadedApps)
|
||||||
|
.mockReturnValueOnce(firstHistory.promise)
|
||||||
|
.mockReturnValueOnce(secondHistory.promise);
|
||||||
|
render(App);
|
||||||
|
|
||||||
|
await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
|
||||||
|
await fireEvent.click(screen.getByText("用户管理"));
|
||||||
|
expect(await screen.findByText("正在加载下载历史...")).toBeTruthy();
|
||||||
|
|
||||||
|
await fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: /^Momen$/ }),
|
||||||
|
);
|
||||||
|
if (!screen.queryByText("退出登录")) {
|
||||||
|
await fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: /^Momen$/ }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await fireEvent.click(screen.getByText("退出登录"));
|
||||||
|
setSecondUserSession();
|
||||||
|
|
||||||
|
const secondUserButton = await screen.findByRole("button", {
|
||||||
|
name: /^Second User$/,
|
||||||
|
});
|
||||||
|
if (!screen.queryByText("用户管理")) {
|
||||||
|
await fireEvent.click(secondUserButton);
|
||||||
|
}
|
||||||
|
await fireEvent.click(await screen.findByText("用户管理"));
|
||||||
|
secondHistory.resolve(downloadedList([]));
|
||||||
|
expect(await screen.findByText("暂无下载记录。")).toBeTruthy();
|
||||||
|
|
||||||
|
firstHistory.resolve(
|
||||||
|
downloadedList([
|
||||||
|
{
|
||||||
|
id: 77,
|
||||||
|
appKey: "app:office:old-account-app",
|
||||||
|
pkgname: "old-account-app",
|
||||||
|
name: "旧账号应用",
|
||||||
|
category: "office",
|
||||||
|
selectedOrigin: "apm",
|
||||||
|
version: "1.0.0",
|
||||||
|
packageArch: "amd64",
|
||||||
|
downloadedAt: "2026-05-18T00:00:00Z",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
await firstHistory.promise;
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(screen.queryByText("旧账号应用")).toBeNull();
|
||||||
|
expect(screen.getByText("暂无下载记录。")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores older downloaded history refreshes for the same user", async () => {
|
||||||
|
const firstHistory = createDeferred<DownloadedAppList>();
|
||||||
|
const secondHistory = createDeferred<DownloadedAppList>();
|
||||||
|
vi.mocked(listDownloadedApps)
|
||||||
|
.mockReturnValueOnce(firstHistory.promise)
|
||||||
|
.mockReturnValueOnce(secondHistory.promise);
|
||||||
|
render(App);
|
||||||
|
|
||||||
|
await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
|
||||||
|
await fireEvent.click(screen.getByText("用户管理"));
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "刷新" }));
|
||||||
|
|
||||||
|
secondHistory.resolve(
|
||||||
|
downloadedList([
|
||||||
|
{
|
||||||
|
id: 88,
|
||||||
|
appKey: "app:office:new-app",
|
||||||
|
pkgname: "new-app",
|
||||||
|
name: "新下载应用",
|
||||||
|
category: "office",
|
||||||
|
selectedOrigin: "apm",
|
||||||
|
version: "2.0.0",
|
||||||
|
packageArch: "amd64",
|
||||||
|
downloadedAt: "2026-05-18T00:00:00Z",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(await screen.findByText("新下载应用")).toBeTruthy();
|
||||||
|
|
||||||
|
firstHistory.resolve(
|
||||||
|
downloadedList([
|
||||||
|
{
|
||||||
|
id: 77,
|
||||||
|
appKey: "app:office:old-app",
|
||||||
|
pkgname: "old-app",
|
||||||
|
name: "旧下载应用",
|
||||||
|
category: "office",
|
||||||
|
selectedOrigin: "apm",
|
||||||
|
version: "1.0.0",
|
||||||
|
packageArch: "amd64",
|
||||||
|
downloadedAt: "2026-05-18T00:00:00Z",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
await firstHistory.promise;
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(screen.queryByText("旧下载应用")).toBeNull();
|
||||||
|
expect(screen.getByText("新下载应用")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores older favorite folder refreshes for the same user", async () => {
|
||||||
|
const firstFolders = createDeferred<FavoriteFolder[]>();
|
||||||
|
const secondFolders = createDeferred<FavoriteFolder[]>();
|
||||||
|
vi.mocked(listFavoriteFolders)
|
||||||
|
.mockReturnValueOnce(firstFolders.promise)
|
||||||
|
.mockReturnValueOnce(secondFolders.promise);
|
||||||
|
render(App);
|
||||||
|
|
||||||
|
await fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: /^Momen$/ }),
|
||||||
|
);
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "我的收藏" }));
|
||||||
|
await fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: /^Momen$/ }),
|
||||||
|
);
|
||||||
|
if (!screen.queryByRole("button", { name: "我的收藏" })) {
|
||||||
|
await fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: /^Momen$/ }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "我的收藏" }));
|
||||||
|
|
||||||
|
secondFolders.resolve([
|
||||||
|
{
|
||||||
|
id: 42,
|
||||||
|
name: "新收藏夹",
|
||||||
|
itemCount: 0,
|
||||||
|
createdAt: "2026-05-18T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-18T00:00:00Z",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(await screen.findByText("新收藏夹 (0)")).toBeTruthy();
|
||||||
|
|
||||||
|
firstFolders.resolve([
|
||||||
|
{
|
||||||
|
id: 41,
|
||||||
|
name: "旧收藏夹",
|
||||||
|
itemCount: 0,
|
||||||
|
createdAt: "2026-05-18T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-18T00:00:00Z",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await firstFolders.promise;
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(screen.queryByText("旧收藏夹 (0)")).toBeNull();
|
||||||
|
expect(screen.getByText("新收藏夹 (0)")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores cloud apps by origin and package when category changed", async () => {
|
||||||
|
vi.mocked(fetchSyncedAppList).mockResolvedValueOnce({
|
||||||
|
snapshotName: "默认列表",
|
||||||
|
clientArch: "amd64",
|
||||||
|
distro: "deepin 25",
|
||||||
|
updatedAt: "2026-05-18T00:00:00Z",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
pkgname: "wps",
|
||||||
|
origin: "apm",
|
||||||
|
category: "legacy-office",
|
||||||
|
version: "1.0.0",
|
||||||
|
packageArch: "amd64",
|
||||||
|
appName: "WPS Cloud",
|
||||||
|
iconUrl: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
invoke.mockImplementation(async (channel: string, payload?: unknown) => {
|
||||||
|
if (channel === "get-store-filter") return "apm";
|
||||||
|
if (channel === "check-spark-available") return false;
|
||||||
|
if (channel === "check-apm-available") return true;
|
||||||
|
if (channel === "get-app-version") return "5.0.0";
|
||||||
|
if (channel === "get-system-info") return { distro: "deepin 25" };
|
||||||
|
if (channel === "check-installed") return false;
|
||||||
|
if (channel === "list-installed") {
|
||||||
|
const request = payload as { origin?: string };
|
||||||
|
if (request.origin === "apm") return { success: true, apps: [] };
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
render(App);
|
||||||
|
|
||||||
|
await fireEvent.click(await screen.findByText("应用管理"));
|
||||||
|
await fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: /从账号恢复/ }),
|
||||||
|
);
|
||||||
|
await fireEvent.click(await screen.findByLabelText("WPS Cloud"));
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "加入安装队列" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(window.ipcRenderer.send).toHaveBeenCalledWith(
|
||||||
|
"queue-install",
|
||||||
|
expect.stringContaining('"pkgname":"wps"'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the installed modal scoped to the active origin after sync", async () => {
|
||||||
|
invoke.mockImplementation(async (channel: string, payload?: unknown) => {
|
||||||
|
if (channel === "get-store-filter") return "both";
|
||||||
|
if (channel === "check-spark-available") return true;
|
||||||
|
if (channel === "check-apm-available") return true;
|
||||||
|
if (channel === "get-app-version") return "5.0.0";
|
||||||
|
if (channel === "get-system-info") return { distro: "deepin 25" };
|
||||||
|
if (channel === "list-installed") {
|
||||||
|
const request = payload as { origin?: string };
|
||||||
|
if (request.origin === "spark") {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
pkgname: "wps",
|
||||||
|
name: "WPS",
|
||||||
|
version: "1.0.0",
|
||||||
|
arch: "amd64",
|
||||||
|
flags: "installed",
|
||||||
|
origin: "spark",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { success: true, apps: [] };
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
vi.mocked(uploadSyncedAppList).mockResolvedValueOnce(syncedList([]));
|
||||||
|
render(App);
|
||||||
|
|
||||||
|
await fireEvent.click(await screen.findByText("应用管理"));
|
||||||
|
const modal = screen.getByText("已安装应用").closest(".fixed");
|
||||||
|
if (!(modal instanceof HTMLElement)) throw new Error("modal not found");
|
||||||
|
expect(within(modal).getByText("暂无已安装应用")).toBeTruthy();
|
||||||
|
|
||||||
|
await fireEvent.click(
|
||||||
|
within(modal).getByRole("button", { name: /同步到账号/ }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(uploadSyncedAppList).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
expect(within(modal).queryByText("WPS")).toBeNull();
|
||||||
|
expect(within(modal).getByText("暂无已安装应用")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores overlapping installed sync requests", async () => {
|
||||||
|
const syncUpload = createDeferred<SyncedAppList>();
|
||||||
|
vi.mocked(uploadSyncedAppList).mockReturnValue(syncUpload.promise);
|
||||||
|
invoke.mockImplementation(async (channel: string) => {
|
||||||
|
if (channel === "get-store-filter") return "apm";
|
||||||
|
if (channel === "check-spark-available") return false;
|
||||||
|
if (channel === "check-apm-available") return true;
|
||||||
|
if (channel === "get-app-version") return "5.0.0";
|
||||||
|
if (channel === "get-system-info") return { distro: "deepin 25" };
|
||||||
|
if (channel === "list-installed") return { success: true, apps: [] };
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
render(App);
|
||||||
|
|
||||||
|
await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
|
||||||
|
await fireEvent.click(screen.getByText("用户管理"));
|
||||||
|
await fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "立即同步" }),
|
||||||
|
);
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "立即同步" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(uploadSyncedAppList).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
syncUpload.resolve(syncedList([]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not upload stale sync candidates after logout", async () => {
|
||||||
|
const slowInstalled = createDeferred<{
|
||||||
|
success: true;
|
||||||
|
apps: Array<{
|
||||||
|
pkgname: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
arch: string;
|
||||||
|
flags: string;
|
||||||
|
origin: "apm";
|
||||||
|
}>;
|
||||||
|
}>();
|
||||||
|
let listInstalledCalls = 0;
|
||||||
|
invoke.mockImplementation(async (channel: string) => {
|
||||||
|
if (channel === "get-store-filter") return "apm";
|
||||||
|
if (channel === "check-spark-available") return false;
|
||||||
|
if (channel === "check-apm-available") return true;
|
||||||
|
if (channel === "get-app-version") return "5.0.0";
|
||||||
|
if (channel === "get-system-info") return { distro: "deepin 25" };
|
||||||
|
if (channel === "list-installed") {
|
||||||
|
listInstalledCalls += 1;
|
||||||
|
if (listInstalledCalls === 1) return slowInstalled.promise;
|
||||||
|
return { success: true, apps: [] };
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
vi.mocked(uploadSyncedAppList).mockResolvedValue(syncedList([]));
|
||||||
|
render(App);
|
||||||
|
|
||||||
|
await fireEvent.click(await screen.findByRole("button", { name: /Momen/ }));
|
||||||
|
await fireEvent.click(screen.getByText("用户管理"));
|
||||||
|
await fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "立即同步" }),
|
||||||
|
);
|
||||||
|
await fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: /^Momen$/ }),
|
||||||
|
);
|
||||||
|
if (!screen.queryByText("退出登录")) {
|
||||||
|
await fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: /^Momen$/ }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await fireEvent.click(screen.getByText("退出登录"));
|
||||||
|
|
||||||
|
slowInstalled.resolve({
|
||||||
|
success: true,
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
pkgname: "wps",
|
||||||
|
name: "WPS",
|
||||||
|
version: "1.0.0",
|
||||||
|
arch: "amd64",
|
||||||
|
flags: "installed",
|
||||||
|
origin: "apm",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await slowInstalled.promise;
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
setSecondUserSession();
|
||||||
|
const secondUserButton = await screen.findByRole("button", {
|
||||||
|
name: /^Second User$/,
|
||||||
|
});
|
||||||
|
if (!screen.queryByText("用户管理")) {
|
||||||
|
await fireEvent.click(secondUserButton);
|
||||||
|
}
|
||||||
|
await fireEvent.click(await screen.findByText("用户管理"));
|
||||||
|
await fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "立即同步" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(uploadSyncedAppList).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ items: [] }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const uploadedItemNames = vi
|
||||||
|
.mocked(uploadSyncedAppList)
|
||||||
|
.mock.calls.flatMap(([payload]) =>
|
||||||
|
payload.items.map((item) => item.pkgname),
|
||||||
|
);
|
||||||
|
expect(uploadedItemNames).not.toContain("wps");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
import {
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "@testing-library/vue";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import App from "@/App.vue";
|
||||||
|
import { recordDownloadedApp } from "@/modules/backendApi";
|
||||||
|
import { setAuthSession } from "@/global/authState";
|
||||||
|
import { downloads } from "@/global/downloadStatus";
|
||||||
|
import type { DownloadResult } from "@/global/typedefinition";
|
||||||
|
|
||||||
|
const invoke = vi.fn();
|
||||||
|
const send = vi.fn();
|
||||||
|
const ipcHandlers = new Map<string, (...args: unknown[]) => void>();
|
||||||
|
|
||||||
|
const setSecondUserSession = () => {
|
||||||
|
setAuthSession({
|
||||||
|
accessToken: "backend-token-b",
|
||||||
|
tokenType: "bearer",
|
||||||
|
user: {
|
||||||
|
id: 2,
|
||||||
|
flarumUserId: "84",
|
||||||
|
username: "second",
|
||||||
|
displayName: "Second User",
|
||||||
|
avatarUrl: "https://bbs.spark-app.store/avatar-b.png",
|
||||||
|
forumLevel: "用户",
|
||||||
|
forumGroups: ["用户"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setInitialUserSession = () => {
|
||||||
|
setAuthSession({
|
||||||
|
accessToken: "backend-token",
|
||||||
|
tokenType: "bearer",
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
flarumUserId: "42",
|
||||||
|
username: "momen",
|
||||||
|
displayName: "Momen",
|
||||||
|
avatarUrl: "https://bbs.spark-app.store/avatar.png",
|
||||||
|
forumLevel: "管理员",
|
||||||
|
forumGroups: ["管理员"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createControlledPromise = <T>() => {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((promiseResolve) => {
|
||||||
|
resolve = promiseResolve;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("axios", () => {
|
||||||
|
const get = vi.fn(async (url: string) => {
|
||||||
|
if (url.includes("categories.json")) {
|
||||||
|
return { data: { office: { zh: "办公" } } };
|
||||||
|
}
|
||||||
|
if (url.includes("/office/applist.json")) {
|
||||||
|
return {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
Name: "WPS",
|
||||||
|
Pkgname: "wps",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Filename: "wps_1.0.0_amd64.deb",
|
||||||
|
Torrent_address: "",
|
||||||
|
Author: "",
|
||||||
|
Contributor: "",
|
||||||
|
Website: "",
|
||||||
|
Update: "",
|
||||||
|
Size: "",
|
||||||
|
More: "Office suite",
|
||||||
|
Tags: "",
|
||||||
|
img_urls: "[]",
|
||||||
|
icons: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { data: [] };
|
||||||
|
});
|
||||||
|
const post = vi.fn(async () => ({ data: { ok: true } }));
|
||||||
|
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
create: () => ({ get, post }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/modules/updateCenter", () => ({
|
||||||
|
createUpdateCenterStore: () => ({
|
||||||
|
isOpen: { value: false },
|
||||||
|
showCloseConfirm: { value: false },
|
||||||
|
showMigrationConfirm: { value: false },
|
||||||
|
searchQuery: { value: "" },
|
||||||
|
selectedTaskKeys: { value: new Set<string>() },
|
||||||
|
snapshot: {
|
||||||
|
value: { items: [], tasks: [], warnings: [], hasRunningTasks: false },
|
||||||
|
},
|
||||||
|
filteredItems: { value: [] },
|
||||||
|
allSelected: { value: false },
|
||||||
|
someSelected: { value: false },
|
||||||
|
bind: vi.fn(),
|
||||||
|
unbind: vi.fn(),
|
||||||
|
open: vi.fn(),
|
||||||
|
refresh: vi.fn(),
|
||||||
|
ignoreItem: vi.fn(),
|
||||||
|
unignoreItem: vi.fn(),
|
||||||
|
toggleSelection: vi.fn(),
|
||||||
|
toggleSelectAll: vi.fn(),
|
||||||
|
getSelectedItems: vi.fn(() => []),
|
||||||
|
closeNow: vi.fn(),
|
||||||
|
startSelected: vi.fn(),
|
||||||
|
requestClose: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/modules/backendApi", () => ({
|
||||||
|
addFavoriteItem: vi.fn(),
|
||||||
|
bulkDeleteFavoriteItems: vi.fn(),
|
||||||
|
createFavoriteFolder: vi.fn(),
|
||||||
|
exchangeFlarumToken: vi.fn(),
|
||||||
|
listFavoriteFolders: vi.fn(async () => []),
|
||||||
|
listFavoriteItems: vi.fn(async () => []),
|
||||||
|
recordDownloadedApp: vi.fn(async () => undefined),
|
||||||
|
setBackendToken: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("App download records", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
ipcHandlers.clear();
|
||||||
|
downloads.value = [];
|
||||||
|
invoke.mockImplementation(async (channel: string) => {
|
||||||
|
if (channel === "get-store-filter") return "apm";
|
||||||
|
if (channel === "check-spark-available") return false;
|
||||||
|
if (channel === "check-apm-available") return true;
|
||||||
|
if (channel === "get-app-version") return "5.0.0";
|
||||||
|
if (channel === "get-system-info") return { distro: "deepin 25" };
|
||||||
|
if (channel === "list-installed") return { success: true, apps: [] };
|
||||||
|
if (channel === "check-installed") return false;
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.assign(window.ipcRenderer, {
|
||||||
|
invoke,
|
||||||
|
send,
|
||||||
|
on: vi.fn((channel: string, handler: (...args: unknown[]) => void) => {
|
||||||
|
ipcHandlers.set(channel, handler);
|
||||||
|
}),
|
||||||
|
off: vi.fn(),
|
||||||
|
});
|
||||||
|
window.apm_store.arch = "amd64";
|
||||||
|
localStorage.clear();
|
||||||
|
setInitialUserSession();
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
"matchMedia",
|
||||||
|
vi.fn(() => ({
|
||||||
|
matches: false,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("scrollTo", vi.fn());
|
||||||
|
class MockIntersectionObserver {
|
||||||
|
observe = vi.fn();
|
||||||
|
disconnect = vi.fn();
|
||||||
|
unobserve = vi.fn();
|
||||||
|
}
|
||||||
|
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records a download only after the queued install completes successfully", async () => {
|
||||||
|
render(App);
|
||||||
|
|
||||||
|
await fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "全部应用 1" }),
|
||||||
|
);
|
||||||
|
await fireEvent.click(await screen.findByText("WPS"));
|
||||||
|
await fireEvent.click(await screen.findByRole("button", { name: "安装" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(send).toHaveBeenCalledWith(
|
||||||
|
"queue-install",
|
||||||
|
expect.stringContaining('"pkgname":"wps"'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(recordDownloadedApp).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const queuedPayload = vi
|
||||||
|
.mocked(send)
|
||||||
|
.mock.calls.find(
|
||||||
|
([channel]) => channel === "queue-install",
|
||||||
|
)?.[1] as string;
|
||||||
|
const queuedDownload = JSON.parse(queuedPayload) as { id: number };
|
||||||
|
const completion: DownloadResult = {
|
||||||
|
id: queuedDownload.id,
|
||||||
|
time: Date.now(),
|
||||||
|
message: "installed",
|
||||||
|
success: true,
|
||||||
|
exitCode: 0,
|
||||||
|
status: "completed",
|
||||||
|
origin: "apm",
|
||||||
|
};
|
||||||
|
|
||||||
|
ipcHandlers.get("install-complete")?.({}, completion);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(recordDownloadedApp).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
appKey: "app:office:wps",
|
||||||
|
pkgname: "wps",
|
||||||
|
selectedOrigin: "apm",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a pending download record through a failed install retry", async () => {
|
||||||
|
render(App);
|
||||||
|
|
||||||
|
await fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "全部应用 1" }),
|
||||||
|
);
|
||||||
|
await fireEvent.click(await screen.findByText("WPS"));
|
||||||
|
await fireEvent.click(await screen.findByRole("button", { name: "安装" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(send).toHaveBeenCalledWith(
|
||||||
|
"queue-install",
|
||||||
|
expect.stringContaining('"pkgname":"wps"'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const queuedPayload = vi
|
||||||
|
.mocked(send)
|
||||||
|
.mock.calls.find(
|
||||||
|
([channel]) => channel === "queue-install",
|
||||||
|
)?.[1] as string;
|
||||||
|
const queuedDownload = JSON.parse(queuedPayload) as { id: number };
|
||||||
|
const failedCompletion: DownloadResult = {
|
||||||
|
id: queuedDownload.id,
|
||||||
|
time: Date.now(),
|
||||||
|
message: "failed",
|
||||||
|
success: false,
|
||||||
|
exitCode: 1,
|
||||||
|
status: "failed",
|
||||||
|
origin: "apm",
|
||||||
|
};
|
||||||
|
|
||||||
|
ipcHandlers.get("install-complete")?.({}, failedCompletion);
|
||||||
|
downloads.value[0].status = "failed";
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(recordDownloadedApp).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByTitle("重试")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByTitle("重试"));
|
||||||
|
|
||||||
|
const successfulCompletion: DownloadResult = {
|
||||||
|
id: queuedDownload.id,
|
||||||
|
time: Date.now(),
|
||||||
|
message: "installed",
|
||||||
|
success: true,
|
||||||
|
exitCode: 0,
|
||||||
|
status: "completed",
|
||||||
|
origin: "apm",
|
||||||
|
};
|
||||||
|
|
||||||
|
ipcHandlers.get("install-complete")?.({}, successfulCompletion);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(recordDownloadedApp).toHaveBeenCalledTimes(1);
|
||||||
|
expect(recordDownloadedApp).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
appKey: "app:office:wps",
|
||||||
|
pkgname: "wps",
|
||||||
|
name: "WPS",
|
||||||
|
category: "office",
|
||||||
|
selectedOrigin: "apm",
|
||||||
|
version: "1.0.0",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not record a queued install under a later logged-in user", async () => {
|
||||||
|
render(App);
|
||||||
|
|
||||||
|
await fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "全部应用 1" }),
|
||||||
|
);
|
||||||
|
await fireEvent.click(await screen.findByText("WPS"));
|
||||||
|
await fireEvent.click(await screen.findByRole("button", { name: "安装" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(send).toHaveBeenCalledWith(
|
||||||
|
"queue-install",
|
||||||
|
expect.stringContaining('"pkgname":"wps"'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const queuedPayload = vi
|
||||||
|
.mocked(send)
|
||||||
|
.mock.calls.find(
|
||||||
|
([channel]) => channel === "queue-install",
|
||||||
|
)?.[1] as string;
|
||||||
|
const queuedDownload = JSON.parse(queuedPayload) as { id: number };
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "Momen" }));
|
||||||
|
await fireEvent.click(await screen.findByText("退出登录"));
|
||||||
|
|
||||||
|
setSecondUserSession();
|
||||||
|
|
||||||
|
const completion: DownloadResult = {
|
||||||
|
id: queuedDownload.id,
|
||||||
|
time: Date.now(),
|
||||||
|
message: "installed",
|
||||||
|
success: true,
|
||||||
|
exitCode: 0,
|
||||||
|
status: "completed",
|
||||||
|
origin: "apm",
|
||||||
|
};
|
||||||
|
|
||||||
|
ipcHandlers.get("install-complete")?.({}, completion);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(recordDownloadedApp).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not bind a queued install to a user who logged in during the APM availability check", async () => {
|
||||||
|
const apmCheck = createControlledPromise<boolean>();
|
||||||
|
let apmCheckCalls = 0;
|
||||||
|
invoke.mockImplementation(async (channel: string) => {
|
||||||
|
if (channel === "get-store-filter") return "apm";
|
||||||
|
if (channel === "check-spark-available") return false;
|
||||||
|
if (channel === "check-apm-available") {
|
||||||
|
apmCheckCalls += 1;
|
||||||
|
return apmCheckCalls === 1 ? true : apmCheck.promise;
|
||||||
|
}
|
||||||
|
if (channel === "get-app-version") return "5.0.0";
|
||||||
|
if (channel === "get-system-info") return { distro: "deepin 25" };
|
||||||
|
if (channel === "list-installed") return { success: true, apps: [] };
|
||||||
|
if (channel === "check-installed") return false;
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
render(App);
|
||||||
|
|
||||||
|
await fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "全部应用 1" }),
|
||||||
|
);
|
||||||
|
await fireEvent.click(await screen.findByText("WPS"));
|
||||||
|
await fireEvent.click(await screen.findByRole("button", { name: "安装" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(apmCheckCalls).toBe(2);
|
||||||
|
});
|
||||||
|
setSecondUserSession();
|
||||||
|
apmCheck.resolve(true);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(send).toHaveBeenCalledWith(
|
||||||
|
"queue-install",
|
||||||
|
expect.stringContaining('"pkgname":"wps"'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const queuedPayload = vi
|
||||||
|
.mocked(send)
|
||||||
|
.mock.calls.find(
|
||||||
|
([channel]) => channel === "queue-install",
|
||||||
|
)?.[1] as string;
|
||||||
|
const queuedDownload = JSON.parse(queuedPayload) as { id: number };
|
||||||
|
const completion: DownloadResult = {
|
||||||
|
id: queuedDownload.id,
|
||||||
|
time: Date.now(),
|
||||||
|
message: "installed",
|
||||||
|
success: true,
|
||||||
|
exitCode: 0,
|
||||||
|
status: "completed",
|
||||||
|
origin: "apm",
|
||||||
|
};
|
||||||
|
|
||||||
|
ipcHandlers.get("install-complete")?.({}, completion);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(recordDownloadedApp).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cleans up a successful pending record even when the active user does not match", async () => {
|
||||||
|
render(App);
|
||||||
|
|
||||||
|
await fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "全部应用 1" }),
|
||||||
|
);
|
||||||
|
await fireEvent.click(await screen.findByText("WPS"));
|
||||||
|
await fireEvent.click(await screen.findByRole("button", { name: "安装" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(send).toHaveBeenCalledWith(
|
||||||
|
"queue-install",
|
||||||
|
expect.stringContaining('"pkgname":"wps"'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const queuedPayload = vi
|
||||||
|
.mocked(send)
|
||||||
|
.mock.calls.find(
|
||||||
|
([channel]) => channel === "queue-install",
|
||||||
|
)?.[1] as string;
|
||||||
|
const queuedDownload = JSON.parse(queuedPayload) as { id: number };
|
||||||
|
const completion: DownloadResult = {
|
||||||
|
id: queuedDownload.id,
|
||||||
|
time: Date.now(),
|
||||||
|
message: "installed",
|
||||||
|
success: true,
|
||||||
|
exitCode: 0,
|
||||||
|
status: "completed",
|
||||||
|
origin: "apm",
|
||||||
|
};
|
||||||
|
|
||||||
|
setSecondUserSession();
|
||||||
|
ipcHandlers.get("install-complete")?.({}, completion);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(recordDownloadedApp).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
setInitialUserSession();
|
||||||
|
ipcHandlers.get("install-complete")?.({}, completion);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(recordDownloadedApp).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/vue";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import AppDetailPage from "@/components/AppDetailPage.vue";
|
||||||
|
import type { App, ReviewTags } from "@/global/typedefinition";
|
||||||
|
|
||||||
|
vi.mock("@/components/ReviewsPanel.vue", () => ({
|
||||||
|
default: {
|
||||||
|
name: "ReviewsPanel",
|
||||||
|
props: ["appKey", "tags", "loggedIn"],
|
||||||
|
template:
|
||||||
|
'<div data-testid="reviews-panel" :data-app-key="appKey" :data-origin="tags.origin" :data-version="tags.version"></div>',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sparkApp: App = {
|
||||||
|
...app,
|
||||||
|
name: "WPS Spark",
|
||||||
|
version: "2.0.0",
|
||||||
|
filename: "wps_2.0.0_amd64.deb",
|
||||||
|
origin: "spark",
|
||||||
|
};
|
||||||
|
|
||||||
|
const apmApp: App = {
|
||||||
|
...app,
|
||||||
|
name: "WPS APM",
|
||||||
|
version: "1.0.0",
|
||||||
|
filename: "wps_1.0.0_amd64.deb",
|
||||||
|
origin: "apm",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergedApp: App = {
|
||||||
|
...sparkApp,
|
||||||
|
isMerged: true,
|
||||||
|
sparkApp,
|
||||||
|
apmApp,
|
||||||
|
viewingOrigin: "spark",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sparkTags: ReviewTags = {
|
||||||
|
origin: "spark",
|
||||||
|
category: "office",
|
||||||
|
pkgname: "wps",
|
||||||
|
version: "2.0.0",
|
||||||
|
packageArch: "amd64",
|
||||||
|
clientArch: "amd64",
|
||||||
|
distro: "deepin 25",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("AppDetailPage", () => {
|
||||||
|
it("renders as page, emits back, and gates favorite for anonymous users", async () => {
|
||||||
|
const rendered = render(AppDetailPage, {
|
||||||
|
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(
|
||||||
|
"收藏应用需要登录星火账号。",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gates reviews for anonymous users", async () => {
|
||||||
|
const rendered = render(AppDetailPage, {
|
||||||
|
props: {
|
||||||
|
app,
|
||||||
|
screenshots: [],
|
||||||
|
sparkInstalled: false,
|
||||||
|
apmInstalled: false,
|
||||||
|
loggedIn: false,
|
||||||
|
reviewAppKey: "apm:amd64-apm:office:wps",
|
||||||
|
reviewTags: sparkTags,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("reviews-panel")).toBeNull();
|
||||||
|
await fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "登录后查看评价" }),
|
||||||
|
);
|
||||||
|
expect(rendered.emitted("request-login")?.[0]?.[0]).toBe(
|
||||||
|
"登录后查看和发表评论。",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates review identity when switching a merged app origin", async () => {
|
||||||
|
render(AppDetailPage, {
|
||||||
|
props: {
|
||||||
|
app: mergedApp,
|
||||||
|
screenshots: [],
|
||||||
|
sparkInstalled: false,
|
||||||
|
apmInstalled: false,
|
||||||
|
loggedIn: true,
|
||||||
|
reviewAppKey: "spark:amd64-store:office:wps",
|
||||||
|
reviewTags: sparkTags,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
|
||||||
|
"data-app-key",
|
||||||
|
"spark:amd64-store:office:wps",
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
|
||||||
|
"data-origin",
|
||||||
|
"spark",
|
||||||
|
);
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "APM" }));
|
||||||
|
|
||||||
|
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
|
||||||
|
"data-app-key",
|
||||||
|
"apm:amd64-apm:office:wps",
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
|
||||||
|
"data-origin",
|
||||||
|
"apm",
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
|
||||||
|
"data-version",
|
||||||
|
"1.0.0",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/vue";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import AppListRestoreModal from "@/components/AppListRestoreModal.vue";
|
||||||
|
import type { SyncedAppListItem } from "@/global/typedefinition";
|
||||||
|
|
||||||
|
const createItem = (
|
||||||
|
overrides: Partial<SyncedAppListItem> = {},
|
||||||
|
): SyncedAppListItem => ({
|
||||||
|
pkgname: "spark-notes",
|
||||||
|
origin: "spark",
|
||||||
|
category: "office",
|
||||||
|
version: "1.0.0",
|
||||||
|
packageArch: "amd64",
|
||||||
|
appName: "Spark Notes",
|
||||||
|
iconUrl: "",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AppListRestoreModal", () => {
|
||||||
|
it("emits selected installable cloud items", async () => {
|
||||||
|
const rendered = render(AppListRestoreModal, {
|
||||||
|
props: {
|
||||||
|
show: true,
|
||||||
|
loading: false,
|
||||||
|
error: "",
|
||||||
|
items: [
|
||||||
|
createItem(),
|
||||||
|
createItem({ pkgname: "amber-ce", appName: "Amber CE" }),
|
||||||
|
],
|
||||||
|
installedKeys: new Set<string>(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByLabelText("Spark Notes"));
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "加入安装队列" }));
|
||||||
|
|
||||||
|
expect(rendered.emitted("install-selected")?.[0]?.[0]).toEqual([
|
||||||
|
expect.objectContaining({ pkgname: "spark-notes" }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables already installed cloud items", () => {
|
||||||
|
render(AppListRestoreModal, {
|
||||||
|
props: {
|
||||||
|
show: true,
|
||||||
|
loading: false,
|
||||||
|
error: "",
|
||||||
|
items: [createItem()],
|
||||||
|
installedKeys: new Set(["spark:spark-notes"]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("Spark Notes")).toBeDisabled();
|
||||||
|
expect(screen.getByText("已安装")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes selected items when they become installed", async () => {
|
||||||
|
const rendered = render(AppListRestoreModal, {
|
||||||
|
props: {
|
||||||
|
show: true,
|
||||||
|
loading: false,
|
||||||
|
error: "",
|
||||||
|
items: [createItem()],
|
||||||
|
installedKeys: new Set<string>(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByLabelText("Spark Notes"));
|
||||||
|
await rendered.rerender({ installedKeys: new Set(["spark:spark-notes"]) });
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("Spark Notes")).toBeDisabled();
|
||||||
|
expect(screen.getByRole("button", { name: "加入安装队列" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/vue";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import AppSidebar from "@/components/AppSidebar.vue";
|
||||||
|
import type { SparkUser } from "@/global/typedefinition";
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
activeTab: "all",
|
||||||
|
categoryCounts: { all: 0 },
|
||||||
|
themeMode: "auto" as const,
|
||||||
|
storeFilter: "both" as const,
|
||||||
|
sparkAvailable: true,
|
||||||
|
apmAvailable: true,
|
||||||
|
sidebarEntries: [],
|
||||||
|
entryCounts: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const user: SparkUser = {
|
||||||
|
id: 1,
|
||||||
|
flarumUserId: "123",
|
||||||
|
username: "momen",
|
||||||
|
displayName: "Momen",
|
||||||
|
avatarUrl: "https://bbs.spark-app.store/avatar.png",
|
||||||
|
forumLevel: "管理员",
|
||||||
|
forumGroups: ["管理员"],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("AppSidebar account entry", () => {
|
||||||
|
it("prompts login when anonymous", async () => {
|
||||||
|
const rendered = render(AppSidebar, {
|
||||||
|
props: { ...baseProps, currentUser: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: /登录 \/ 注册/ }));
|
||||||
|
|
||||||
|
expect(rendered.emitted("request-login")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens quick menu for logged-in users", async () => {
|
||||||
|
render(AppSidebar, { props: { ...baseProps, currentUser: user } });
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: /Momen/ }));
|
||||||
|
|
||||||
|
expect(screen.getByText("用户管理")).toBeTruthy();
|
||||||
|
expect(screen.getByText("我的收藏")).toBeTruthy();
|
||||||
|
expect(screen.getByText("退出登录")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,7 @@ const renderSidebar = (
|
|||||||
apmAvailable: true,
|
apmAvailable: true,
|
||||||
sidebarEntries: [],
|
sidebarEntries: [],
|
||||||
entryCounts: {},
|
entryCounts: {},
|
||||||
|
currentUser: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/vue";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import FavoriteFolderManager from "@/components/FavoriteFolderManager.vue";
|
||||||
|
import type {
|
||||||
|
FavoriteFolder,
|
||||||
|
ResolvedFavoriteItem,
|
||||||
|
} from "@/global/typedefinition";
|
||||||
|
|
||||||
|
const folder: FavoriteFolder = {
|
||||||
|
id: 1,
|
||||||
|
name: "默认收藏夹",
|
||||||
|
itemCount: 1,
|
||||||
|
createdAt: "2026-05-18T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-18T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const item: ResolvedFavoriteItem = {
|
||||||
|
item: {
|
||||||
|
id: 2,
|
||||||
|
appKey: "app:office:wps",
|
||||||
|
pkgname: "wps",
|
||||||
|
name: "WPS",
|
||||||
|
category: "office",
|
||||||
|
iconUrl: "",
|
||||||
|
createdAt: "2026-05-18T00:00:00Z",
|
||||||
|
},
|
||||||
|
status: "downlisted",
|
||||||
|
reason: "已下架",
|
||||||
|
selectedApp: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("FavoriteFolderManager", () => {
|
||||||
|
it("shows downlisted favorites and emits bulk delete", async () => {
|
||||||
|
const rendered = render(FavoriteFolderManager, {
|
||||||
|
props: {
|
||||||
|
folders: [folder],
|
||||||
|
activeFolderId: 1,
|
||||||
|
items: [item],
|
||||||
|
loading: false,
|
||||||
|
error: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("已下架")).toBeTruthy();
|
||||||
|
await fireEvent.click(screen.getByLabelText("选择 WPS"));
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "移除选中" }));
|
||||||
|
|
||||||
|
expect(rendered.emitted("remove-selected")?.[0]?.[0]).toEqual([2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,6 +37,8 @@ describe("InstalledAppsModal", () => {
|
|||||||
storeFilter: "both",
|
storeFilter: "both",
|
||||||
sparkAvailable: true,
|
sparkAvailable: true,
|
||||||
apmAvailable: true,
|
apmAvailable: true,
|
||||||
|
loggedIn: false,
|
||||||
|
syncing: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,6 +59,8 @@ describe("InstalledAppsModal", () => {
|
|||||||
storeFilter: "both",
|
storeFilter: "both",
|
||||||
sparkAvailable: true,
|
sparkAvailable: true,
|
||||||
apmAvailable: true,
|
apmAvailable: true,
|
||||||
|
loggedIn: false,
|
||||||
|
syncing: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,6 +79,8 @@ describe("InstalledAppsModal", () => {
|
|||||||
storeFilter: "both",
|
storeFilter: "both",
|
||||||
sparkAvailable: true,
|
sparkAvailable: true,
|
||||||
apmAvailable: true,
|
apmAvailable: true,
|
||||||
|
loggedIn: false,
|
||||||
|
syncing: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,6 +103,8 @@ describe("InstalledAppsModal", () => {
|
|||||||
storeFilter: "both",
|
storeFilter: "both",
|
||||||
sparkAvailable: true,
|
sparkAvailable: true,
|
||||||
apmAvailable: true,
|
apmAvailable: true,
|
||||||
|
loggedIn: false,
|
||||||
|
syncing: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -119,6 +127,8 @@ describe("InstalledAppsModal", () => {
|
|||||||
storeFilter: "both",
|
storeFilter: "both",
|
||||||
sparkAvailable: true,
|
sparkAvailable: true,
|
||||||
apmAvailable: true,
|
apmAvailable: true,
|
||||||
|
loggedIn: false,
|
||||||
|
syncing: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,9 +146,75 @@ describe("InstalledAppsModal", () => {
|
|||||||
storeFilter: "both",
|
storeFilter: "both",
|
||||||
sparkAvailable: true,
|
sparkAvailable: true,
|
||||||
apmAvailable: true,
|
apmAvailable: true,
|
||||||
|
loggedIn: false,
|
||||||
|
syncing: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.queryByRole("button", { name: "查看详情" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "查看详情" })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requests login for cloud actions when logged out", async () => {
|
||||||
|
const rendered = render(InstalledAppsModal, {
|
||||||
|
props: {
|
||||||
|
show: true,
|
||||||
|
apps: [],
|
||||||
|
loading: false,
|
||||||
|
error: "",
|
||||||
|
activeOrigin: "spark",
|
||||||
|
storeFilter: "both",
|
||||||
|
sparkAvailable: true,
|
||||||
|
apmAvailable: true,
|
||||||
|
loggedIn: false,
|
||||||
|
syncing: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "同步到账号" }));
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "从账号恢复" }));
|
||||||
|
|
||||||
|
expect(rendered.emitted("request-login")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits cloud sync and restore events when logged in", async () => {
|
||||||
|
const rendered = render(InstalledAppsModal, {
|
||||||
|
props: {
|
||||||
|
show: true,
|
||||||
|
apps: [],
|
||||||
|
loading: false,
|
||||||
|
error: "",
|
||||||
|
activeOrigin: "spark",
|
||||||
|
storeFilter: "both",
|
||||||
|
sparkAvailable: true,
|
||||||
|
apmAvailable: true,
|
||||||
|
loggedIn: true,
|
||||||
|
syncing: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "同步到账号" }));
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "从账号恢复" }));
|
||||||
|
|
||||||
|
expect(rendered.emitted("sync-to-account")).toHaveLength(1);
|
||||||
|
expect(rendered.emitted("restore-from-account")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables sync button while syncing", () => {
|
||||||
|
render(InstalledAppsModal, {
|
||||||
|
props: {
|
||||||
|
show: true,
|
||||||
|
apps: [],
|
||||||
|
loading: false,
|
||||||
|
error: "",
|
||||||
|
activeOrigin: "spark",
|
||||||
|
storeFilter: "both",
|
||||||
|
sparkAvailable: true,
|
||||||
|
apmAvailable: true,
|
||||||
|
loggedIn: true,
|
||||||
|
syncing: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "同步中" })).toBeDisabled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/vue";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import LoginModal from "@/components/LoginModal.vue";
|
||||||
|
|
||||||
|
describe("LoginModal", () => {
|
||||||
|
it("emits login credentials and register request", async () => {
|
||||||
|
const rendered = render(LoginModal, {
|
||||||
|
props: { show: true, loading: false, error: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await fireEvent.update(screen.getByLabelText("论坛账号"), " momen ");
|
||||||
|
await fireEvent.update(screen.getByLabelText("论坛密码"), " secret ");
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "登录" }));
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "注册账号" }));
|
||||||
|
|
||||||
|
expect(rendered.emitted("login")?.[0]?.[0]).toEqual({
|
||||||
|
identification: "momen",
|
||||||
|
password: "secret",
|
||||||
|
});
|
||||||
|
expect(rendered.emitted("register")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { render, screen } from "@testing-library/vue";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import ReviewsPanel from "@/components/ReviewsPanel.vue";
|
||||||
|
import {
|
||||||
|
fetchRatingSummary,
|
||||||
|
fetchReviews,
|
||||||
|
submitReview,
|
||||||
|
} from "@/modules/backendApi";
|
||||||
|
import type {
|
||||||
|
AppReview,
|
||||||
|
RatingSummary,
|
||||||
|
ReviewTags,
|
||||||
|
} from "@/global/typedefinition";
|
||||||
|
|
||||||
|
const emptySummary: RatingSummary = {
|
||||||
|
averageRating: 0,
|
||||||
|
reviewCount: 0,
|
||||||
|
starCounts: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("@/modules/backendApi", () => ({
|
||||||
|
fetchRatingSummary: vi.fn(async () => emptySummary),
|
||||||
|
fetchReviews: vi.fn(async () => []),
|
||||||
|
submitReview: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const tags: ReviewTags = {
|
||||||
|
origin: "apm",
|
||||||
|
category: "office",
|
||||||
|
pkgname: "wps",
|
||||||
|
version: "1.0.0",
|
||||||
|
packageArch: "amd64",
|
||||||
|
clientArch: "amd64",
|
||||||
|
distro: "deepin 25",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("ReviewsPanel", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(fetchRatingSummary).mockReset();
|
||||||
|
vi.mocked(fetchReviews).mockReset();
|
||||||
|
vi.mocked(submitReview).mockReset();
|
||||||
|
vi.mocked(fetchRatingSummary).mockResolvedValue(emptySummary);
|
||||||
|
vi.mocked(fetchReviews).mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows anonymous login prompt and read-only review tags", () => {
|
||||||
|
render(ReviewsPanel, {
|
||||||
|
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("登录后发表评论")).toBeTruthy();
|
||||||
|
expect(screen.getByText("1.0.0")).toBeTruthy();
|
||||||
|
expect(screen.getByText("deepin 25")).toBeTruthy();
|
||||||
|
expect(fetchRatingSummary).not.toHaveBeenCalled();
|
||||||
|
expect(fetchReviews).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores stale review responses after app key changes", async () => {
|
||||||
|
let resolveFirstSummary!: (summary: RatingSummary) => void;
|
||||||
|
let resolveFirstReviews!: (reviews: AppReview[]) => void;
|
||||||
|
let resolveSecondSummary!: (summary: RatingSummary) => void;
|
||||||
|
let resolveSecondReviews!: (reviews: AppReview[]) => void;
|
||||||
|
|
||||||
|
vi.mocked(fetchRatingSummary)
|
||||||
|
.mockReturnValueOnce(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveFirstSummary = resolve;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockReturnValueOnce(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveSecondSummary = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.mocked(fetchReviews)
|
||||||
|
.mockReturnValueOnce(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveFirstReviews = resolve;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockReturnValueOnce(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveSecondReviews = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rendered = render(ReviewsPanel, {
|
||||||
|
props: { appKey: "first", tags, loggedIn: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await rendered.rerender({ appKey: "second", tags, loggedIn: true });
|
||||||
|
|
||||||
|
resolveSecondSummary({ averageRating: 5, reviewCount: 1, starCounts: {} });
|
||||||
|
resolveSecondReviews([
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
rating: 5,
|
||||||
|
content: "second review",
|
||||||
|
version: tags.version,
|
||||||
|
packageArch: tags.packageArch,
|
||||||
|
clientArch: tags.clientArch,
|
||||||
|
distro: tags.distro,
|
||||||
|
origin: tags.origin,
|
||||||
|
category: tags.category,
|
||||||
|
createdAt: "2026-05-18T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-18T00:00:00Z",
|
||||||
|
userDisplayName: "Second User",
|
||||||
|
userAvatarUrl: "",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(await screen.findByText("second review")).toBeTruthy();
|
||||||
|
expect(screen.getByText("5.0 / 5 (1)")).toBeTruthy();
|
||||||
|
|
||||||
|
resolveFirstSummary({ averageRating: 1, reviewCount: 1, starCounts: {} });
|
||||||
|
resolveFirstReviews([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
rating: 1,
|
||||||
|
content: "first review",
|
||||||
|
version: tags.version,
|
||||||
|
packageArch: tags.packageArch,
|
||||||
|
clientArch: tags.clientArch,
|
||||||
|
distro: tags.distro,
|
||||||
|
origin: tags.origin,
|
||||||
|
category: tags.category,
|
||||||
|
createdAt: "2026-05-18T00:00:00Z",
|
||||||
|
updatedAt: "2026-05-18T00:00:00Z",
|
||||||
|
userDisplayName: "First User",
|
||||||
|
userAvatarUrl: "",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(screen.getByText("second review")).toBeTruthy();
|
||||||
|
expect(screen.getByText("5.0 / 5 (1)")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("first review")).toBeNull();
|
||||||
|
expect(screen.queryByText("1.0 / 5 (1)")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { render, screen } from "@testing-library/vue";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import UserManagementView from "@/components/UserManagementView.vue";
|
||||||
|
import type { DownloadedAppRecord, SparkUser } from "@/global/typedefinition";
|
||||||
|
|
||||||
|
const user: SparkUser = {
|
||||||
|
id: 1,
|
||||||
|
flarumUserId: "123",
|
||||||
|
username: "momen",
|
||||||
|
displayName: "Momen",
|
||||||
|
avatarUrl: "https://bbs.spark-app.store/avatar.png",
|
||||||
|
forumLevel: "管理员",
|
||||||
|
forumGroups: ["管理员"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const download: DownloadedAppRecord = {
|
||||||
|
id: 1,
|
||||||
|
appKey: "app:office:wps",
|
||||||
|
pkgname: "wps",
|
||||||
|
name: "WPS",
|
||||||
|
category: "office",
|
||||||
|
selectedOrigin: "apm",
|
||||||
|
version: "1.0.0",
|
||||||
|
packageArch: "amd64",
|
||||||
|
downloadedAt: "2026-05-18T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("UserManagementView", () => {
|
||||||
|
it("renders profile, forum level, links, downloads, and sync preference", () => {
|
||||||
|
render(UserManagementView, {
|
||||||
|
props: {
|
||||||
|
user,
|
||||||
|
downloadedApps: [download],
|
||||||
|
syncEnabled: true,
|
||||||
|
loading: false,
|
||||||
|
error: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("Momen")).toBeTruthy();
|
||||||
|
expect(screen.getByText("管理员")).toBeTruthy();
|
||||||
|
expect(screen.getByText("论坛首页")).toBeTruthy();
|
||||||
|
expect(screen.getByText("修改论坛资料")).toBeTruthy();
|
||||||
|
expect(screen.getByText("WPS")).toBeTruthy();
|
||||||
|
expect(screen.getByLabelText("自动同步已安装应用")).toBeChecked();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
describe("accountSyncState", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes installed sync preference to the current user", async () => {
|
||||||
|
const {
|
||||||
|
installedSyncEnabled,
|
||||||
|
loadInstalledSyncPreference,
|
||||||
|
setInstalledSyncEnabled,
|
||||||
|
} = await import("@/global/accountSyncState");
|
||||||
|
|
||||||
|
loadInstalledSyncPreference(1);
|
||||||
|
setInstalledSyncEnabled(true);
|
||||||
|
|
||||||
|
loadInstalledSyncPreference(2);
|
||||||
|
|
||||||
|
expect(installedSyncEnabled.value).toBeNull();
|
||||||
|
|
||||||
|
setInstalledSyncEnabled(false);
|
||||||
|
loadInstalledSyncPreference(1);
|
||||||
|
|
||||||
|
expect(installedSyncEnabled.value).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
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(SPARK_BACKEND_BASE_URL).toMatch(/^https?:\/\//);
|
||||||
|
expect(FLARUM_BASE_URL).toContain("bbs.spark-app.store");
|
||||||
|
expect(FLARUM_REGISTER_URL).toContain("register");
|
||||||
|
expect(user.forumGroups).toEqual(["管理员"]);
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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("builds review keys from already-qualified client arch values", () => {
|
||||||
|
expect(buildReviewAppKey({ ...app, origin: "spark" }, "amd64-store")).toBe(
|
||||||
|
"spark:amd64-store:office:wps",
|
||||||
|
);
|
||||||
|
expect(buildReviewAppKey(app, "amd64-apm")).toBe(
|
||||||
|
"apm:amd64-apm:office:wps",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses package arch and review tags", () => {
|
||||||
|
expect(parsePackageArch(app.filename)).toBe("amd64");
|
||||||
|
expect(
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildSyncItems,
|
||||||
|
cloudItemKey,
|
||||||
|
mergeInstalledApps,
|
||||||
|
} from "@/modules/appListSync";
|
||||||
|
import type { App } from "@/global/typedefinition";
|
||||||
|
|
||||||
|
const createApp = (overrides: Partial<App> = {}): App => ({
|
||||||
|
name: "Spark Notes",
|
||||||
|
pkgname: "spark-notes",
|
||||||
|
version: "1.0.0",
|
||||||
|
filename: "spark-notes_1.0.0_amd64.deb",
|
||||||
|
torrent_address: "",
|
||||||
|
author: "",
|
||||||
|
contributor: "",
|
||||||
|
website: "",
|
||||||
|
update: "",
|
||||||
|
size: "1 MB",
|
||||||
|
more: "",
|
||||||
|
tags: "",
|
||||||
|
img_urls: [],
|
||||||
|
icons: "https://example.test/icon.png",
|
||||||
|
category: "office",
|
||||||
|
origin: "spark",
|
||||||
|
currentStatus: "installed",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("appListSync", () => {
|
||||||
|
it("builds cloud sync items for installed store-recognized user apps", () => {
|
||||||
|
expect(buildSyncItems([createApp()])).toEqual([
|
||||||
|
{
|
||||||
|
pkgname: "spark-notes",
|
||||||
|
origin: "spark",
|
||||||
|
category: "office",
|
||||||
|
version: "1.0.0",
|
||||||
|
packageArch: "amd64",
|
||||||
|
appName: "Spark Notes",
|
||||||
|
iconUrl: "https://example.test/icon.png",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters out non-installed unknown dependency and unusable package entries", () => {
|
||||||
|
const items = buildSyncItems([
|
||||||
|
createApp({ pkgname: "not-installed", currentStatus: "not-installed" }),
|
||||||
|
createApp({ pkgname: "unknown-app", category: "unknown" }),
|
||||||
|
createApp({ pkgname: "dependency", isDependency: true }),
|
||||||
|
createApp({ pkgname: "" }),
|
||||||
|
createApp({ pkgname: "blank-origin", origin: "spark" }),
|
||||||
|
createApp({ pkgname: "kept", origin: "apm", arch: "arm64" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(items).toEqual([
|
||||||
|
expect.objectContaining({ pkgname: "blank-origin" }),
|
||||||
|
expect.objectContaining({ pkgname: "kept", packageArch: "arm64" }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses pkgname as appName and blank icon when optional display fields are missing", () => {
|
||||||
|
const app = createApp({ icons: "", pkgname: "fallback-name" });
|
||||||
|
app.name = "";
|
||||||
|
const [syncItem] = buildSyncItems([app]);
|
||||||
|
|
||||||
|
expect(syncItem).toMatchObject({
|
||||||
|
appName: "fallback-name",
|
||||||
|
iconUrl: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds stable installed keys from origin and package", () => {
|
||||||
|
expect(cloudItemKey({ origin: "apm", pkgname: "amber-ce" })).toBe(
|
||||||
|
"apm:amber-ce",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges refreshed apps without mutating active modal origin lists", () => {
|
||||||
|
const current = [createApp({ origin: "apm", pkgname: "apm-installed" })];
|
||||||
|
const refreshed = [
|
||||||
|
createApp({ origin: "spark", pkgname: "spark-installed" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(mergeInstalledApps(current, refreshed, ["spark"])).toEqual([
|
||||||
|
expect.objectContaining({ origin: "apm", pkgname: "apm-installed" }),
|
||||||
|
expect.objectContaining({ origin: "spark", pkgname: "spark-installed" }),
|
||||||
|
]);
|
||||||
|
expect(current).toEqual([
|
||||||
|
expect.objectContaining({ origin: "apm", pkgname: "apm-installed" }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
describe("authState", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists and clears a backend session", async () => {
|
||||||
|
const { authSession, currentUser, isLoggedIn, setAuthSession, logout } =
|
||||||
|
await import("@/global/authState");
|
||||||
|
|
||||||
|
setAuthSession({
|
||||||
|
accessToken: "jwt",
|
||||||
|
tokenType: "bearer",
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
flarumUserId: "123",
|
||||||
|
username: "momen",
|
||||||
|
displayName: "Momen",
|
||||||
|
avatarUrl: "https://bbs.spark-app.store/avatar.png",
|
||||||
|
forumLevel: "管理员",
|
||||||
|
forumGroups: ["管理员"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(authSession.value?.accessToken).toBe("jwt");
|
||||||
|
expect(currentUser.value?.displayName).toBe("Momen");
|
||||||
|
expect(isLoggedIn.value).toBe(true);
|
||||||
|
expect(
|
||||||
|
JSON.parse(localStorage.getItem("spark-store-auth") || "{}").accessToken,
|
||||||
|
).toBe("jwt");
|
||||||
|
|
||||||
|
logout();
|
||||||
|
|
||||||
|
expect(authSession.value).toBeNull();
|
||||||
|
expect(isLoggedIn.value).toBe(false);
|
||||||
|
expect(localStorage.getItem("spark-store-auth")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { exchangeFlarumToken } from "@/modules/backendApi";
|
||||||
|
|
||||||
|
const axiosMocks = vi.hoisted(() => {
|
||||||
|
const post = vi.fn();
|
||||||
|
return {
|
||||||
|
instance: {
|
||||||
|
defaults: { headers: { common: {} as Record<string, unknown> } },
|
||||||
|
get: vi.fn(),
|
||||||
|
post,
|
||||||
|
},
|
||||||
|
post,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const loggerMocks = vi.hoisted(() => ({
|
||||||
|
error: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("axios", () => ({
|
||||||
|
default: {
|
||||||
|
create: vi.fn(() => axiosMocks.instance),
|
||||||
|
isAxiosError: (error: unknown) =>
|
||||||
|
Boolean((error as { isAxiosError?: boolean }).isAxiosError),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("pino", () => ({
|
||||||
|
default: () => loggerMocks,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("backend API auth exchange", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(axios.create).mockClear();
|
||||||
|
axiosMocks.post.mockReset();
|
||||||
|
loggerMocks.error.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps backend connection failures to a user-actionable login error", async () => {
|
||||||
|
const error = Object.assign(new Error("Network Error"), {
|
||||||
|
code: "ERR_NETWORK",
|
||||||
|
isAxiosError: true,
|
||||||
|
request: {},
|
||||||
|
});
|
||||||
|
axiosMocks.post.mockRejectedValue(error);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
exchangeFlarumToken({ flarumUserId: "42", flarumToken: "forum-token" }),
|
||||||
|
).rejects.toThrow("无法连接星火账号服务,请确认后端服务已启动或稍后重试。");
|
||||||
|
expect(loggerMocks.error).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
code: "ERR_NETWORK",
|
||||||
|
message: "Network Error",
|
||||||
|
status: undefined,
|
||||||
|
},
|
||||||
|
"Spark backend auth exchange failed",
|
||||||
|
);
|
||||||
|
expect(JSON.stringify(loggerMocks.error.mock.calls)).not.toContain("forum-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps backend server failures to an update-required login error", async () => {
|
||||||
|
const error = Object.assign(new Error("Request failed with status code 500"), {
|
||||||
|
isAxiosError: true,
|
||||||
|
response: { status: 500 },
|
||||||
|
});
|
||||||
|
axiosMocks.post.mockRejectedValue(error);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
exchangeFlarumToken({ flarumUserId: "42", flarumToken: "forum-token" }),
|
||||||
|
).rejects.toThrow("星火账号服务异常,请确认后端数据库迁移已执行后重试。");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { resolveFavoriteItems } from "@/modules/favoriteAvailability";
|
||||||
|
import { loadPriorityConfig } from "@/global/storeConfig";
|
||||||
|
import type { App, FavoriteItem } from "@/global/typedefinition";
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
const app = (origin: "spark" | "apm", overrides: Partial<App> = {}): 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", () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: false } as Response);
|
||||||
|
await loadPriorityConfig("amd64");
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks downlisted favorites", () => {
|
||||||
|
expect(
|
||||||
|
resolveFavoriteItems(
|
||||||
|
[favorite],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
{ spark: true, apm: true },
|
||||||
|
"both",
|
||||||
|
)[0].status,
|
||||||
|
).toBe("downlisted");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects preferred installable variant", async () => {
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
sparkPriority: { pkgnames: [], categories: [], tags: [] },
|
||||||
|
apmPriority: { pkgnames: [], categories: [], tags: [] },
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
await loadPriorityConfig("amd64");
|
||||||
|
|
||||||
|
const resolved = resolveFavoriteItems(
|
||||||
|
[favorite],
|
||||||
|
[app("spark"), app("apm")],
|
||||||
|
[],
|
||||||
|
{ spark: true, apm: true },
|
||||||
|
"both",
|
||||||
|
)[0];
|
||||||
|
expect(resolved.status).toBe("installable");
|
||||||
|
expect(resolved.selectedApp?.origin).toBe("apm");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects Spark when hybrid priority config prefers Spark", async () => {
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
sparkPriority: { pkgnames: ["wps"], categories: [], tags: [] },
|
||||||
|
apmPriority: { pkgnames: [], categories: [], tags: [] },
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
await loadPriorityConfig("amd64");
|
||||||
|
|
||||||
|
const resolved = resolveFavoriteItems(
|
||||||
|
[favorite],
|
||||||
|
[app("spark"), app("apm")],
|
||||||
|
[],
|
||||||
|
{ spark: true, apm: true },
|
||||||
|
"both",
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
expect(resolved.status).toBe("installable");
|
||||||
|
expect(resolved.selectedApp?.origin).toBe("spark");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks installed favorites", () => {
|
||||||
|
const resolved = resolveFavoriteItems(
|
||||||
|
[favorite],
|
||||||
|
[app("apm")],
|
||||||
|
[app("apm", { currentStatus: "installed" })],
|
||||||
|
{ spark: true, apm: true },
|
||||||
|
"both",
|
||||||
|
)[0];
|
||||||
|
expect(resolved.status).toBe("installed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks installed favorites from all-category catalog matches", () => {
|
||||||
|
const resolved = resolveFavoriteItems(
|
||||||
|
[favorite],
|
||||||
|
[app("spark")],
|
||||||
|
[app("spark", { category: "all", currentStatus: "installed" })],
|
||||||
|
{ spark: true, apm: true },
|
||||||
|
"both",
|
||||||
|
)[0];
|
||||||
|
expect(resolved.status).toBe("installed");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { requestFlarumToken } from "@/modules/flarumAuth";
|
||||||
|
|
||||||
|
vi.mock("axios", () => ({
|
||||||
|
default: {
|
||||||
|
post: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("requestFlarumToken", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(window.ipcRenderer.invoke).mockReset();
|
||||||
|
vi.mocked(axios.post).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requests the Flarum token through main-process IPC", async () => {
|
||||||
|
vi.mocked(window.ipcRenderer.invoke).mockResolvedValue({
|
||||||
|
token: "forum-token",
|
||||||
|
user_id: 42,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = { identification: "user@example.com", password: "secret" };
|
||||||
|
|
||||||
|
const token = await requestFlarumToken(payload);
|
||||||
|
|
||||||
|
expect(window.ipcRenderer.invoke).toHaveBeenCalledWith(
|
||||||
|
"request-flarum-token",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
expect(axios.post).not.toHaveBeenCalled();
|
||||||
|
expect(token).toEqual({ token: "forum-token", userId: "42" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed token responses from main-process IPC", async () => {
|
||||||
|
vi.mocked(window.ipcRenderer.invoke).mockResolvedValue({
|
||||||
|
token: "",
|
||||||
|
user_id: 42,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
requestFlarumToken({ identification: "momen", password: "secret" }),
|
||||||
|
).rejects.toThrow("论坛登录响应异常,请稍后重试。");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips Electron IPC wrapper text from known login errors", async () => {
|
||||||
|
vi.mocked(window.ipcRenderer.invoke).mockRejectedValue(
|
||||||
|
new Error(
|
||||||
|
"Error invoking remote method 'request-flarum-token': Error: 无法连接星火论坛,请检查网络后重试。",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
requestFlarumToken({ identification: "momen", password: "secret" }),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
message: "无法连接星火论坛,请检查网络后重试。",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -107,4 +107,44 @@ describe("processInstall queue forwarding", () => {
|
|||||||
expect.stringContaining('"id":5'),
|
expect.stringContaining('"id":5'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns queued download metadata for account records", async () => {
|
||||||
|
vi.doMock("axios", () => ({
|
||||||
|
default: {
|
||||||
|
create: vi.fn(() => ({
|
||||||
|
post: vi.fn(() => Promise.resolve({ data: { ok: true } })),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
Object.assign(window.ipcRenderer, {
|
||||||
|
on: vi.fn(),
|
||||||
|
send: vi.fn(),
|
||||||
|
invoke: vi.fn(() => Promise.resolve(true)),
|
||||||
|
});
|
||||||
|
window.apm_store.arch = "amd64";
|
||||||
|
const { handleInstall } = await import("@/modules/processInstall");
|
||||||
|
|
||||||
|
const result = await handleInstall({
|
||||||
|
name: "WPS",
|
||||||
|
pkgname: "wps",
|
||||||
|
version: "1.0.0",
|
||||||
|
filename: "wps_1.0.0_amd64.deb",
|
||||||
|
torrent_address: "",
|
||||||
|
author: "",
|
||||||
|
contributor: "",
|
||||||
|
website: "",
|
||||||
|
update: "",
|
||||||
|
size: "",
|
||||||
|
more: "",
|
||||||
|
tags: "",
|
||||||
|
img_urls: [],
|
||||||
|
icons: "",
|
||||||
|
category: "office",
|
||||||
|
origin: "apm",
|
||||||
|
currentStatus: "not-installed",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result?.pkgname).toBe("wps");
|
||||||
|
expect(result?.origin).toBe("apm");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<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-700 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-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
|
||||||
|
@click="emit('logout')"
|
||||||
|
>
|
||||||
|
<i class="fas fa-sign-out-alt"></i>
|
||||||
|
<span>退出登录</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const emit = 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.75rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #475569;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-menu-item:hover {
|
||||||
|
background: rgba(0, 113, 227, 0.06);
|
||||||
|
color: #0071e3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .quick-menu-item {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .quick-menu-item:hover {
|
||||||
|
background: rgba(64, 156, 255, 0.1);
|
||||||
|
color: #409cff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
<template>
|
||||||
|
<section
|
||||||
|
v-if="displayApp"
|
||||||
|
class="mx-auto max-w-6xl rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900 lg:p-6"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mb-5 inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white 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 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||||
|
aria-label="返回"
|
||||||
|
@click="emit('back')"
|
||||||
|
>
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
<span>返回</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="grid gap-6 lg:grid-cols-[18rem_minmax(0,1fr)]">
|
||||||
|
<aside class="space-y-5">
|
||||||
|
<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
|
||||||
|
:src="iconPath"
|
||||||
|
alt="icon"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1 class="mt-4 text-2xl font-bold text-slate-900 dark:text-white">
|
||||||
|
{{ displayApp.name }}
|
||||||
|
</h1>
|
||||||
|
<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/70 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 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="selectOrigin('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="selectOrigin('apm')"
|
||||||
|
>
|
||||||
|
APM
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
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="emit('install', displayApp)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-download text-xs"></i>
|
||||||
|
<span>{{ installButtonText }}</span>
|
||||||
|
</button>
|
||||||
|
<div v-else 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 dark:hover:bg-slate-700 dark:hover:text-rose-400"
|
||||||
|
@click="emit('remove', displayApp)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-trash text-xs"></i>
|
||||||
|
<span>卸载</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex w-full 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:bg-slate-50 hover:text-slate-900 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||||
|
@click="handleFavorite"
|
||||||
|
>
|
||||||
|
<i class="fas fa-star text-xs"></i>
|
||||||
|
<span>收藏</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl
|
||||||
|
class="space-y-2 border-t border-slate-200/60 pt-3 text-xs dark:border-slate-800/60"
|
||||||
|
>
|
||||||
|
<div v-if="displayApp.category" class="flex justify-between gap-3">
|
||||||
|
<dt class="text-slate-400">分类</dt>
|
||||||
|
<dd class="truncate text-slate-700 dark:text-slate-300">
|
||||||
|
{{ displayApp.category }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="displayApp.author" class="flex justify-between gap-3">
|
||||||
|
<dt class="text-slate-400">作者</dt>
|
||||||
|
<dd class="truncate text-slate-700 dark:text-slate-300">
|
||||||
|
{{ displayApp.author }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="displayApp.size" class="flex justify-between gap-3">
|
||||||
|
<dt class="text-slate-400">大小</dt>
|
||||||
|
<dd class="text-slate-700 dark:text-slate-300">
|
||||||
|
{{ displayApp.size }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="displayApp.update" class="flex justify-between gap-3">
|
||||||
|
<dt class="text-slate-400">更新</dt>
|
||||||
|
<dd class="text-slate-700 dark:text-slate-300">
|
||||||
|
{{ displayApp.update }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="min-w-0 space-y-5">
|
||||||
|
<div
|
||||||
|
class="rounded-2xl border border-slate-200/60 bg-slate-50/50 p-5 dark:border-slate-800/60 dark:bg-slate-800/30"
|
||||||
|
>
|
||||||
|
<h2 class="mb-3 flex items-center gap-2 text-base font-semibold">
|
||||||
|
<i class="fas fa-info-circle text-slate-400"></i>
|
||||||
|
应用详情
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
v-if="displayApp.more.trim() !== ''"
|
||||||
|
class="space-y-2 text-sm leading-relaxed text-slate-600 dark:text-slate-300"
|
||||||
|
v-html="detailHtml"
|
||||||
|
></div>
|
||||||
|
<p v-else class="text-sm text-slate-400">暂无应用详情</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="rounded-2xl border border-slate-200/60 bg-slate-50/50 p-5 dark:border-slate-800/60 dark:bg-slate-800/30"
|
||||||
|
>
|
||||||
|
<h2 class="mb-3 flex items-center gap-2 text-base font-semibold">
|
||||||
|
<i class="fas fa-images text-slate-400"></i>
|
||||||
|
应用截图
|
||||||
|
</h2>
|
||||||
|
<div v-if="screenshots.length" class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<img
|
||||||
|
v-for="(screen, index) in screenshots"
|
||||||
|
:key="screen"
|
||||||
|
:src="screen"
|
||||||
|
alt="screenshot"
|
||||||
|
class="h-44 w-full cursor-pointer rounded-2xl border border-slate-200/60 object-cover shadow-sm transition hover:-translate-y-1 hover:shadow-lg dark:border-slate-800/60"
|
||||||
|
loading="lazy"
|
||||||
|
@click="emit('open-preview', index)"
|
||||||
|
@error="hideImage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-sm text-slate-400">暂无应用截图</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ReviewsPanel
|
||||||
|
v-if="loggedIn && reviewAppKey && reviewTags"
|
||||||
|
:app-key="reviewAppKey"
|
||||||
|
:tags="reviewTags"
|
||||||
|
:logged-in="loggedIn"
|
||||||
|
@request-login="$emit('request-login', $event)"
|
||||||
|
/>
|
||||||
|
<section
|
||||||
|
v-else-if="reviewAppKey && reviewTags"
|
||||||
|
class="rounded-2xl border border-slate-200/60 bg-slate-50/50 p-5 dark:border-slate-800/60 dark:bg-slate-800/30"
|
||||||
|
>
|
||||||
|
<h2 class="mb-2 flex items-center gap-2 text-base font-semibold">
|
||||||
|
<i class="fas fa-comments text-slate-400"></i>
|
||||||
|
应用评价
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
登录星火账号后可查看评价并发表评论。
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-4 inline-flex items-center rounded-xl bg-slate-800 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
|
||||||
|
@click="emit('request-login', '登录后查看和发表评论。')"
|
||||||
|
>
|
||||||
|
登录后查看评价
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import ReviewsPanel from "@/components/ReviewsPanel.vue";
|
||||||
|
import {
|
||||||
|
APM_STORE_BASE_URL,
|
||||||
|
getHybridDefaultOrigin,
|
||||||
|
} from "@/global/storeConfig";
|
||||||
|
import {
|
||||||
|
buildReviewAppKey,
|
||||||
|
buildReviewTags,
|
||||||
|
getDisplayApp,
|
||||||
|
} from "@/modules/appIdentity";
|
||||||
|
import type { App, ReviewTags } from "@/global/typedefinition";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
app: App;
|
||||||
|
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">(
|
||||||
|
props.app.viewingOrigin ?? props.app.origin,
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.app,
|
||||||
|
(app) => {
|
||||||
|
if (app.isMerged) {
|
||||||
|
viewingOrigin.value =
|
||||||
|
app.viewingOrigin ??
|
||||||
|
(app.sparkApp ? getHybridDefaultOrigin(app.sparkApp) : "apm");
|
||||||
|
} else {
|
||||||
|
viewingOrigin.value = app.origin;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const appWithSelectedOrigin = computed<App>(() => ({
|
||||||
|
...props.app,
|
||||||
|
viewingOrigin: viewingOrigin.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const displayApp = computed(() => getDisplayApp(appWithSelectedOrigin.value));
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => displayApp.value,
|
||||||
|
(app) => {
|
||||||
|
if (app) emit("check-install", app);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const isInstalled = computed(() =>
|
||||||
|
viewingOrigin.value === "spark" ? props.sparkInstalled : props.apmInstalled,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isOtherVersionInstalled = computed(() =>
|
||||||
|
viewingOrigin.value === "spark" ? props.apmInstalled : props.sparkInstalled,
|
||||||
|
);
|
||||||
|
|
||||||
|
const installButtonText = computed(() => {
|
||||||
|
if (isOtherVersionInstalled.value) {
|
||||||
|
return viewingOrigin.value === "spark"
|
||||||
|
? "已安装 APM 版"
|
||||||
|
: "已安装 Spark 版";
|
||||||
|
}
|
||||||
|
return "安装";
|
||||||
|
});
|
||||||
|
|
||||||
|
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 detailHtml = computed(
|
||||||
|
() => displayApp.value?.more.replace(/\n/g, "<br>") ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
|
const reviewAppKey = computed(() => {
|
||||||
|
if (!displayApp.value) return "";
|
||||||
|
return buildReviewAppKey(
|
||||||
|
displayApp.value,
|
||||||
|
props.reviewTags?.clientArch ?? "amd64",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const reviewTags = computed<ReviewTags | null>(() => {
|
||||||
|
if (!displayApp.value || !props.reviewTags) return null;
|
||||||
|
return buildReviewTags(displayApp.value, {
|
||||||
|
clientArch: props.reviewTags.clientArch,
|
||||||
|
distro: props.reviewTags.distro,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectOrigin = (origin: "spark" | "apm") => {
|
||||||
|
viewingOrigin.value = origin;
|
||||||
|
if (displayApp.value) emit("check-install", displayApp.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<template>
|
||||||
|
<Transition
|
||||||
|
enter-active-class="duration-200 ease-out"
|
||||||
|
enter-from-class="opacity-0 scale-95"
|
||||||
|
enter-to-class="opacity-100 scale-100"
|
||||||
|
leave-active-class="duration-150 ease-in"
|
||||||
|
leave-from-class="opacity-100 scale-100"
|
||||||
|
leave-to-class="opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="show"
|
||||||
|
class="fixed inset-0 z-50 flex items-start justify-center bg-slate-900/70 px-4 py-10"
|
||||||
|
@click.self="emit('close')"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex w-full max-w-3xl max-h-[85vh] flex-col rounded-3xl border border-white/10 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-start justify-between border-b border-slate-200/70 p-6 dark:border-slate-800/70"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p class="text-2xl font-semibold text-slate-900 dark:text-white">
|
||||||
|
从账号恢复
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
选择云端已同步的应用加入安装队列
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200/70 text-slate-500 transition hover:text-slate-900 dark:border-slate-700 dark:hover:bg-slate-800 dark:hover:text-white"
|
||||||
|
aria-label="关闭"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
<i class="fas fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto p-6">
|
||||||
|
<div
|
||||||
|
v-if="loading"
|
||||||
|
class="rounded-2xl border border-dashed border-slate-200/80 px-4 py-10 text-center text-slate-500 dark:border-slate-800/80 dark:text-slate-400"
|
||||||
|
>
|
||||||
|
正在读取云端应用列表…
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="error"
|
||||||
|
class="rounded-2xl border border-rose-200/70 bg-rose-50/60 px-4 py-6 text-center text-sm text-rose-600 dark:border-rose-500/40 dark:bg-rose-500/10"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="items.length === 0"
|
||||||
|
class="rounded-2xl border border-slate-200/70 px-4 py-10 text-center text-slate-500 dark:border-slate-800/70 dark:text-slate-400"
|
||||||
|
>
|
||||||
|
云端暂无已同步应用
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<label
|
||||||
|
v-for="item in items"
|
||||||
|
:key="cloudItemKey(item)"
|
||||||
|
class="flex items-center gap-3 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-sm dark:border-slate-800/70 dark:bg-slate-900/70"
|
||||||
|
:class="isInstalled(item) ? 'opacity-60' : 'cursor-pointer'"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 rounded border-slate-300 text-brand focus:ring-brand"
|
||||||
|
:aria-label="item.appName || item.pkgname"
|
||||||
|
:checked="selectedKeys.has(cloudItemKey(item))"
|
||||||
|
:disabled="isInstalled(item)"
|
||||||
|
@change="toggleSelection(item)"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-if="item.iconUrl"
|
||||||
|
:src="item.iconUrl"
|
||||||
|
class="h-10 w-10 rounded-xl object-contain"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex h-10 w-10 items-center justify-center rounded-xl bg-slate-100 text-slate-400 dark:bg-slate-800"
|
||||||
|
>
|
||||||
|
<i class="fas fa-cube"></i>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="font-semibold text-slate-900 dark:text-white">
|
||||||
|
{{ item.appName || item.pkgname }}
|
||||||
|
</p>
|
||||||
|
<p class="truncate text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{{ item.origin }} · {{ item.category }} · {{ item.pkgname }} ·
|
||||||
|
{{ item.version }} · {{ item.packageArch }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
v-if="isInstalled(item)"
|
||||||
|
class="rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300"
|
||||||
|
>
|
||||||
|
已安装
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-end gap-3 border-t border-slate-200/70 p-6 dark:border-slate-800/70"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="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="emit('close')"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-2xl bg-brand px-5 py-2 text-sm font-semibold text-white transition hover:bg-brand/90 disabled:opacity-40"
|
||||||
|
:disabled="selectedItems.length === 0"
|
||||||
|
@click="emit('install-selected', selectedItems)"
|
||||||
|
>
|
||||||
|
加入安装队列
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
|
||||||
|
import type { SyncedAppListItem } from "@/global/typedefinition";
|
||||||
|
import { cloudItemKey } from "@/modules/appListSync";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
show: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
|
items: SyncedAppListItem[];
|
||||||
|
installedKeys: Set<string>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "close"): void;
|
||||||
|
(e: "install-selected", items: SyncedAppListItem[]): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const selectedKeys = ref<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const isInstalled = (item: SyncedAppListItem): boolean =>
|
||||||
|
props.installedKeys.has(cloudItemKey(item));
|
||||||
|
|
||||||
|
const selectedItems = computed(() =>
|
||||||
|
props.items.filter(
|
||||||
|
(item) => selectedKeys.value.has(cloudItemKey(item)) && !isInstalled(item),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const pruneSelectedKeys = (): void => {
|
||||||
|
selectedKeys.value = new Set(
|
||||||
|
[...selectedKeys.value].filter((key) => !props.installedKeys.has(key)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelection = (item: SyncedAppListItem): void => {
|
||||||
|
if (isInstalled(item)) return;
|
||||||
|
const key = cloudItemKey(item);
|
||||||
|
const next = new Set(selectedKeys.value);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
selectedKeys.value = next;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.show, props.items] as const,
|
||||||
|
() => {
|
||||||
|
selectedKeys.value = new Set();
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.installedKeys,
|
||||||
|
() => {
|
||||||
|
pruneSelectedKeys();
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
</script>
|
||||||
@@ -1,21 +1,44 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full flex-col gap-6">
|
<div class="flex h-full flex-col gap-6">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div class="flex items-center gap-3">
|
<div class="relative min-w-0 flex-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full min-w-0 items-center gap-3 rounded-2xl p-1 text-left transition hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||||
|
:aria-label="accountLabel"
|
||||||
|
@click="handleAccountClick"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
|
v-if="!currentUser || !currentUser.avatarUrl"
|
||||||
:src="amberLogo"
|
:src="amberLogo"
|
||||||
alt="Amber PM"
|
alt="Amber PM"
|
||||||
class="h-11 w-11 rounded-2xl bg-white/70 p-2 shadow-sm ring-1 ring-slate-900/5 dark:bg-slate-800"
|
class="h-11 w-11 rounded-2xl bg-white/70 p-2 shadow-sm ring-1 ring-slate-900/5 dark:bg-slate-800"
|
||||||
/>
|
/>
|
||||||
|
<img
|
||||||
|
v-else
|
||||||
|
:src="currentUser.avatarUrl"
|
||||||
|
:alt="accountLabel"
|
||||||
|
class="h-11 w-11 rounded-2xl object-cover shadow-sm ring-1 ring-slate-900/5"
|
||||||
|
/>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<span
|
<span
|
||||||
class="text-xs uppercase tracking-[0.3em] text-slate-500 dark:text-slate-400"
|
class="text-xs uppercase tracking-[0.3em] text-slate-500 dark:text-slate-400"
|
||||||
>Spark Store</span
|
>{{ currentUser ? currentUser.forumLevel : "Spark Store" }}</span
|
||||||
>
|
>
|
||||||
<span class="text-lg font-semibold text-slate-900 dark:text-white"
|
<span
|
||||||
>星火应用商店</span
|
class="text-lg font-semibold text-slate-900 dark:text-white"
|
||||||
|
>{{ accountLabel }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<ThemeToggle :theme-mode="themeMode" @toggle="toggleTheme" />
|
<ThemeToggle :theme-mode="themeMode" @toggle="toggleTheme" />
|
||||||
@@ -105,10 +128,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed, ref } from "vue";
|
||||||
|
import AccountQuickMenu from "./AccountQuickMenu.vue";
|
||||||
import ThemeToggle from "./ThemeToggle.vue";
|
import ThemeToggle from "./ThemeToggle.vue";
|
||||||
import amberLogo from "../assets/imgs/spark-store.svg";
|
import amberLogo from "../assets/imgs/spark-store.svg";
|
||||||
import type { SidebarEntry } from "../global/typedefinition";
|
import type { SidebarEntry, SparkUser } from "../global/typedefinition";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
activeTab: string;
|
activeTab: string;
|
||||||
@@ -119,6 +143,7 @@ const props = defineProps<{
|
|||||||
storeFilter: "spark" | "apm" | "both";
|
storeFilter: "spark" | "apm" | "both";
|
||||||
sidebarEntries: SidebarEntry[];
|
sidebarEntries: SidebarEntry[];
|
||||||
entryCounts: Record<string, number>;
|
entryCounts: Record<string, number>;
|
||||||
|
currentUser: SparkUser | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -127,8 +152,31 @@ const emit = defineEmits<{
|
|||||||
(e: "close"): void;
|
(e: "close"): void;
|
||||||
(e: "list"): void;
|
(e: "list"): void;
|
||||||
(e: "update"): void;
|
(e: "update"): void;
|
||||||
|
(e: "request-login"): void;
|
||||||
|
(e: "open-user-management"): void;
|
||||||
|
(e: "open-favorites"): void;
|
||||||
|
(e: "open-forum"): void;
|
||||||
|
(e: "edit-profile"): void;
|
||||||
|
(e: "logout"): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const showAccountMenu = ref(false);
|
||||||
|
|
||||||
|
const accountLabel = computed(() => {
|
||||||
|
return props.currentUser
|
||||||
|
? props.currentUser.displayName || props.currentUser.username
|
||||||
|
: "登录 / 注册";
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAccountClick = () => {
|
||||||
|
if (!props.currentUser) {
|
||||||
|
emit("request-login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showAccountMenu.value = !showAccountMenu.value;
|
||||||
|
};
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
emit("toggle-theme");
|
emit("toggle-theme");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<template>
|
||||||
|
<section
|
||||||
|
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 sm:justify-between"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold text-slate-900 dark:text-white">
|
||||||
|
我的收藏
|
||||||
|
</h1>
|
||||||
|
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
管理收藏夹中的应用,已下架或不可用项目会保留显示。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||||
|
@click="emit('create-folder')"
|
||||||
|
>
|
||||||
|
新建收藏夹
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="folder in folders"
|
||||||
|
:key="folder.id"
|
||||||
|
type="button"
|
||||||
|
class="rounded-full px-4 py-2 text-sm font-medium transition"
|
||||||
|
:class="
|
||||||
|
folder.id === activeFolderId
|
||||||
|
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900'
|
||||||
|
: 'bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700'
|
||||||
|
"
|
||||||
|
@click="emit('select-folder', folder.id)"
|
||||||
|
>
|
||||||
|
{{ folder.name }} ({{ folder.itemCount }})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="mt-6 text-sm text-slate-500">加载中...</div>
|
||||||
|
<div v-else-if="error" class="mt-6 text-sm text-rose-500">{{ error }}</div>
|
||||||
|
<div v-else class="mt-6 space-y-3">
|
||||||
|
<div
|
||||||
|
v-if="items.length === 0"
|
||||||
|
class="rounded-2xl border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400"
|
||||||
|
>
|
||||||
|
当前收藏夹暂无应用。
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
v-for="resolved in items"
|
||||||
|
:key="resolved.item.id"
|
||||||
|
class="flex items-center gap-3 rounded-2xl border border-slate-200 p-4 transition hover:bg-slate-50 dark:border-slate-800 dark:hover:bg-slate-800/60"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="selectedIds"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 rounded border-slate-300"
|
||||||
|
:value="resolved.item.id"
|
||||||
|
:aria-label="`选择 ${resolved.item.name || resolved.item.pkgname}`"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-if="resolved.item.iconUrl"
|
||||||
|
:src="resolved.item.iconUrl"
|
||||||
|
alt=""
|
||||||
|
class="h-10 w-10 rounded-xl object-cover"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex h-10 w-10 items-center justify-center rounded-xl bg-slate-100 text-sm font-semibold text-slate-500 dark:bg-slate-800"
|
||||||
|
>
|
||||||
|
{{ (resolved.item.name || resolved.item.pkgname).slice(0, 1) }}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="truncate font-medium text-slate-900 dark:text-white">
|
||||||
|
{{ resolved.item.name || resolved.item.pkgname }}
|
||||||
|
</p>
|
||||||
|
<p class="truncate text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{{ resolved.item.pkgname }} · {{ resolved.item.category }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="rounded-full px-3 py-1 text-xs font-medium"
|
||||||
|
:class="statusClass(resolved.status)"
|
||||||
|
>
|
||||||
|
{{ resolved.reason }}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-50 disabled:opacity-40 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||||
|
@click="selectInstallable"
|
||||||
|
>
|
||||||
|
选择可安装
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700 disabled:opacity-40 dark:bg-slate-100 dark:text-slate-900"
|
||||||
|
:disabled="selectedInstallableItems.length === 0"
|
||||||
|
@click="emit('install-selected', selectedInstallableItems)"
|
||||||
|
>
|
||||||
|
加入安装队列
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-xl border border-rose-300 px-4 py-2 text-sm font-medium text-rose-600 transition hover:bg-rose-50 disabled:opacity-40 dark:border-rose-900/70 dark:hover:bg-rose-950/30"
|
||||||
|
:disabled="selectedIds.length === 0"
|
||||||
|
@click="emit('remove-selected', [...selectedIds])"
|
||||||
|
>
|
||||||
|
移除选中
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import type {
|
||||||
|
FavoriteAvailabilityStatus,
|
||||||
|
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 selectedIds = ref<number[]>([]);
|
||||||
|
|
||||||
|
const selectedInstallableItems = computed(() =>
|
||||||
|
props.items.filter(
|
||||||
|
(item) =>
|
||||||
|
selectedIds.value.includes(item.item.id) && item.status === "installable",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.items,
|
||||||
|
() => {
|
||||||
|
const visibleIds = new Set(props.items.map((item) => item.item.id));
|
||||||
|
selectedIds.value = selectedIds.value.filter((id) => visibleIds.has(id));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectInstallable = () => {
|
||||||
|
selectedIds.value = props.items
|
||||||
|
.filter((item) => item.status === "installable")
|
||||||
|
.map((item) => item.item.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusClass = (status: FavoriteAvailabilityStatus): string => {
|
||||||
|
if (status === "installable") {
|
||||||
|
return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300";
|
||||||
|
}
|
||||||
|
if (status === "installed") {
|
||||||
|
return "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300";
|
||||||
|
}
|
||||||
|
return "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300";
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="show"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
|
>
|
||||||
|
<div class="absolute inset-0 bg-black/40" @click="emit('close')"></div>
|
||||||
|
<section
|
||||||
|
class="relative z-10 w-full max-w-sm rounded-3xl border border-slate-200 bg-white p-6 shadow-xl dark:border-slate-800 dark:bg-slate-900"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="选择收藏夹"
|
||||||
|
>
|
||||||
|
<h2 class="text-lg font-semibold text-slate-900 dark:text-white">
|
||||||
|
添加到收藏夹
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
选择要保存当前应用的收藏夹。
|
||||||
|
</p>
|
||||||
|
<div class="mt-5 space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-left text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||||
|
@click="emit('select-folder', 'default')"
|
||||||
|
>
|
||||||
|
默认收藏夹
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="folder in folders"
|
||||||
|
:key="folder.id"
|
||||||
|
type="button"
|
||||||
|
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-left text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||||
|
@click="emit('select-folder', folder.id)"
|
||||||
|
>
|
||||||
|
{{ folder.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-5 w-full rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { FavoriteFolder } from "@/global/typedefinition";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
show: boolean;
|
||||||
|
folders: FavoriteFolder[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: [];
|
||||||
|
"select-folder": [folderId: number | "default"];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
@@ -28,6 +28,23 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
|
<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="handleSyncClick"
|
||||||
|
>
|
||||||
|
<i class="fas fa-cloud-arrow-up"></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="handleRestoreClick"
|
||||||
|
>
|
||||||
|
<i class="fas fa-cloud-arrow-down"></i>
|
||||||
|
从账号恢复
|
||||||
|
</button>
|
||||||
<div
|
<div
|
||||||
v-if="showOriginSwitcher"
|
v-if="showOriginSwitcher"
|
||||||
class="flex items-center rounded-2xl border border-slate-200/70 p-1 dark:border-slate-800/70"
|
class="flex items-center rounded-2xl border border-slate-200/70 p-1 dark:border-slate-800/70"
|
||||||
@@ -220,17 +237,40 @@ const props = defineProps<{
|
|||||||
storeFilter: "spark" | "apm" | "both";
|
storeFilter: "spark" | "apm" | "both";
|
||||||
sparkAvailable: boolean;
|
sparkAvailable: boolean;
|
||||||
apmAvailable: boolean;
|
apmAvailable: boolean;
|
||||||
|
loggedIn: boolean;
|
||||||
|
syncing: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "close"): void;
|
(e: "close"): void;
|
||||||
(e: "refresh"): void;
|
(e: "refresh"): void;
|
||||||
(e: "uninstall", app: App): void;
|
(e: "uninstall", app: App): void;
|
||||||
(e: "switch-origin", origin: "apm" | "spark"): void;
|
(e: "switch-origin", origin: "apm" | "spark"): void;
|
||||||
(e: "open-app", app: App): void;
|
(e: "open-app", app: App): void;
|
||||||
(e: "open-detail", app: App): void;
|
(e: "open-detail", app: App): void;
|
||||||
|
(e: "sync-to-account"): void;
|
||||||
|
(e: "restore-from-account"): void;
|
||||||
|
(e: "request-login"): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const handleSyncClick = () => {
|
||||||
|
if (props.loggedIn) {
|
||||||
|
emit("sync-to-account");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("request-login");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestoreClick = () => {
|
||||||
|
if (props.loggedIn) {
|
||||||
|
emit("restore-from-account");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("request-login");
|
||||||
|
};
|
||||||
|
|
||||||
const onOverlayWheel = (e: WheelEvent) => {
|
const onOverlayWheel = (e: WheelEvent) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.closest(".overflow-y-auto, .overflow-auto")) return;
|
if (target.closest(".overflow-y-auto, .overflow-auto")) return;
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition duration-200 ease-out"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
enter-to-class="opacity-100"
|
||||||
|
leave-active-class="transition 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-black/50 p-4 backdrop-blur-sm"
|
||||||
|
@click.self="emit('close')"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
class="w-full max-w-md rounded-3xl border border-slate-200 bg-white p-6 shadow-2xl dark:border-slate-700 dark:bg-slate-900"
|
||||||
|
@submit.prevent="submitLogin"
|
||||||
|
>
|
||||||
|
<div class="mb-6">
|
||||||
|
<h2 class="text-2xl font-bold text-slate-900 dark:text-white">
|
||||||
|
登录星火账号
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
class="mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400"
|
||||||
|
>
|
||||||
|
使用论坛账号登录。密码仅直接提交到星火论坛用于换取论坛令牌,不会发送给商店后端。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="mb-4 block">
|
||||||
|
<span
|
||||||
|
class="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-200"
|
||||||
|
>
|
||||||
|
论坛账号
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
v-model="identification"
|
||||||
|
type="text"
|
||||||
|
autocomplete="username"
|
||||||
|
class="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-slate-900 outline-none transition focus:border-brand focus:ring-2 focus:ring-brand/20 dark:border-slate-700 dark:bg-slate-800 dark:text-white"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="mb-4 block">
|
||||||
|
<span
|
||||||
|
class="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-200"
|
||||||
|
>
|
||||||
|
论坛密码
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-slate-900 outline-none transition focus:border-brand focus:ring-2 focus:ring-brand/20 dark:border-slate-700 dark:bg-slate-800 dark:text-white"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p v-if="error" class="mb-4 text-sm text-red-600 dark:text-red-400">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-xl px-4 py-2 text-sm font-medium text-slate-500 transition hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||||
|
@click="emit('register')"
|
||||||
|
>
|
||||||
|
注册账号
|
||||||
|
</button>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-xl bg-brand px-5 py-2 text-sm font-semibold text-white transition hover:bg-brand/90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
:disabled="loading"
|
||||||
|
>
|
||||||
|
{{ loading ? "登录中..." : "登录" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
import type { FlarumLoginPayload } from "@/global/typedefinition";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
show: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: [];
|
||||||
|
login: [payload: FlarumLoginPayload];
|
||||||
|
register: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const identification = ref("");
|
||||||
|
const password = ref("");
|
||||||
|
|
||||||
|
const submitLogin = () => {
|
||||||
|
emit("login", {
|
||||||
|
identification: identification.value.trim(),
|
||||||
|
password: password.value.trim(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition duration-200 ease-out"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
enter-to-class="opacity-100"
|
||||||
|
leave-active-class="transition 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-black/50 p-4 backdrop-blur-sm"
|
||||||
|
@click.self="emit('close')"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-full max-w-sm rounded-3xl border border-slate-200 bg-white p-6 shadow-2xl dark:border-slate-700 dark:bg-slate-900"
|
||||||
|
>
|
||||||
|
<h2 class="text-xl font-bold text-slate-900 dark:text-white">
|
||||||
|
需要登录
|
||||||
|
</h2>
|
||||||
|
<p class="mt-3 text-sm leading-6 text-slate-500 dark:text-slate-400">
|
||||||
|
{{ message }}
|
||||||
|
</p>
|
||||||
|
<div class="mt-6 flex items-center justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-xl px-4 py-2 text-sm font-medium text-slate-500 transition hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||||
|
@click="emit('register')"
|
||||||
|
>
|
||||||
|
注册账号
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-xl bg-brand px-5 py-2 text-sm font-semibold text-white transition hover:bg-brand/90"
|
||||||
|
@click="emit('login')"
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
show: boolean;
|
||||||
|
message: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: [];
|
||||||
|
login: [];
|
||||||
|
register: [];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
<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="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<h2 class="flex items-center gap-2 text-base font-semibold">
|
||||||
|
<i class="fas fa-comments text-slate-400"></i>
|
||||||
|
应用评价
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{{ ratingText }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="mb-4 grid gap-2 text-xs text-slate-500 sm:grid-cols-2">
|
||||||
|
<div
|
||||||
|
class="flex justify-between gap-3 rounded-xl bg-white px-3 py-2 dark:bg-slate-900/60"
|
||||||
|
>
|
||||||
|
<dt>版本</dt>
|
||||||
|
<dd class="font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
{{ tags.version }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex justify-between gap-3 rounded-xl bg-white px-3 py-2 dark:bg-slate-900/60"
|
||||||
|
>
|
||||||
|
<dt>发行版</dt>
|
||||||
|
<dd class="font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
{{ tags.distro }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex justify-between gap-3 rounded-xl bg-white px-3 py-2 dark:bg-slate-900/60"
|
||||||
|
>
|
||||||
|
<dt>架构</dt>
|
||||||
|
<dd class="font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
{{ tags.packageArch }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex justify-between gap-3 rounded-xl bg-white px-3 py-2 dark:bg-slate-900/60"
|
||||||
|
>
|
||||||
|
<dt>来源</dt>
|
||||||
|
<dd class="font-medium uppercase text-slate-700 dark:text-slate-300">
|
||||||
|
{{ tags.origin }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="!loggedIn"
|
||||||
|
type="button"
|
||||||
|
class="mb-4 inline-flex items-center rounded-xl bg-slate-800 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
|
||||||
|
@click="emit('request-login', '登录后发表评论')"
|
||||||
|
>
|
||||||
|
登录后发表评论
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<form v-else class="mb-4 space-y-3" @submit.prevent="submit">
|
||||||
|
<label
|
||||||
|
class="block text-sm font-medium text-slate-600 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
评分
|
||||||
|
<select
|
||||||
|
v-model.number="rating"
|
||||||
|
class="mt-1 block w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
|
||||||
|
>
|
||||||
|
<option v-for="value in ratingOptions" :key="value" :value="value">
|
||||||
|
{{ value }} 星
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
class="block text-sm font-medium text-slate-600 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
评论
|
||||||
|
<textarea
|
||||||
|
v-model="content"
|
||||||
|
rows="3"
|
||||||
|
class="mt-1 block w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
|
||||||
|
placeholder="分享你的使用体验"
|
||||||
|
></textarea>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex items-center rounded-xl bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-500 disabled:opacity-50"
|
||||||
|
:disabled="submitting"
|
||||||
|
>
|
||||||
|
发表评论
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p v-if="loading" class="text-sm text-slate-400">正在加载评价...</p>
|
||||||
|
<p v-else-if="error" class="text-sm text-rose-500">{{ error }}</p>
|
||||||
|
<div v-else-if="reviews.length" class="space-y-3">
|
||||||
|
<article
|
||||||
|
v-for="review in reviews"
|
||||||
|
:key="review.id"
|
||||||
|
class="rounded-xl bg-white p-3 text-sm dark:bg-slate-900/60"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<strong class="text-slate-700 dark:text-slate-200">
|
||||||
|
{{ review.userDisplayName || "星火用户" }}
|
||||||
|
</strong>
|
||||||
|
<span class="text-xs text-slate-400">{{ review.rating }} 星</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 whitespace-pre-wrap text-slate-600 dark:text-slate-300">
|
||||||
|
{{ review.content || "暂无评论内容" }}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-sm text-slate-400">暂无评价</p>
|
||||||
|
</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;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"request-login": [message: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const ratingOptions = [5, 4, 3, 2, 1];
|
||||||
|
const rating = ref(5);
|
||||||
|
const content = ref("");
|
||||||
|
const reviews = ref<AppReview[]>([]);
|
||||||
|
const summary = ref<RatingSummary | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const submitting = ref(false);
|
||||||
|
const error = ref("");
|
||||||
|
const loadGeneration = ref(0);
|
||||||
|
|
||||||
|
const ratingText = computed(() => {
|
||||||
|
if (!summary.value || summary.value.reviewCount === 0) return "暂无评分";
|
||||||
|
return `${summary.value.averageRating.toFixed(1)} / 5 (${summary.value.reviewCount})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearReviewState = () => {
|
||||||
|
loadGeneration.value += 1;
|
||||||
|
reviews.value = [];
|
||||||
|
summary.value = null;
|
||||||
|
loading.value = false;
|
||||||
|
error.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadReviews = async () => {
|
||||||
|
if (!props.loggedIn || !props.appKey) {
|
||||||
|
clearReviewState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const generation = loadGeneration.value + 1;
|
||||||
|
loadGeneration.value = generation;
|
||||||
|
const appKey = props.appKey;
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
const [nextSummary, nextReviews] = await Promise.all([
|
||||||
|
fetchRatingSummary(appKey),
|
||||||
|
fetchReviews(appKey),
|
||||||
|
]);
|
||||||
|
if (generation !== loadGeneration.value || appKey !== props.appKey) return;
|
||||||
|
summary.value = nextSummary;
|
||||||
|
reviews.value = nextReviews;
|
||||||
|
} catch (caught: unknown) {
|
||||||
|
if (generation !== loadGeneration.value || appKey !== props.appKey) return;
|
||||||
|
error.value = (caught as Error)?.message || "加载评价失败";
|
||||||
|
} finally {
|
||||||
|
if (generation === loadGeneration.value && appKey === props.appKey) {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
const appKey = props.appKey;
|
||||||
|
const tags = props.tags;
|
||||||
|
submitting.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
await submitReview(appKey, {
|
||||||
|
rating: rating.value,
|
||||||
|
content: content.value.trim(),
|
||||||
|
tags,
|
||||||
|
});
|
||||||
|
if (appKey !== props.appKey) return;
|
||||||
|
content.value = "";
|
||||||
|
await loadReviews();
|
||||||
|
} catch (caught: unknown) {
|
||||||
|
if (appKey !== props.appKey) return;
|
||||||
|
error.value = (caught as Error)?.message || "发表评论失败";
|
||||||
|
} finally {
|
||||||
|
if (appKey === props.appKey) {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(loadReviews);
|
||||||
|
watch(() => [props.appKey, props.loggedIn], loadReviews);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<template>
|
||||||
|
<section
|
||||||
|
class="space-y-6 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-5 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<img
|
||||||
|
v-if="user.avatarUrl"
|
||||||
|
:src="user.avatarUrl"
|
||||||
|
:alt="user.displayName"
|
||||||
|
class="h-16 w-16 rounded-2xl border border-slate-200 object-cover dark:border-slate-700"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex h-16 w-16 items-center justify-center rounded-2xl bg-slate-100 text-2xl font-semibold text-slate-500 dark:bg-slate-800 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
{{ userInitial }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold text-slate-900 dark:text-white">
|
||||||
|
用户管理
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
class="mt-1 text-lg font-medium text-slate-800 dark:text-slate-100"
|
||||||
|
>
|
||||||
|
{{ user.displayName }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
@{{ user.username }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{{ user.forumLevel }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-sky-300 hover:text-sky-600 dark:border-slate-700 dark:text-slate-200 dark:hover:border-sky-500 dark:hover:text-sky-300"
|
||||||
|
@click="emit('open-forum')"
|
||||||
|
>
|
||||||
|
论坛首页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full bg-sky-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-sky-500"
|
||||||
|
@click="emit('edit-profile')"
|
||||||
|
>
|
||||||
|
修改论坛资料
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="visibleForumGroups.length > 0"
|
||||||
|
class="flex flex-wrap gap-2 text-sm text-slate-600 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="group in visibleForumGroups"
|
||||||
|
:key="group"
|
||||||
|
class="rounded-full bg-slate-100 px-3 py-1 dark:bg-slate-800"
|
||||||
|
>
|
||||||
|
{{ group }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-4 rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-950/40 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
class="flex items-center gap-3 text-sm font-medium text-slate-700 dark:text-slate-200"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
|
||||||
|
aria-label="自动同步已安装应用"
|
||||||
|
:checked="syncEnabled"
|
||||||
|
@change="handleSyncToggle"
|
||||||
|
/>
|
||||||
|
自动同步已安装应用
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-sky-300 hover:text-sky-600 dark:border-slate-700 dark:text-slate-200 dark:hover:border-sky-500 dark:hover:text-sky-300"
|
||||||
|
@click="emit('sync-now')"
|
||||||
|
>
|
||||||
|
立即同步
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-slate-900 dark:text-white">
|
||||||
|
下载历史
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
最近通过当前账号记录的应用安装历史。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-sky-300 hover:text-sky-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:text-slate-200 dark:hover:border-sky-500 dark:hover:text-sky-300"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="emit('refresh-downloads')"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="loading" class="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
正在加载下载历史...
|
||||||
|
</p>
|
||||||
|
<p v-else-if="error" class="text-sm text-red-600 dark:text-red-400">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else-if="downloadedApps.length === 0"
|
||||||
|
class="text-sm text-slate-500 dark:text-slate-400"
|
||||||
|
>
|
||||||
|
暂无下载记录。
|
||||||
|
</p>
|
||||||
|
<ul v-else class="divide-y divide-slate-200 dark:divide-slate-800">
|
||||||
|
<li
|
||||||
|
v-for="app in downloadedApps"
|
||||||
|
:key="app.id"
|
||||||
|
class="flex flex-col gap-1 py-4 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-slate-900 dark:text-white">
|
||||||
|
{{ app.name }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{{ app.pkgname }} · {{ app.category }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{{ app.selectedOrigin.toUpperCase() }} · {{ app.version }} ·
|
||||||
|
{{ app.packageArch }}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
import type { DownloadedAppRecord, SparkUser } from "@/global/typedefinition";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
user: SparkUser;
|
||||||
|
downloadedApps: DownloadedAppRecord[];
|
||||||
|
syncEnabled: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"open-forum": [];
|
||||||
|
"edit-profile": [];
|
||||||
|
"toggle-sync": [enabled: boolean];
|
||||||
|
"sync-now": [];
|
||||||
|
"refresh-downloads": [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const userInitial = computed(() =>
|
||||||
|
(props.user.displayName || props.user.username || "?").slice(0, 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
const visibleForumGroups = computed(() =>
|
||||||
|
props.user.forumGroups.filter((group) => group !== props.user.forumLevel),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSyncToggle = (event: Event): void => {
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof HTMLInputElement)) return;
|
||||||
|
emit("toggle-sync", target.checked);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { ref, watch } from "vue";
|
||||||
|
|
||||||
|
const INSTALLED_SYNC_STORAGE_KEY = "spark-store-installed-sync-enabled";
|
||||||
|
let activeSyncUserId: number | null = null;
|
||||||
|
|
||||||
|
const syncStorageKey = (userId: number | null): string =>
|
||||||
|
userId === null
|
||||||
|
? INSTALLED_SYNC_STORAGE_KEY
|
||||||
|
: `${INSTALLED_SYNC_STORAGE_KEY}:${userId}`;
|
||||||
|
|
||||||
|
const readSyncEnabled = (userId: number | null): boolean | null => {
|
||||||
|
const savedValue = localStorage.getItem(syncStorageKey(userId));
|
||||||
|
if (savedValue === "true") return true;
|
||||||
|
if (savedValue === "false") return false;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const installedSyncEnabled = ref<boolean | null>(
|
||||||
|
readSyncEnabled(activeSyncUserId),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const loadInstalledSyncPreference = (userId: number | null): void => {
|
||||||
|
activeSyncUserId = userId;
|
||||||
|
installedSyncEnabled.value = readSyncEnabled(userId);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setInstalledSyncEnabled = (enabled: boolean): void => {
|
||||||
|
installedSyncEnabled.value = enabled;
|
||||||
|
localStorage.setItem(syncStorageKey(activeSyncUserId), String(enabled));
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(installedSyncEnabled, (enabled) => {
|
||||||
|
if (enabled === null) {
|
||||||
|
localStorage.removeItem(syncStorageKey(activeSyncUserId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(syncStorageKey(activeSyncUserId), String(enabled));
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { computed, ref } from "vue";
|
||||||
|
|
||||||
|
import { setBackendToken } from "@/modules/backendApi";
|
||||||
|
import type { AuthSession, SparkUser } from "./typedefinition";
|
||||||
|
|
||||||
|
const AUTH_STORAGE_KEY = "spark-store-auth";
|
||||||
|
|
||||||
|
const isSparkUser = (value: unknown): value is SparkUser => {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||||
|
const user = value as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof user.id === "number" &&
|
||||||
|
typeof user.flarumUserId === "string" &&
|
||||||
|
typeof user.username === "string" &&
|
||||||
|
typeof user.displayName === "string" &&
|
||||||
|
typeof user.avatarUrl === "string" &&
|
||||||
|
typeof user.forumLevel === "string" &&
|
||||||
|
Array.isArray(user.forumGroups) &&
|
||||||
|
user.forumGroups.every((group) => typeof group === "string")
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAuthSession = (value: unknown): value is AuthSession => {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||||
|
const session = value as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof session.accessToken === "string" &&
|
||||||
|
session.accessToken.length > 0 &&
|
||||||
|
session.tokenType === "bearer" &&
|
||||||
|
isSparkUser(session.user)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadStoredSession = (): AuthSession | null => {
|
||||||
|
const raw = localStorage.getItem(AUTH_STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
return isAuthSession(parsed) ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const authSession = ref<AuthSession | null>(loadStoredSession());
|
||||||
|
export const currentUser = computed(() => authSession.value?.user ?? null);
|
||||||
|
export const isLoggedIn = computed(() => authSession.value !== null);
|
||||||
|
|
||||||
|
setBackendToken(authSession.value?.accessToken ?? null);
|
||||||
|
|
||||||
|
export const setAuthSession = (session: AuthSession): void => {
|
||||||
|
authSession.value = session;
|
||||||
|
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(session));
|
||||||
|
setBackendToken(session.accessToken);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const logout = (): void => {
|
||||||
|
authSession.value = null;
|
||||||
|
localStorage.removeItem(AUTH_STORAGE_KEY);
|
||||||
|
setBackendToken(null);
|
||||||
|
};
|
||||||
@@ -7,6 +7,17 @@ export const APM_STORE_BASE_URL: string =
|
|||||||
export const APM_STORE_STATS_BASE_URL: string =
|
export const APM_STORE_STATS_BASE_URL: string =
|
||||||
import.meta.env.VITE_APM_STORE_STATS_BASE_URL || "";
|
import.meta.env.VITE_APM_STORE_STATS_BASE_URL || "";
|
||||||
|
|
||||||
|
export const DEFAULT_SPARK_BACKEND_BASE_URL = "http://127.0.0.1:8000";
|
||||||
|
|
||||||
|
export const SPARK_BACKEND_BASE_URL: string =
|
||||||
|
import.meta.env.VITE_SPARK_BACKEND_BASE_URL ||
|
||||||
|
DEFAULT_SPARK_BACKEND_BASE_URL;
|
||||||
|
|
||||||
|
export const FLARUM_BASE_URL = "https://bbs.spark-app.store";
|
||||||
|
export const FLARUM_REGISTER_URL = `${FLARUM_BASE_URL}/register`;
|
||||||
|
export const FLARUM_PROFILE_URL = `${FLARUM_BASE_URL}/u`;
|
||||||
|
export const FLARUM_SETTINGS_URL = `${FLARUM_BASE_URL}/settings`;
|
||||||
|
|
||||||
// 下面的变量用于存储当前应用的信息,其实用在多个组件中
|
// 下面的变量用于存储当前应用的信息,其实用在多个组件中
|
||||||
export const currentApp = ref<App | null>(null);
|
export const currentApp = ref<App | null>(null);
|
||||||
export const currentAppSparkInstalled = ref(false);
|
export const currentAppSparkInstalled = ref(false);
|
||||||
|
|||||||
@@ -249,3 +249,130 @@ export interface SidebarEntry {
|
|||||||
type?: "category" | "search" | "link";
|
type?: "category" | "search" | "link";
|
||||||
value?: string;
|
value?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SparkUser {
|
||||||
|
id: number;
|
||||||
|
flarumUserId: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
forumLevel: string;
|
||||||
|
forumGroups: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthSession {
|
||||||
|
accessToken: string;
|
||||||
|
tokenType: "bearer";
|
||||||
|
user: SparkUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlarumLoginPayload {
|
||||||
|
identification: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewTags {
|
||||||
|
origin: "spark" | "apm";
|
||||||
|
category: string;
|
||||||
|
pkgname: string;
|
||||||
|
version: string;
|
||||||
|
packageArch: string;
|
||||||
|
clientArch: string;
|
||||||
|
distro: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RatingSummary {
|
||||||
|
averageRating: number;
|
||||||
|
reviewCount: number;
|
||||||
|
starCounts: Record<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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { App, ReviewTags } from "@/global/typedefinition";
|
||||||
|
|
||||||
|
export const parsePackageArch = (filename: string): string => {
|
||||||
|
const match = filename.match(/_([^_]+)\.deb$/);
|
||||||
|
return match?.[1] || "unknown";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildStoreArch = (
|
||||||
|
origin: "spark" | "apm",
|
||||||
|
clientArch: string,
|
||||||
|
): string => {
|
||||||
|
const rawArch = clientArch.replace(/-(store|apm)$/, "");
|
||||||
|
return `${rawArch}-${origin === "spark" ? "store" : "apm"}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildFavoriteAppKey = (app: App): string => {
|
||||||
|
return `app:${app.category || "unknown"}:${app.pkgname}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildReviewAppKey = (app: App, clientArch: string): string => {
|
||||||
|
return `${app.origin}:${buildStoreArch(app.origin, clientArch)}:${app.category || "unknown"}:${app.pkgname}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDisplayApp = (app: App | null): App | null => {
|
||||||
|
if (!app) return null;
|
||||||
|
if (!app.isMerged) return app;
|
||||||
|
if (app.viewingOrigin === "spark") return app.sparkApp ?? app.apmApp ?? app;
|
||||||
|
if (app.viewingOrigin === "apm") return app.apmApp ?? app.sparkApp ?? app;
|
||||||
|
return app.sparkApp ?? app.apmApp ?? app;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildReviewTags = (
|
||||||
|
app: App,
|
||||||
|
options: { clientArch: string; distro: string },
|
||||||
|
): ReviewTags => {
|
||||||
|
return {
|
||||||
|
origin: app.origin,
|
||||||
|
category: app.category || "unknown",
|
||||||
|
pkgname: app.pkgname,
|
||||||
|
version: app.version,
|
||||||
|
packageArch: parsePackageArch(app.filename),
|
||||||
|
clientArch: options.clientArch,
|
||||||
|
distro: options.distro,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { App, SyncedAppListItem } from "@/global/typedefinition";
|
||||||
|
import { parsePackageArch } from "@/modules/appIdentity";
|
||||||
|
|
||||||
|
const hasUsablePackageIdentity = (app: App): boolean => {
|
||||||
|
return app.pkgname.trim().length > 0 && Boolean(app.origin);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildSyncItems = (apps: App[]): SyncedAppListItem[] => {
|
||||||
|
return apps
|
||||||
|
.filter(
|
||||||
|
(app) =>
|
||||||
|
app.currentStatus === "installed" &&
|
||||||
|
app.category !== "unknown" &&
|
||||||
|
!app.isDependency &&
|
||||||
|
hasUsablePackageIdentity(app),
|
||||||
|
)
|
||||||
|
.map((app) => ({
|
||||||
|
pkgname: app.pkgname,
|
||||||
|
origin: app.origin,
|
||||||
|
category: app.category,
|
||||||
|
version: app.version,
|
||||||
|
packageArch: app.arch || parsePackageArch(app.filename),
|
||||||
|
appName: app.name || app.pkgname,
|
||||||
|
iconUrl: app.icons || "",
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cloudItemKey = (
|
||||||
|
item: Pick<SyncedAppListItem, "origin" | "pkgname">,
|
||||||
|
): string => `${item.origin}:${item.pkgname}`;
|
||||||
|
|
||||||
|
export const mergeInstalledApps = (
|
||||||
|
currentApps: App[],
|
||||||
|
refreshedApps: App[],
|
||||||
|
refreshedOrigins: Array<"spark" | "apm">,
|
||||||
|
): App[] => {
|
||||||
|
const refreshedKeys = new Set(
|
||||||
|
refreshedApps.map((app) => `${app.origin}:${app.pkgname}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
...currentApps.filter(
|
||||||
|
(app) =>
|
||||||
|
!refreshedOrigins.includes(app.origin) &&
|
||||||
|
!refreshedKeys.has(`${app.origin}:${app.pkgname}`),
|
||||||
|
),
|
||||||
|
...refreshedApps,
|
||||||
|
];
|
||||||
|
};
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
import axios, { type AxiosResponse } from "axios";
|
||||||
|
import pino from "pino";
|
||||||
|
|
||||||
|
import { SPARK_BACKEND_BASE_URL } from "@/global/storeConfig";
|
||||||
|
import type {
|
||||||
|
AppReview,
|
||||||
|
AuthSession,
|
||||||
|
DownloadedAppList,
|
||||||
|
DownloadedAppRecord,
|
||||||
|
FavoriteFolder,
|
||||||
|
FavoriteItem,
|
||||||
|
RatingSummary,
|
||||||
|
ReviewTags,
|
||||||
|
SparkUser,
|
||||||
|
SyncedAppList,
|
||||||
|
SyncedAppListItem,
|
||||||
|
} from "@/global/typedefinition";
|
||||||
|
|
||||||
|
const backend = axios.create({
|
||||||
|
baseURL: SPARK_BACKEND_BASE_URL,
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
const logger = pino({ name: "backendApi" });
|
||||||
|
|
||||||
|
type ApiRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
const normalizeBackendAuthError = (error: unknown): Error => {
|
||||||
|
if (!axios.isAxiosError(error)) {
|
||||||
|
return error instanceof Error ? error : new Error("登录失败,请稍后重试。");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
{
|
||||||
|
code: error.code,
|
||||||
|
message: error.message,
|
||||||
|
status: error.response?.status,
|
||||||
|
},
|
||||||
|
"Spark backend auth exchange failed",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!error.response) {
|
||||||
|
return new Error("无法连接星火账号服务,请确认后端服务已启动或稍后重试。");
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = error.response.status;
|
||||||
|
if (status === 401) {
|
||||||
|
return new Error("论坛登录失败,请检查账号和密码。");
|
||||||
|
}
|
||||||
|
if (status === 503) {
|
||||||
|
return new Error("星火账号服务暂时无法连接论坛,请稍后重试。");
|
||||||
|
}
|
||||||
|
if (status === 500) {
|
||||||
|
return new Error("星火账号服务异常,请确认后端数据库迁移已执行后重试。");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Error(`星火账号服务返回异常 (${status}),请稍后重试。`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const asApiRecord = (value: unknown): ApiRecord => {
|
||||||
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
return value as ApiRecord;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
const asApiRecordArray = (value: unknown): ApiRecord[] => {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.map(asApiRecord);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseForumGroups = (raw: unknown): string[] => {
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
return raw.filter((item): item is string => typeof item === "string");
|
||||||
|
}
|
||||||
|
if (typeof raw !== "string" || raw.length === 0) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed)
|
||||||
|
? parsed.filter((item): item is string => typeof item === "string")
|
||||||
|
: [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toUser = (raw: ApiRecord): SparkUser => ({
|
||||||
|
id: Number(raw.id),
|
||||||
|
flarumUserId: String(raw.flarum_user_id || ""),
|
||||||
|
username: String(raw.username || ""),
|
||||||
|
displayName: String(raw.display_name || raw.username || ""),
|
||||||
|
avatarUrl: String(raw.avatar_url || ""),
|
||||||
|
forumLevel: String(raw.forum_level || "论坛用户"),
|
||||||
|
forumGroups: parseForumGroups(raw.forum_groups),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toReview = (raw: ApiRecord): AppReview => ({
|
||||||
|
id: Number(raw.id),
|
||||||
|
rating: Number(raw.rating),
|
||||||
|
content: String(raw.content || ""),
|
||||||
|
version: String(raw.version || "unknown"),
|
||||||
|
packageArch: String(raw.package_arch || "unknown"),
|
||||||
|
clientArch: String(raw.client_arch || "unknown"),
|
||||||
|
distro: String(raw.distro || "unknown"),
|
||||||
|
origin: raw.origin === "spark" ? "spark" : "apm",
|
||||||
|
category: String(raw.category || ""),
|
||||||
|
createdAt: String(raw.created_at || ""),
|
||||||
|
updatedAt: String(raw.updated_at || ""),
|
||||||
|
userDisplayName: String(raw.user_display_name || ""),
|
||||||
|
userAvatarUrl: String(raw.user_avatar_url || ""),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toFavoriteFolder = (raw: ApiRecord): FavoriteFolder => ({
|
||||||
|
id: Number(raw.id),
|
||||||
|
name: String(raw.name || ""),
|
||||||
|
itemCount: Number(raw.item_count || 0),
|
||||||
|
createdAt: String(raw.created_at || ""),
|
||||||
|
updatedAt: String(raw.updated_at || ""),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toFavoriteItem = (raw: ApiRecord): FavoriteItem => ({
|
||||||
|
id: Number(raw.id),
|
||||||
|
appKey: String(raw.app_key || ""),
|
||||||
|
pkgname: String(raw.pkgname || ""),
|
||||||
|
name: String(raw.name || ""),
|
||||||
|
category: String(raw.category || ""),
|
||||||
|
iconUrl: String(raw.icon_url || ""),
|
||||||
|
createdAt: String(raw.created_at || ""),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toDownloadedApp = (raw: ApiRecord): DownloadedAppRecord => ({
|
||||||
|
id: Number(raw.id),
|
||||||
|
appKey: String(raw.app_key || ""),
|
||||||
|
pkgname: String(raw.pkgname || ""),
|
||||||
|
name: String(raw.name || ""),
|
||||||
|
category: String(raw.category || ""),
|
||||||
|
selectedOrigin: raw.selected_origin === "spark" ? "spark" : "apm",
|
||||||
|
version: String(raw.version || ""),
|
||||||
|
packageArch: String(raw.package_arch || "unknown"),
|
||||||
|
downloadedAt: String(raw.downloaded_at || ""),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toSyncedAppListItem = (raw: ApiRecord): SyncedAppListItem => ({
|
||||||
|
id: raw.id === undefined ? undefined : Number(raw.id),
|
||||||
|
pkgname: String(raw.pkgname || ""),
|
||||||
|
origin: raw.origin === "spark" ? "spark" : "apm",
|
||||||
|
category: String(raw.category || ""),
|
||||||
|
version: String(raw.version || ""),
|
||||||
|
packageArch: String(raw.package_arch || "unknown"),
|
||||||
|
appName: String(raw.app_name || ""),
|
||||||
|
iconUrl: String(raw.icon_url || ""),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toSyncedAppList = (
|
||||||
|
raw: ApiRecord,
|
||||||
|
fallback?: { clientArch: string; distro: string; items: SyncedAppListItem[] },
|
||||||
|
): SyncedAppList => ({
|
||||||
|
snapshotName: String(raw.snapshot_name || "默认列表"),
|
||||||
|
clientArch: String(raw.client_arch || fallback?.clientArch || "unknown"),
|
||||||
|
distro: String(raw.distro || fallback?.distro || "unknown"),
|
||||||
|
updatedAt: String(raw.updated_at || ""),
|
||||||
|
items: raw.items
|
||||||
|
? asApiRecordArray(raw.items).map(toSyncedAppListItem)
|
||||||
|
: fallback?.items || [],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setBackendToken = (token: string | null): void => {
|
||||||
|
const backendWithOptionalDefaults = backend as typeof backend & {
|
||||||
|
defaults?: { headers?: { common?: Record<string, unknown> } };
|
||||||
|
};
|
||||||
|
const commonHeaders = backendWithOptionalDefaults.defaults?.headers?.common;
|
||||||
|
if (!commonHeaders) return;
|
||||||
|
|
||||||
|
if (token) commonHeaders.Authorization = `Bearer ${token}`;
|
||||||
|
else delete commonHeaders.Authorization;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const exchangeFlarumToken = async (payload: {
|
||||||
|
flarumUserId: string;
|
||||||
|
flarumToken: string;
|
||||||
|
}): Promise<AuthSession> => {
|
||||||
|
let response: AxiosResponse;
|
||||||
|
try {
|
||||||
|
response = await backend.post("/auth/flarum", {
|
||||||
|
flarum_user_id: payload.flarumUserId,
|
||||||
|
flarum_token: payload.flarumToken,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw normalizeBackendAuthError(error);
|
||||||
|
}
|
||||||
|
const data = asApiRecord(response.data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: String(data.access_token || ""),
|
||||||
|
tokenType: "bearer",
|
||||||
|
user: toUser(asApiRecord(data.user)),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchMe = async (): Promise<SparkUser> => {
|
||||||
|
const response = await backend.get("/me");
|
||||||
|
return toUser(asApiRecord(response.data));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchRatingSummary = async (
|
||||||
|
appKey: string,
|
||||||
|
): Promise<RatingSummary> => {
|
||||||
|
const response = await backend.get(
|
||||||
|
`/apps/${encodeURIComponent(appKey)}/rating-summary`,
|
||||||
|
);
|
||||||
|
const data = asApiRecord(response.data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
averageRating: Number(data.average_rating || 0),
|
||||||
|
reviewCount: Number(data.review_count || 0),
|
||||||
|
starCounts: Object.fromEntries(
|
||||||
|
Object.entries(asApiRecord(data.star_counts)).map(([key, value]) => [
|
||||||
|
Number(key),
|
||||||
|
Number(value),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchReviews = async (appKey: string): Promise<AppReview[]> => {
|
||||||
|
const response = await backend.get(
|
||||||
|
`/apps/${encodeURIComponent(appKey)}/reviews`,
|
||||||
|
);
|
||||||
|
return asApiRecordArray(response.data).map(toReview);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const submitReview = async (
|
||||||
|
appKey: string,
|
||||||
|
payload: { rating: number; content: string; tags: ReviewTags },
|
||||||
|
): Promise<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(asApiRecord(response.data));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listFavoriteFolders = async (): Promise<FavoriteFolder[]> => {
|
||||||
|
const response = await backend.get("/me/favorite-folders");
|
||||||
|
return asApiRecordArray(response.data).map(toFavoriteFolder);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createFavoriteFolder = async (
|
||||||
|
name: string,
|
||||||
|
): Promise<FavoriteFolder> => {
|
||||||
|
const response = await backend.post("/me/favorite-folders", { name });
|
||||||
|
return toFavoriteFolder(asApiRecord(response.data));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const renameFavoriteFolder = async (
|
||||||
|
folderId: number,
|
||||||
|
name: string,
|
||||||
|
): Promise<FavoriteFolder> => {
|
||||||
|
const response = await backend.patch(`/me/favorite-folders/${folderId}`, {
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
return toFavoriteFolder(asApiRecord(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 asApiRecordArray(response.data).map(toFavoriteItem);
|
||||||
|
};
|
||||||
|
|
||||||
|
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(asApiRecord(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(asApiRecord(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 },
|
||||||
|
});
|
||||||
|
const data = asApiRecord(response.data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: asApiRecordArray(data.items).map(toDownloadedApp),
|
||||||
|
total: Number(data.total || 0),
|
||||||
|
page: Number(data.page || page),
|
||||||
|
pageSize: Number(data.page_size || pageSize),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const recordDownloadedApp = async (
|
||||||
|
item: Omit<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(asApiRecord(response.data));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchSyncedAppList = async (): Promise<SyncedAppList | null> => {
|
||||||
|
const response = await backend.get("/me/app-list");
|
||||||
|
if (!response.data) return null;
|
||||||
|
return toSyncedAppList(asApiRecord(response.data));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadSyncedAppList = async (payload: {
|
||||||
|
clientArch: string;
|
||||||
|
distro: string;
|
||||||
|
items: SyncedAppListItem[];
|
||||||
|
}): Promise<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 toSyncedAppList(asApiRecord(response.data), payload);
|
||||||
|
};
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { getHybridDefaultOrigin } from "@/global/storeConfig";
|
||||||
|
import type {
|
||||||
|
App,
|
||||||
|
FavoriteItem,
|
||||||
|
ResolvedFavoriteItem,
|
||||||
|
StoreFilter,
|
||||||
|
} from "@/global/typedefinition";
|
||||||
|
|
||||||
|
type SourceAvailability = {
|
||||||
|
spark: boolean;
|
||||||
|
apm: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeArch = (arch: string): string =>
|
||||||
|
arch.replace(/-(store|apm)$/, "");
|
||||||
|
|
||||||
|
const appMatchesFavorite = (app: App, item: FavoriteItem): boolean =>
|
||||||
|
app.pkgname === item.pkgname && app.category === item.category;
|
||||||
|
|
||||||
|
const installedAppMatchesFavorite = (app: App, item: FavoriteItem): boolean =>
|
||||||
|
app.pkgname === item.pkgname &&
|
||||||
|
(app.category === item.category ||
|
||||||
|
app.category === "all" ||
|
||||||
|
app.category === "unknown");
|
||||||
|
|
||||||
|
const appMatchesClientArch = (app: App, clientArch: string): boolean => {
|
||||||
|
if (!app.arch) return true;
|
||||||
|
return normalizeArch(app.arch) === normalizeArch(clientArch);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourceAllowed = (
|
||||||
|
origin: "spark" | "apm",
|
||||||
|
available: SourceAvailability,
|
||||||
|
storeFilter: StoreFilter,
|
||||||
|
): boolean => {
|
||||||
|
if (!available[origin]) return false;
|
||||||
|
if (storeFilter === "both") return true;
|
||||||
|
return storeFilter === origin;
|
||||||
|
};
|
||||||
|
|
||||||
|
const choosePreferredApp = (apps: App[]): App => {
|
||||||
|
if (apps.length === 1) return apps[0];
|
||||||
|
|
||||||
|
const referenceApp = apps.find((app) => app.origin === "spark") ?? apps[0];
|
||||||
|
const preferredOrigin = getHybridDefaultOrigin(referenceApp);
|
||||||
|
return apps.find((app) => app.origin === preferredOrigin) ?? apps[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveFavoriteItems = (
|
||||||
|
items: FavoriteItem[],
|
||||||
|
catalogApps: App[],
|
||||||
|
installedApps: App[],
|
||||||
|
available: SourceAvailability,
|
||||||
|
storeFilter: StoreFilter,
|
||||||
|
clientArch = window.apm_store.arch || "amd64",
|
||||||
|
): ResolvedFavoriteItem[] => {
|
||||||
|
return items.map((item) => {
|
||||||
|
const catalogMatches = catalogApps.filter((app) =>
|
||||||
|
appMatchesFavorite(app, item),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (catalogMatches.length === 0) {
|
||||||
|
return {
|
||||||
|
item,
|
||||||
|
status: "downlisted",
|
||||||
|
reason: "已下架",
|
||||||
|
selectedApp: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const installedMatch = installedApps.find((app) =>
|
||||||
|
installedAppMatchesFavorite(app, item),
|
||||||
|
);
|
||||||
|
if (installedMatch) {
|
||||||
|
return {
|
||||||
|
item,
|
||||||
|
status: "installed",
|
||||||
|
reason: "已安装",
|
||||||
|
selectedApp: installedMatch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const archMatches = catalogMatches.filter((app) =>
|
||||||
|
appMatchesClientArch(app, clientArch),
|
||||||
|
);
|
||||||
|
if (archMatches.length === 0) {
|
||||||
|
return {
|
||||||
|
item,
|
||||||
|
status: "arch-unavailable",
|
||||||
|
reason: "当前架构不可用",
|
||||||
|
selectedApp: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceMatches = archMatches.filter((app) =>
|
||||||
|
sourceAllowed(app.origin, available, storeFilter),
|
||||||
|
);
|
||||||
|
if (sourceMatches.length === 0) {
|
||||||
|
return {
|
||||||
|
item,
|
||||||
|
status: "platform-unavailable",
|
||||||
|
reason: "当前来源不可用",
|
||||||
|
selectedApp: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
item,
|
||||||
|
status: "installable",
|
||||||
|
reason: "可安装",
|
||||||
|
selectedApp: choosePreferredApp(sourceMatches),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { FlarumLoginPayload } from "@/global/typedefinition";
|
||||||
|
|
||||||
|
type FlarumTokenResponse = {
|
||||||
|
token: string;
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const asRecord = (value: unknown): Record<string, unknown> => {
|
||||||
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
const knownLoginErrorMessages = [
|
||||||
|
"无法连接星火论坛,请检查网络后重试。",
|
||||||
|
"论坛登录失败,请检查账号和密码。",
|
||||||
|
"论坛登录响应异常,请稍后重试。",
|
||||||
|
"登录信息格式不正确,请重新输入。",
|
||||||
|
];
|
||||||
|
|
||||||
|
const normalizeIpcError = (error: unknown): Error => {
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
return new Error("登录失败,请稍后重试");
|
||||||
|
}
|
||||||
|
|
||||||
|
const knownMessage = knownLoginErrorMessages.find((message) =>
|
||||||
|
error.message.includes(message),
|
||||||
|
);
|
||||||
|
return knownMessage ? new Error(knownMessage) : error;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requestFlarumToken = async (
|
||||||
|
payload: FlarumLoginPayload,
|
||||||
|
): Promise<FlarumTokenResponse> => {
|
||||||
|
let data: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
data = asRecord(
|
||||||
|
await window.ipcRenderer.invoke("request-flarum-token", payload),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
throw normalizeIpcError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = data.token;
|
||||||
|
const userId = data.userId ?? data.user_id;
|
||||||
|
if (
|
||||||
|
typeof token !== "string" ||
|
||||||
|
!token ||
|
||||||
|
userId === undefined ||
|
||||||
|
userId === null
|
||||||
|
) {
|
||||||
|
throw new Error("论坛登录响应异常,请稍后重试。");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
userId: String(userId),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -21,16 +21,18 @@ import axios from "axios";
|
|||||||
|
|
||||||
const logger = pino({ name: "processInstall.ts" });
|
const logger = pino({ name: "processInstall.ts" });
|
||||||
|
|
||||||
export const handleInstall = async (appObj?: App) => {
|
export const handleInstall = async (
|
||||||
|
appObj?: App,
|
||||||
|
): Promise<DownloadItem | null> => {
|
||||||
const targetApp = appObj || currentApp.value;
|
const targetApp = appObj || currentApp.value;
|
||||||
if (!targetApp?.pkgname) return;
|
if (!targetApp?.pkgname) return null;
|
||||||
|
|
||||||
// APM 应用:在创建下载任务前检查 APM 是否可用
|
// APM 应用:在创建下载任务前检查 APM 是否可用
|
||||||
if (targetApp.origin === "apm") {
|
if (targetApp.origin === "apm") {
|
||||||
const hasApm = await window.ipcRenderer.invoke("check-apm-available");
|
const hasApm = await window.ipcRenderer.invoke("check-apm-available");
|
||||||
if (!hasApm) {
|
if (!hasApm) {
|
||||||
showApmInstallDialog.value = true;
|
showApmInstallDialog.value = true;
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +44,7 @@ export const handleInstall = async (appObj?: App) => {
|
|||||||
logger.info(
|
logger.info(
|
||||||
`任务已存在,忽略重复添加: ${targetApp.pkgname} (${targetApp.origin})`,
|
`任务已存在,忽略重复添加: ${targetApp.pkgname} (${targetApp.origin})`,
|
||||||
);
|
);
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建下载任务
|
// 创建下载任务
|
||||||
@@ -98,6 +100,7 @@ export const handleInstall = async (appObj?: App) => {
|
|||||||
.then((response) => {
|
.then((response) => {
|
||||||
logger.info("下载次数统计已发送,状态:", response.data);
|
logger.info("下载次数统计已发送,状态:", response.data);
|
||||||
});
|
});
|
||||||
|
return download;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleRetry = (download_: DownloadItem) => {
|
export const handleRetry = (download_: DownloadItem) => {
|
||||||
|
|||||||
Vendored
+10
-1
@@ -1,7 +1,7 @@
|
|||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
import type { UpdateCenterBridge } from "@/global/typedefinition";
|
import type { SystemInfo, UpdateCenterBridge } from "@/global/typedefinition";
|
||||||
|
|
||||||
declare module "*.vue" {
|
declare module "*.vue" {
|
||||||
import type { DefineComponent } from "vue";
|
import type { DefineComponent } from "vue";
|
||||||
@@ -10,6 +10,10 @@ declare module "*.vue" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_SPARK_BACKEND_BASE_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
// expose in the `electron/preload/index.ts`
|
// expose in the `electron/preload/index.ts`
|
||||||
ipcRenderer: IpcRendererFacade;
|
ipcRenderer: IpcRendererFacade;
|
||||||
@@ -30,6 +34,11 @@ interface IpcRendererFacade {
|
|||||||
// IPC channel type definitions
|
// IPC channel type definitions
|
||||||
declare interface IpcChannels {
|
declare interface IpcChannels {
|
||||||
"get-app-version": () => string;
|
"get-app-version": () => string;
|
||||||
|
"get-system-info": () => Promise<SystemInfo>;
|
||||||
|
"request-flarum-token": (payload: {
|
||||||
|
identification: string;
|
||||||
|
password: string;
|
||||||
|
}) => Promise<{ token: string; userId: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare const __APP_VERSION__: string;
|
declare const __APP_VERSION__: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user