mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-06-22 14:13:49 +08:00
feat(favorites): add cloud favorite management
This commit is contained in:
+166
-14
@@ -95,17 +95,18 @@
|
|||||||
账号资料与安全设置功能即将开放。
|
账号资料与安全设置功能即将开放。
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<section
|
<FavoriteFolderManager
|
||||||
v-else-if="currentView === 'favorites'"
|
v-else-if="currentView === 'favorites'"
|
||||||
class="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900"
|
:folders="favoriteFolders"
|
||||||
>
|
:active-folder-id="activeFavoriteFolderId"
|
||||||
<h1 class="text-2xl font-semibold text-slate-900 dark:text-white">
|
:items="resolvedFavoriteItems"
|
||||||
我的收藏
|
:loading="favoriteLoading"
|
||||||
</h1>
|
:error="favoriteError"
|
||||||
<p class="mt-3 text-sm text-slate-500 dark:text-slate-400">
|
@select-folder="selectFavoriteFolder"
|
||||||
收藏应用列表功能即将开放。
|
@create-folder="createFavoriteFolderFromPrompt"
|
||||||
</p>
|
@remove-selected="removeSelectedFavorites"
|
||||||
</section>
|
@install-selected="installResolvedFavorites"
|
||||||
|
/>
|
||||||
<template v-else-if="activeTab === 'home'">
|
<template v-else-if="activeTab === 'home'">
|
||||||
<HomeView
|
<HomeView
|
||||||
:links="homeLinks"
|
:links="homeLinks"
|
||||||
@@ -222,6 +223,13 @@
|
|||||||
@login="openLoginFromPrompt"
|
@login="openLoginFromPrompt"
|
||||||
@register="openExternalUrl(FLARUM_REGISTER_URL)"
|
@register="openExternalUrl(FLARUM_REGISTER_URL)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FavoriteFolderSelector
|
||||||
|
:show="showFavoriteSelector"
|
||||||
|
:folders="favoriteFolders"
|
||||||
|
@close="showFavoriteSelector = false"
|
||||||
|
@select-folder="addCurrentFavoriteToFolder"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -246,6 +254,8 @@ import AboutModal from "./components/AboutModal.vue";
|
|||||||
import SettingsModal from "./components/SettingsModal.vue";
|
import SettingsModal from "./components/SettingsModal.vue";
|
||||||
import LoginModal from "./components/LoginModal.vue";
|
import LoginModal from "./components/LoginModal.vue";
|
||||||
import LoginPromptModal from "./components/LoginPromptModal.vue";
|
import LoginPromptModal from "./components/LoginPromptModal.vue";
|
||||||
|
import FavoriteFolderSelector from "./components/FavoriteFolderSelector.vue";
|
||||||
|
import FavoriteFolderManager from "./components/FavoriteFolderManager.vue";
|
||||||
import {
|
import {
|
||||||
APM_STORE_BASE_URL,
|
APM_STORE_BASE_URL,
|
||||||
FLARUM_BASE_URL,
|
FLARUM_BASE_URL,
|
||||||
@@ -269,7 +279,14 @@ import {
|
|||||||
rankAppsBySearch,
|
rankAppsBySearch,
|
||||||
} from "./modules/appSearch";
|
} from "./modules/appSearch";
|
||||||
import { handleInstall, handleRetry } from "./modules/processInstall";
|
import { handleInstall, handleRetry } from "./modules/processInstall";
|
||||||
import { exchangeFlarumToken } from "./modules/backendApi";
|
import {
|
||||||
|
addFavoriteItem,
|
||||||
|
bulkDeleteFavoriteItems,
|
||||||
|
createFavoriteFolder,
|
||||||
|
exchangeFlarumToken,
|
||||||
|
listFavoriteFolders,
|
||||||
|
listFavoriteItems,
|
||||||
|
} from "./modules/backendApi";
|
||||||
import { requestFlarumToken } from "./modules/flarumAuth";
|
import { requestFlarumToken } from "./modules/flarumAuth";
|
||||||
import {
|
import {
|
||||||
currentUser,
|
currentUser,
|
||||||
@@ -286,9 +303,11 @@ import {
|
|||||||
import { createUpdateCenterStore } from "./modules/updateCenter";
|
import { createUpdateCenterStore } from "./modules/updateCenter";
|
||||||
import {
|
import {
|
||||||
buildReviewAppKey,
|
buildReviewAppKey,
|
||||||
|
buildFavoriteAppKey,
|
||||||
buildReviewTags,
|
buildReviewTags,
|
||||||
getDisplayApp,
|
getDisplayApp,
|
||||||
} from "./modules/appIdentity";
|
} from "./modules/appIdentity";
|
||||||
|
import { resolveFavoriteItems } from "./modules/favoriteAvailability";
|
||||||
import type {
|
import type {
|
||||||
App,
|
App,
|
||||||
AppJson,
|
AppJson,
|
||||||
@@ -301,6 +320,9 @@ import type {
|
|||||||
SidebarEntry,
|
SidebarEntry,
|
||||||
UpdateCenterItem,
|
UpdateCenterItem,
|
||||||
ReviewTags,
|
ReviewTags,
|
||||||
|
FavoriteFolder,
|
||||||
|
FavoriteItem,
|
||||||
|
ResolvedFavoriteItem,
|
||||||
} from "./global/typedefinition";
|
} from "./global/typedefinition";
|
||||||
import type { Ref } from "vue";
|
import type { Ref } from "vue";
|
||||||
import type { IpcRendererEvent } from "electron";
|
import type { IpcRendererEvent } from "electron";
|
||||||
@@ -372,6 +394,13 @@ const loginPromptMessage = ref("请登录星火账号后继续操作。");
|
|||||||
const sparkAvailable = ref(false);
|
const sparkAvailable = ref(false);
|
||||||
const apmAvailable = ref(false);
|
const apmAvailable = ref(false);
|
||||||
const sidebarEntries: Ref<SidebarEntry[]> = ref([]);
|
const sidebarEntries: Ref<SidebarEntry[]> = ref([]);
|
||||||
|
const favoriteFolders = ref<FavoriteFolder[]>([]);
|
||||||
|
const activeFavoriteFolderId = ref<number | null>(null);
|
||||||
|
const favoriteItems = ref<FavoriteItem[]>([]);
|
||||||
|
const showFavoriteSelector = ref(false);
|
||||||
|
const favoriteTargetApp = ref<App | null>(null);
|
||||||
|
const favoriteLoading = ref(false);
|
||||||
|
const favoriteError = ref("");
|
||||||
|
|
||||||
/** 启动参数 --no-apm => 仅 Spark;--no-spark => 仅 APM;由主进程 IPC 提供 */
|
/** 启动参数 --no-apm => 仅 Spark;--no-spark => 仅 APM;由主进程 IPC 提供 */
|
||||||
const storeFilter = ref<"spark" | "apm" | "both">("both");
|
const storeFilter = ref<"spark" | "apm" | "both">("both");
|
||||||
@@ -473,6 +502,17 @@ const currentReviewTags = computed<ReviewTags | null>(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const resolvedFavoriteItems = computed<ResolvedFavoriteItem[]>(() =>
|
||||||
|
resolveFavoriteItems(
|
||||||
|
favoriteItems.value,
|
||||||
|
apps.value,
|
||||||
|
installedApps.value,
|
||||||
|
availableSources.value,
|
||||||
|
storeFilter.value,
|
||||||
|
clientArch.value,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// 方法
|
// 方法
|
||||||
const syncThemePreference = () => {
|
const syncThemePreference = () => {
|
||||||
document.documentElement.classList.toggle("dark", isDarkTheme.value);
|
document.documentElement.classList.toggle("dark", isDarkTheme.value);
|
||||||
@@ -1108,8 +1148,8 @@ const onDetailInstall = async (app: App) => {
|
|||||||
await handleInstall(app);
|
await handleInstall(app);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDetailFavorite = (app: App) => {
|
const onDetailFavorite = async (app: App) => {
|
||||||
logger.info(`Favorite requested for ${app.pkgname}`);
|
await openFavoriteSelector(app);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDetailRequestLogin = (message: string) => {
|
const handleDetailRequestLogin = (message: string) => {
|
||||||
@@ -1215,12 +1255,124 @@ const openUserManagement = () => {
|
|||||||
showLoginPrompt.value = false;
|
showLoginPrompt.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const openFavoriteManagement = () => {
|
const loadFavoriteFolders = async (): Promise<void> => {
|
||||||
|
favoriteFolders.value = await listFavoriteFolders();
|
||||||
|
if (!activeFavoriteFolderId.value && favoriteFolders.value.length > 0) {
|
||||||
|
activeFavoriteFolderId.value = favoriteFolders.value[0].id;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadActiveFavoriteItems = async (): Promise<void> => {
|
||||||
|
if (!activeFavoriteFolderId.value) {
|
||||||
|
favoriteItems.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
favoriteItems.value = await listFavoriteItems(activeFavoriteFolderId.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshFavorites = async (): Promise<void> => {
|
||||||
|
favoriteLoading.value = true;
|
||||||
|
favoriteError.value = "";
|
||||||
|
try {
|
||||||
|
await loadFavoriteFolders();
|
||||||
|
await loadActiveFavoriteItems();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
favoriteError.value = (error as Error)?.message || "读取收藏夹失败";
|
||||||
|
} finally {
|
||||||
|
favoriteLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openFavoriteSelector = async (app: App) => {
|
||||||
|
if (!requireLogin("收藏应用需要登录星火账号。")) return;
|
||||||
|
favoriteTargetApp.value = app;
|
||||||
|
favoriteError.value = "";
|
||||||
|
try {
|
||||||
|
await loadFavoriteFolders();
|
||||||
|
showFavoriteSelector.value = true;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
favoriteError.value = (error as Error)?.message || "读取收藏夹失败";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCurrentFavoriteToFolder = async (folderId: number | "default") => {
|
||||||
|
const app = favoriteTargetApp.value;
|
||||||
|
if (!app) return;
|
||||||
|
try {
|
||||||
|
await addFavoriteItem(folderId, {
|
||||||
|
appKey: buildFavoriteAppKey(app),
|
||||||
|
pkgname: app.pkgname,
|
||||||
|
name: app.name,
|
||||||
|
category: app.category,
|
||||||
|
iconUrl: app.icons,
|
||||||
|
});
|
||||||
|
showFavoriteSelector.value = false;
|
||||||
|
favoriteTargetApp.value = null;
|
||||||
|
await refreshFavorites();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
favoriteError.value = (error as Error)?.message || "添加收藏失败";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openFavoriteManagement = async () => {
|
||||||
if (!requireLogin("请登录后查看我的收藏。")) return;
|
if (!requireLogin("请登录后查看我的收藏。")) return;
|
||||||
currentView.value = "favorites";
|
currentView.value = "favorites";
|
||||||
activeTab.value = "favorites";
|
activeTab.value = "favorites";
|
||||||
isSidebarOpen.value = false;
|
isSidebarOpen.value = false;
|
||||||
showLoginPrompt.value = false;
|
showLoginPrompt.value = false;
|
||||||
|
await refreshFavorites();
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectFavoriteFolder = async (folderId: number) => {
|
||||||
|
activeFavoriteFolderId.value = folderId;
|
||||||
|
favoriteLoading.value = true;
|
||||||
|
favoriteError.value = "";
|
||||||
|
try {
|
||||||
|
await loadActiveFavoriteItems();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
favoriteError.value = (error as Error)?.message || "读取收藏应用失败";
|
||||||
|
} finally {
|
||||||
|
favoriteLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createFavoriteFolderFromPrompt = async () => {
|
||||||
|
const name = window.prompt("请输入收藏夹名称");
|
||||||
|
const folderName = name?.trim();
|
||||||
|
if (!folderName) return;
|
||||||
|
favoriteLoading.value = true;
|
||||||
|
favoriteError.value = "";
|
||||||
|
try {
|
||||||
|
const folder = await createFavoriteFolder(folderName);
|
||||||
|
activeFavoriteFolderId.value = folder.id;
|
||||||
|
await refreshFavorites();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
favoriteError.value = (error as Error)?.message || "创建收藏夹失败";
|
||||||
|
} finally {
|
||||||
|
favoriteLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeSelectedFavorites = async (ids: number[]) => {
|
||||||
|
if (!activeFavoriteFolderId.value || ids.length === 0) return;
|
||||||
|
favoriteLoading.value = true;
|
||||||
|
favoriteError.value = "";
|
||||||
|
try {
|
||||||
|
await bulkDeleteFavoriteItems(activeFavoriteFolderId.value, ids);
|
||||||
|
await refreshFavorites();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
favoriteError.value = (error as Error)?.message || "移除收藏失败";
|
||||||
|
} finally {
|
||||||
|
favoriteLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const installResolvedFavorites = async (items: ResolvedFavoriteItem[]) => {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.selectedApp) {
|
||||||
|
await onDetailInstall(item.selectedApp);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO: 目前 APM 商店不能暂停下载
|
// TODO: 目前 APM 商店不能暂停下载
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { resolveFavoriteItems } from "@/modules/favoriteAvailability";
|
||||||
|
import type { App, FavoriteItem } from "@/global/typedefinition";
|
||||||
|
|
||||||
|
const app = (origin: "spark" | "apm", overrides: Partial<App> = {}): App => ({
|
||||||
|
name: "WPS",
|
||||||
|
pkgname: "wps",
|
||||||
|
version: "1.0.0",
|
||||||
|
filename: "wps_1.0.0_amd64.deb",
|
||||||
|
torrent_address: "",
|
||||||
|
author: "",
|
||||||
|
contributor: "",
|
||||||
|
website: "",
|
||||||
|
update: "",
|
||||||
|
size: "",
|
||||||
|
more: "",
|
||||||
|
tags: "",
|
||||||
|
img_urls: [],
|
||||||
|
icons: "",
|
||||||
|
category: "office",
|
||||||
|
origin,
|
||||||
|
currentStatus: "not-installed",
|
||||||
|
arch: "amd64",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const favorite: FavoriteItem = {
|
||||||
|
id: 1,
|
||||||
|
appKey: "app:office:wps",
|
||||||
|
pkgname: "wps",
|
||||||
|
name: "WPS",
|
||||||
|
category: "office",
|
||||||
|
iconUrl: "",
|
||||||
|
createdAt: "2026-05-18T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("favoriteAvailability", () => {
|
||||||
|
it("marks downlisted favorites", () => {
|
||||||
|
expect(
|
||||||
|
resolveFavoriteItems(
|
||||||
|
[favorite],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
{ spark: true, apm: true },
|
||||||
|
"both",
|
||||||
|
)[0].status,
|
||||||
|
).toBe("downlisted");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects preferred installable variant", () => {
|
||||||
|
const resolved = resolveFavoriteItems(
|
||||||
|
[favorite],
|
||||||
|
[app("spark"), app("apm")],
|
||||||
|
[],
|
||||||
|
{ spark: true, apm: true },
|
||||||
|
"both",
|
||||||
|
)[0];
|
||||||
|
expect(resolved.status).toBe("installable");
|
||||||
|
expect(resolved.selectedApp?.origin).toBe("apm");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks installed favorites", () => {
|
||||||
|
const resolved = resolveFavoriteItems(
|
||||||
|
[favorite],
|
||||||
|
[app("apm")],
|
||||||
|
[app("apm", { currentStatus: "installed" })],
|
||||||
|
{ spark: true, apm: true },
|
||||||
|
"both",
|
||||||
|
)[0];
|
||||||
|
expect(resolved.status).toBe("installed");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import {
|
||||||
|
HYBRID_DEFAULT_PRIORITY,
|
||||||
|
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 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) === "spark"
|
||||||
|
? HYBRID_DEFAULT_PRIORITY
|
||||||
|
: 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) =>
|
||||||
|
appMatchesFavorite(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),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user