mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-06-22 14:13:49 +08:00
feat(account): add client account api foundation
This commit is contained in:
@@ -20,11 +20,13 @@ Object.defineProperty(window, "ipcRenderer", {
|
||||
invoke: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Mock window.apm_store
|
||||
Object.defineProperty(window, "apm_store", {
|
||||
value: {
|
||||
arch: "amd64-store",
|
||||
arch: "amd64",
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
FLARUM_BASE_URL,
|
||||
FLARUM_REGISTER_URL,
|
||||
SPARK_BACKEND_BASE_URL,
|
||||
} from "@/global/storeConfig";
|
||||
import type {
|
||||
DownloadedAppRecord,
|
||||
FavoriteFolder,
|
||||
FavoriteItem,
|
||||
ReviewTags,
|
||||
SparkUser,
|
||||
SyncedAppListItem,
|
||||
} from "@/global/typedefinition";
|
||||
|
||||
describe("account shared types", () => {
|
||||
it("exports backend/forum config and account shapes", () => {
|
||||
const user: SparkUser = {
|
||||
id: 1,
|
||||
flarumUserId: "123",
|
||||
username: "momen",
|
||||
displayName: "Momen",
|
||||
avatarUrl: "https://bbs.spark-app.store/avatar.png",
|
||||
forumLevel: "管理员",
|
||||
forumGroups: ["管理员"],
|
||||
};
|
||||
const folder: FavoriteFolder = {
|
||||
id: 1,
|
||||
name: "默认收藏夹",
|
||||
itemCount: 1,
|
||||
createdAt: "2026-05-18T00:00:00Z",
|
||||
updatedAt: "2026-05-18T00:00:00Z",
|
||||
};
|
||||
const favorite: FavoriteItem = {
|
||||
id: 2,
|
||||
appKey: "app:office:wps",
|
||||
pkgname: "wps",
|
||||
name: "WPS",
|
||||
category: "office",
|
||||
iconUrl: "https://example.invalid/wps.png",
|
||||
createdAt: "2026-05-18T00:00:00Z",
|
||||
};
|
||||
const download: DownloadedAppRecord = {
|
||||
id: 3,
|
||||
appKey: "app:office:wps",
|
||||
pkgname: "wps",
|
||||
name: "WPS",
|
||||
category: "office",
|
||||
selectedOrigin: "apm",
|
||||
version: "1.0.0",
|
||||
packageArch: "amd64",
|
||||
downloadedAt: "2026-05-18T00:00:00Z",
|
||||
};
|
||||
const syncItem: SyncedAppListItem = {
|
||||
pkgname: "wps",
|
||||
origin: "apm",
|
||||
category: "office",
|
||||
version: "1.0.0",
|
||||
packageArch: "amd64",
|
||||
appName: "WPS",
|
||||
iconUrl: "https://example.invalid/wps.png",
|
||||
};
|
||||
const tags: ReviewTags = {
|
||||
origin: "apm",
|
||||
category: "office",
|
||||
pkgname: "wps",
|
||||
version: "1.0.0",
|
||||
packageArch: "amd64",
|
||||
clientArch: "amd64",
|
||||
distro: "deepin 25",
|
||||
};
|
||||
|
||||
expect(typeof SPARK_BACKEND_BASE_URL).toBe("string");
|
||||
expect(FLARUM_BASE_URL).toContain("bbs.spark-app.store");
|
||||
expect(FLARUM_REGISTER_URL).toContain("register");
|
||||
expect(user.forumGroups).toEqual(["管理员"]);
|
||||
expect(folder.itemCount).toBe(1);
|
||||
expect(favorite.appKey).toBe("app:office:wps");
|
||||
expect(download.selectedOrigin).toBe("apm");
|
||||
expect(syncItem.origin).toBe("apm");
|
||||
expect(tags.packageArch).toBe("amd64");
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,14 @@ export const APM_STORE_BASE_URL: string =
|
||||
export const APM_STORE_STATS_BASE_URL: string =
|
||||
import.meta.env.VITE_APM_STORE_STATS_BASE_URL || "";
|
||||
|
||||
export const SPARK_BACKEND_BASE_URL: string =
|
||||
import.meta.env.VITE_SPARK_BACKEND_BASE_URL || "";
|
||||
|
||||
export const FLARUM_BASE_URL = "https://bbs.spark-app.store";
|
||||
export const FLARUM_REGISTER_URL = `${FLARUM_BASE_URL}/register`;
|
||||
export const FLARUM_PROFILE_URL = `${FLARUM_BASE_URL}/u`;
|
||||
export const FLARUM_SETTINGS_URL = `${FLARUM_BASE_URL}/settings`;
|
||||
|
||||
// 下面的变量用于存储当前应用的信息,其实用在多个组件中
|
||||
export const currentApp = ref<App | null>(null);
|
||||
export const currentAppSparkInstalled = ref(false);
|
||||
|
||||
@@ -249,3 +249,130 @@ export interface SidebarEntry {
|
||||
type?: "category" | "search" | "link";
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface SparkUser {
|
||||
id: number;
|
||||
flarumUserId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatarUrl: string;
|
||||
forumLevel: string;
|
||||
forumGroups: string[];
|
||||
}
|
||||
|
||||
export interface AuthSession {
|
||||
accessToken: string;
|
||||
tokenType: "bearer";
|
||||
user: SparkUser;
|
||||
}
|
||||
|
||||
export interface FlarumLoginPayload {
|
||||
identification: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface ReviewTags {
|
||||
origin: "spark" | "apm";
|
||||
category: string;
|
||||
pkgname: string;
|
||||
version: string;
|
||||
packageArch: string;
|
||||
clientArch: string;
|
||||
distro: string;
|
||||
}
|
||||
|
||||
export interface RatingSummary {
|
||||
averageRating: number;
|
||||
reviewCount: number;
|
||||
starCounts: Record<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,336 @@
|
||||
import axios from "axios";
|
||||
|
||||
import { SPARK_BACKEND_BASE_URL } from "@/global/storeConfig";
|
||||
import type {
|
||||
AppReview,
|
||||
AuthSession,
|
||||
DownloadedAppList,
|
||||
DownloadedAppRecord,
|
||||
FavoriteFolder,
|
||||
FavoriteItem,
|
||||
RatingSummary,
|
||||
ReviewTags,
|
||||
SparkUser,
|
||||
SyncedAppList,
|
||||
SyncedAppListItem,
|
||||
} from "@/global/typedefinition";
|
||||
|
||||
const backend = axios.create({
|
||||
baseURL: SPARK_BACKEND_BASE_URL,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
type ApiRecord = Record<string, unknown>;
|
||||
|
||||
const asApiRecord = (value: unknown): ApiRecord => {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as ApiRecord;
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
const asApiRecordArray = (value: unknown): ApiRecord[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map(asApiRecord);
|
||||
};
|
||||
|
||||
export const parseForumGroups = (raw: unknown): string[] => {
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.filter((item): item is string => typeof item === "string");
|
||||
}
|
||||
if (typeof raw !== "string" || raw.length === 0) return [];
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const toUser = (raw: ApiRecord): SparkUser => ({
|
||||
id: Number(raw.id),
|
||||
flarumUserId: String(raw.flarum_user_id || ""),
|
||||
username: String(raw.username || ""),
|
||||
displayName: String(raw.display_name || raw.username || ""),
|
||||
avatarUrl: String(raw.avatar_url || ""),
|
||||
forumLevel: String(raw.forum_level || "论坛用户"),
|
||||
forumGroups: parseForumGroups(raw.forum_groups),
|
||||
});
|
||||
|
||||
const toReview = (raw: ApiRecord): AppReview => ({
|
||||
id: Number(raw.id),
|
||||
rating: Number(raw.rating),
|
||||
content: String(raw.content || ""),
|
||||
version: String(raw.version || "unknown"),
|
||||
packageArch: String(raw.package_arch || "unknown"),
|
||||
clientArch: String(raw.client_arch || "unknown"),
|
||||
distro: String(raw.distro || "unknown"),
|
||||
origin: raw.origin === "spark" ? "spark" : "apm",
|
||||
category: String(raw.category || ""),
|
||||
createdAt: String(raw.created_at || ""),
|
||||
updatedAt: String(raw.updated_at || ""),
|
||||
userDisplayName: String(raw.user_display_name || ""),
|
||||
userAvatarUrl: String(raw.user_avatar_url || ""),
|
||||
});
|
||||
|
||||
const toFavoriteFolder = (raw: ApiRecord): FavoriteFolder => ({
|
||||
id: Number(raw.id),
|
||||
name: String(raw.name || ""),
|
||||
itemCount: Number(raw.item_count || 0),
|
||||
createdAt: String(raw.created_at || ""),
|
||||
updatedAt: String(raw.updated_at || ""),
|
||||
});
|
||||
|
||||
const toFavoriteItem = (raw: ApiRecord): FavoriteItem => ({
|
||||
id: Number(raw.id),
|
||||
appKey: String(raw.app_key || ""),
|
||||
pkgname: String(raw.pkgname || ""),
|
||||
name: String(raw.name || ""),
|
||||
category: String(raw.category || ""),
|
||||
iconUrl: String(raw.icon_url || ""),
|
||||
createdAt: String(raw.created_at || ""),
|
||||
});
|
||||
|
||||
const toDownloadedApp = (raw: ApiRecord): DownloadedAppRecord => ({
|
||||
id: Number(raw.id),
|
||||
appKey: String(raw.app_key || ""),
|
||||
pkgname: String(raw.pkgname || ""),
|
||||
name: String(raw.name || ""),
|
||||
category: String(raw.category || ""),
|
||||
selectedOrigin: raw.selected_origin === "spark" ? "spark" : "apm",
|
||||
version: String(raw.version || ""),
|
||||
packageArch: String(raw.package_arch || "unknown"),
|
||||
downloadedAt: String(raw.downloaded_at || ""),
|
||||
});
|
||||
|
||||
const toSyncedAppListItem = (raw: ApiRecord): SyncedAppListItem => ({
|
||||
id: raw.id === undefined ? undefined : Number(raw.id),
|
||||
pkgname: String(raw.pkgname || ""),
|
||||
origin: raw.origin === "spark" ? "spark" : "apm",
|
||||
category: String(raw.category || ""),
|
||||
version: String(raw.version || ""),
|
||||
packageArch: String(raw.package_arch || "unknown"),
|
||||
appName: String(raw.app_name || ""),
|
||||
iconUrl: String(raw.icon_url || ""),
|
||||
});
|
||||
|
||||
const toSyncedAppList = (
|
||||
raw: ApiRecord,
|
||||
fallback?: { clientArch: string; distro: string; items: SyncedAppListItem[] },
|
||||
): SyncedAppList => ({
|
||||
snapshotName: String(raw.snapshot_name || "默认列表"),
|
||||
clientArch: String(raw.client_arch || fallback?.clientArch || "unknown"),
|
||||
distro: String(raw.distro || fallback?.distro || "unknown"),
|
||||
updatedAt: String(raw.updated_at || ""),
|
||||
items: raw.items
|
||||
? asApiRecordArray(raw.items).map(toSyncedAppListItem)
|
||||
: fallback?.items || [],
|
||||
});
|
||||
|
||||
export const setBackendToken = (token: string | null): void => {
|
||||
if (token) backend.defaults.headers.common.Authorization = `Bearer ${token}`;
|
||||
else delete backend.defaults.headers.common.Authorization;
|
||||
};
|
||||
|
||||
export const exchangeFlarumToken = async (payload: {
|
||||
flarumUserId: string;
|
||||
flarumToken: string;
|
||||
}): Promise<AuthSession> => {
|
||||
const response = await backend.post("/auth/flarum", {
|
||||
flarum_user_id: payload.flarumUserId,
|
||||
flarum_token: payload.flarumToken,
|
||||
});
|
||||
const data = asApiRecord(response.data);
|
||||
|
||||
return {
|
||||
accessToken: String(data.access_token || ""),
|
||||
tokenType: "bearer",
|
||||
user: toUser(asApiRecord(data.user)),
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchMe = async (): Promise<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);
|
||||
};
|
||||
Vendored
+4
@@ -10,6 +10,10 @@ declare module "*.vue" {
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_SPARK_BACKEND_BASE_URL?: string;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
// expose in the `electron/preload/index.ts`
|
||||
ipcRenderer: IpcRendererFacade;
|
||||
|
||||
Reference in New Issue
Block a user