mirror of
https://gitee.com/spark-store-project/spark-store
synced 2026-09-20 21:50:11 +08:00
fix: 标签策略启动初始化加固 + 已安装应用过滤/统计性能优化
- 标签优先显示策略 initTagPriorityStrategy 提到 App.vue onMounted 统一调用, 消除组件挂载顺序导致先读内存默认 auto 的竞态窗口(AppDetailModal 移除重复调用) - InstalledAppsModal 过滤逻辑合并为单次遍历(保留 hasOrigin 双来源语义) - 统计 apm/spark/total 改为基于 searchFilteredApps 单次遍历 - canOpenDetail 补充业务含义注释与可读性变量 - 将 App.vue 重构的 9 个 composable 纳入版本管理 - 验证:vue-tsc 0 / eslint 0 / dpkg-buildpackage 打包 spark-store_5.2.1.24-test 成功
This commit is contained in:
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
spark-store (5.2.1.19-test) UNRELEASED; urgency=medium
|
||||
spark-store (5.2.1.24-test) UNRELEASED; urgency=medium
|
||||
|
||||
* Initial release. (Closes: #nnnn) <nnnn is the bug number of your ITP>
|
||||
|
||||
|
||||
+319
-2646
File diff suppressed because it is too large
Load Diff
@@ -578,7 +578,6 @@ import {
|
||||
} from "../global/storeConfig";
|
||||
import {
|
||||
tagPriorityStrategyRef,
|
||||
initTagPriorityStrategy,
|
||||
type TagPriorityStrategy,
|
||||
} from "../global/tagPriority";
|
||||
// 评论功能暂时关闭
|
||||
@@ -690,7 +689,7 @@ const computeDefaultViewingOrigin = (
|
||||
// - 策略变化(在设置页实时切换)→ 已打开且未手动切标签的详情页立即更新默认标签
|
||||
// 手动点标签页(selectOrigin)只是临时预览:置 manualOverride 标记,本次会话内保持,
|
||||
// 既不被策略变化强行拉回,也不跨重开持久(重开后会按设置重算)。
|
||||
initTagPriorityStrategy();
|
||||
// 注:initTagPriorityStrategy() 已在 App.vue 启动时统一调用,此处不再重复。
|
||||
const manualOverride = ref(false); // 用户是否手动切换过标签(本次会话)
|
||||
let lastPkgname: string | null = null; // 上一次计算对应的应用 pkgname,用于识别"新打开/重开"
|
||||
watch(
|
||||
|
||||
@@ -424,13 +424,19 @@ const getIconUrl = (app: App) => {
|
||||
return `${APM_STORE_BASE_URL}/${finalArch}/${app.category}/${app.pkgname}/icon.png`;
|
||||
};
|
||||
|
||||
const canOpenDetail = (app: App) => {
|
||||
// 判断应用是否可打开详情页。满足以下任一条件即可展示"查看详情":
|
||||
// - 有明确分类(非 unknown):详情页可按分类加载元数据
|
||||
// - 有详细描述(more) / 官网(website) / 作者(author) 任一字段:详情有内容可展示
|
||||
// - 有截图(img_urls):详情页可渲染预览图
|
||||
// 这些字段缺失时详情页信息过空,故隐藏入口仅保留打开/卸载。
|
||||
const canOpenDetail = (app: App): boolean => {
|
||||
const hasCategory = app.category !== "unknown";
|
||||
const hasDescription = Boolean(app.more);
|
||||
const hasWebsite = Boolean(app.website);
|
||||
const hasAuthor = Boolean(app.author);
|
||||
const hasScreenshots = (app.img_urls?.length ?? 0) > 0;
|
||||
return (
|
||||
app.category !== "unknown" ||
|
||||
Boolean(app.more) ||
|
||||
Boolean(app.website) ||
|
||||
Boolean(app.author) ||
|
||||
(app.img_urls?.length ?? 0) > 0
|
||||
hasCategory || hasDescription || hasWebsite || hasAuthor || hasScreenshots
|
||||
);
|
||||
};
|
||||
|
||||
@@ -461,12 +467,19 @@ const searchFilteredApps = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const apmCount = computed(
|
||||
() => searchFilteredApps.value.filter((a) => hasOrigin(a, "apm")).length,
|
||||
);
|
||||
const sparkCount = computed(
|
||||
() => searchFilteredApps.value.filter((a) => hasOrigin(a, "spark")).length,
|
||||
);
|
||||
// 对搜索过滤后的列表做单次遍历,同时统计 APM / Spark 各自安装数。
|
||||
// 同一 pkgname 同时装两种来源时各计一次(双来源计数),避免对 props.apps 多次 filter 遍历。
|
||||
const appStats = computed(() => {
|
||||
let apm = 0;
|
||||
let spark = 0;
|
||||
for (const a of searchFilteredApps.value) {
|
||||
if (hasOrigin(a, "apm")) apm++;
|
||||
if (hasOrigin(a, "spark")) spark++;
|
||||
}
|
||||
return { apm, spark };
|
||||
});
|
||||
const apmCount = computed(() => appStats.value.apm);
|
||||
const sparkCount = computed(() => appStats.value.spark);
|
||||
// 总数 = APM 包数 + Spark 包数(不同来源视为不同包,单独计数)
|
||||
const totalCount = computed(() => apmCount.value + sparkCount.value);
|
||||
|
||||
@@ -476,26 +489,23 @@ const filterOrigin = ref<"all" | "apm" | "spark">("all");
|
||||
// 搜索关键词(按名称/包名不区分大小写过滤已安装应用)
|
||||
const searchQuery = ref("");
|
||||
const filteredApps = computed(() => {
|
||||
// 1. 先按搜索关键词过滤
|
||||
// 单次遍历完成「搜索过滤 + 来源筛选」,避免多次 .filter() 创建中间数组。
|
||||
// 来源判定统一用 hasOrigin(兼顾双来源 origins 数组,不能退化为 a.origin)。
|
||||
const q = searchQuery.value.trim().toLowerCase();
|
||||
let list = props.apps;
|
||||
const originFilter = filterOrigin.value;
|
||||
const matched = props.apps.filter((a) => {
|
||||
if (originFilter === "apm" && !hasOrigin(a, "apm")) return false;
|
||||
if (originFilter === "spark" && !hasOrigin(a, "spark")) return false;
|
||||
if (q) {
|
||||
list = list.filter(
|
||||
(a) =>
|
||||
a.name.toLowerCase().includes(q) || a.pkgname.toLowerCase().includes(q),
|
||||
);
|
||||
const nameLower = a.name.toLowerCase();
|
||||
const pkgLower = a.pkgname.toLowerCase();
|
||||
if (!nameLower.includes(q) && !pkgLower.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// 2. 再按来源筛选(默认 all = 不过滤)
|
||||
if (filterOrigin.value === "apm") {
|
||||
list = list.filter((a) => hasOrigin(a, "apm"));
|
||||
} else if (filterOrigin.value === "spark") {
|
||||
list = list.filter((a) => hasOrigin(a, "spark"));
|
||||
}
|
||||
|
||||
// 3. 排序:APM 应用始终排在前面(默认全部视图也遵守此规则)
|
||||
// 返回新数组,避免修改原始 props.apps
|
||||
return [...list].sort((a, b) => {
|
||||
// 返回新数组排序:APM 应用始终排在前面(默认全部视图也遵守此规则)
|
||||
return [...matched].sort((a, b) => {
|
||||
const aApm = hasOrigin(a, "apm") ? 0 : 1;
|
||||
const bApm = hasOrigin(b, "apm") ? 0 : 1;
|
||||
if (aApm !== bApm) return aApm - bApm;
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* useAccountSync —— 登录、下载历史、云端同步/恢复、账号态联动、登出。
|
||||
*
|
||||
* 从原 App.vue 原样搬移(requireLogin / openLoginFromPrompt / handleFlarumLogin /
|
||||
* loadDownloadedHistory / refreshInstalledSyncCandidates / syncInstalledAppsToAccount /
|
||||
* syncInstalledAppsNow / openRestoreFromAccount / installCloudItems /
|
||||
* maybePromptInstalledSync / openUserManagement / handleLogout / openReviewUserProfile /
|
||||
* 各代次守卫 / clear*State),逻辑零改动。
|
||||
*
|
||||
* 共享状态来自 useAppState;currentUser / isLoggedIn 来自 authState;
|
||||
* clearFavoriteState 来自 useFavorites;onDetailInstall 来自 useDownloads。
|
||||
*/
|
||||
import type {
|
||||
FlarumLoginPayload,
|
||||
SyncedAppListItem,
|
||||
AppReview,
|
||||
App,
|
||||
} from "../global/typedefinition";
|
||||
import {
|
||||
downloadedApps,
|
||||
downloadedLoading,
|
||||
downloadedError,
|
||||
downloadedRequestGeneration,
|
||||
syncLoading,
|
||||
syncStatusMessage,
|
||||
syncRequestGeneration,
|
||||
syncCandidateApps,
|
||||
restoreLoading,
|
||||
restoreError,
|
||||
showRestoreModal,
|
||||
restoreItems,
|
||||
restoreRequestGeneration,
|
||||
installedSyncPromptShown,
|
||||
systemInfo,
|
||||
showUserManagementModal,
|
||||
isSidebarOpen,
|
||||
showLoginPrompt,
|
||||
showLoginModal,
|
||||
currentView,
|
||||
activeTab,
|
||||
selectedCategory,
|
||||
selectedReviewUserProfile,
|
||||
showReviewUserProfileModal,
|
||||
sparkAvailable,
|
||||
apmAvailable,
|
||||
storeFilter,
|
||||
apps,
|
||||
showLoginPromptMessage,
|
||||
loginLoading,
|
||||
loginError,
|
||||
} from "./useAppState";
|
||||
import { isLoggedIn, currentUser } from "../global/authState";
|
||||
import {
|
||||
setInstalledSyncEnabled,
|
||||
loadInstalledSyncPreference,
|
||||
installedSyncEnabled,
|
||||
} from "../global/accountSyncState";
|
||||
import {
|
||||
exchangeFlarumToken,
|
||||
listDownloadedApps,
|
||||
uploadSyncedAppList,
|
||||
fetchSyncedAppList,
|
||||
} from "../modules/backendApi";
|
||||
import { requestFlarumToken } from "../modules/flarumAuth";
|
||||
import {
|
||||
buildSyncItems,
|
||||
mergeInstalledApps,
|
||||
resolveCloudInstallCandidate,
|
||||
} from "../modules/appListSync";
|
||||
import { isOriginEnabled } from "../modules/storeFilter";
|
||||
import { clearFavoriteState } from "./useFavorites";
|
||||
import { onDetailInstall } from "./useDownloads";
|
||||
import { registerRequireLogin } from "./useAppDetail";
|
||||
import { registerRequireLogin as registerRequireLoginFav } from "./useFavorites";
|
||||
|
||||
// requireLogin 供 useAppDetail / useFavorites / 本模块内部使用;登记到其它 composable
|
||||
export const requireLogin = (message: string): boolean => {
|
||||
if (isLoggedIn.value) return true;
|
||||
showLoginPromptMessage.value = message;
|
||||
showLoginPrompt.value = true;
|
||||
return false;
|
||||
};
|
||||
registerRequireLogin(requireLogin);
|
||||
registerRequireLoginFav(requireLogin);
|
||||
|
||||
const openLoginFromPrompt = () => {
|
||||
showLoginPrompt.value = false;
|
||||
showLoginModal.value = true;
|
||||
};
|
||||
|
||||
const clearDownloadedState = () => {
|
||||
downloadedRequestGeneration.value += 1;
|
||||
downloadedApps.value = [];
|
||||
downloadedLoading.value = false;
|
||||
downloadedError.value = "";
|
||||
};
|
||||
|
||||
const clearRestoreState = () => {
|
||||
restoreRequestGeneration.value += 1;
|
||||
restoreItems.value = [];
|
||||
restoreLoading.value = false;
|
||||
restoreError.value = "";
|
||||
showRestoreModal.value = false;
|
||||
};
|
||||
|
||||
const clearInstalledSyncState = () => {
|
||||
syncRequestGeneration.value += 1;
|
||||
syncLoading.value = false;
|
||||
syncStatusMessage.value = "";
|
||||
syncCandidateApps.value = [];
|
||||
};
|
||||
|
||||
const nextDownloadedRequestGeneration = (): number => {
|
||||
downloadedRequestGeneration.value += 1;
|
||||
return downloadedRequestGeneration.value;
|
||||
};
|
||||
|
||||
const isCurrentDownloadedRequest = (
|
||||
generation: number,
|
||||
userId: number,
|
||||
): boolean =>
|
||||
downloadedRequestGeneration.value === generation &&
|
||||
currentUser.value?.id === userId;
|
||||
|
||||
const isCurrentRestoreRequest = (generation: number, userId: number): boolean =>
|
||||
restoreRequestGeneration.value === generation &&
|
||||
currentUser.value?.id === userId;
|
||||
|
||||
const handleLogout = () => {
|
||||
// 调用 authState 的 logout(此处通过注入,保持 authState 为唯一会话源)
|
||||
logoutRef();
|
||||
pendingDownloadRecordsClear();
|
||||
clearFavoriteState();
|
||||
clearDownloadedState();
|
||||
clearRestoreState();
|
||||
clearInstalledSyncState();
|
||||
loadInstalledSyncPreference(null);
|
||||
showLoginModal.value = false;
|
||||
showLoginPrompt.value = false;
|
||||
isSidebarOpen.value = false;
|
||||
showUserManagementModal.value = false;
|
||||
if (currentView.value === "favorites") {
|
||||
currentView.value = "default";
|
||||
activeTab.value = "home";
|
||||
selectedCategory.value = "all";
|
||||
}
|
||||
};
|
||||
|
||||
const handleFlarumLogin = async (payload: FlarumLoginPayload) => {
|
||||
loginLoading.value = true;
|
||||
loginError.value = "";
|
||||
|
||||
try {
|
||||
const flarumToken = await requestFlarumToken(payload);
|
||||
const session = await exchangeFlarumToken({
|
||||
flarumUserId: flarumToken.userId,
|
||||
flarumToken: flarumToken.token,
|
||||
});
|
||||
setAuthSessionRef(session);
|
||||
clearInstalledSyncState();
|
||||
loadInstalledSyncPreference(session.user.id);
|
||||
showLoginModal.value = false;
|
||||
} catch (error: unknown) {
|
||||
loginError.value = (error as Error)?.message || "登录失败,请稍后重试";
|
||||
} finally {
|
||||
loginLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadDownloadedHistory = async (): Promise<void> => {
|
||||
if (!requireLogin("请登录后查看和管理账号信息。")) return;
|
||||
const userId = currentUser.value?.id;
|
||||
if (userId === undefined) return;
|
||||
const generation = nextDownloadedRequestGeneration();
|
||||
|
||||
downloadedLoading.value = true;
|
||||
downloadedError.value = "";
|
||||
try {
|
||||
const result = await listDownloadedApps(1, 50);
|
||||
if (!isCurrentDownloadedRequest(generation, userId)) return;
|
||||
downloadedApps.value = result.items;
|
||||
} catch (error: unknown) {
|
||||
if (!isCurrentDownloadedRequest(generation, userId)) return;
|
||||
downloadedApps.value = [];
|
||||
downloadedError.value = (error as Error)?.message || "读取下载历史失败";
|
||||
} finally {
|
||||
if (isCurrentDownloadedRequest(generation, userId)) {
|
||||
downloadedLoading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const refreshInstalledSyncCandidates = async (
|
||||
isCurrentRequest: () => boolean,
|
||||
): Promise<boolean> => {
|
||||
const origins: Array<"spark" | "apm"> = [];
|
||||
if (isOriginEnabled(storeFilter.value, "spark") && sparkAvailable.value) {
|
||||
origins.push("spark");
|
||||
}
|
||||
if (isOriginEnabled(storeFilter.value, "apm") && apmAvailable.value) {
|
||||
origins.push("apm");
|
||||
}
|
||||
|
||||
const refreshedApps: App[] = [];
|
||||
await Promise.all(
|
||||
origins.map(async (origin) => {
|
||||
const pkgnameList =
|
||||
origin === "spark"
|
||||
? apps.value
|
||||
.filter((app) => app.origin === "spark")
|
||||
.map((app) => app.pkgname)
|
||||
: undefined;
|
||||
const result = await window.ipcRenderer.invoke("list-installed", {
|
||||
origin,
|
||||
pkgnameList,
|
||||
});
|
||||
if (!result?.success) return;
|
||||
|
||||
const appList = Array.isArray(result?.apps) ? result.apps : [];
|
||||
for (const rawApp of appList) {
|
||||
// 运行时类型守卫:避免后端字段缺失时下游访问 undefined
|
||||
if (!isInstalledAppInfoRef(rawApp)) continue;
|
||||
const appInfo = mapInstalledAppToCatalogAppRef(rawApp, origin);
|
||||
if (appInfo) refreshedApps.push(appInfo);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (!isCurrentRequest()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
syncCandidateApps.value = mergeInstalledApps(
|
||||
syncCandidateApps.value,
|
||||
refreshedApps,
|
||||
origins,
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
const syncInstalledAppsToAccount = async (): Promise<void> => {
|
||||
if (!requireLogin("云端同步需要登录星火账号。")) return;
|
||||
if (syncLoading.value) return;
|
||||
const userId = currentUser.value?.id;
|
||||
if (userId === undefined) return;
|
||||
const generation = syncRequestGeneration.value + 1;
|
||||
syncRequestGeneration.value = generation;
|
||||
syncLoading.value = true;
|
||||
syncStatusMessage.value = "";
|
||||
try {
|
||||
const refreshed = await refreshInstalledSyncCandidates(
|
||||
() =>
|
||||
syncRequestGeneration.value === generation &&
|
||||
currentUser.value?.id === userId,
|
||||
);
|
||||
if (!refreshed) return;
|
||||
const items = buildSyncItems(syncCandidateApps.value);
|
||||
await uploadSyncedAppList({
|
||||
clientArch: window.apm_store.arch || "amd64",
|
||||
distro: systemInfo.value.distro,
|
||||
items,
|
||||
});
|
||||
if (
|
||||
syncRequestGeneration.value !== generation ||
|
||||
currentUser.value?.id !== userId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
downloadedError.value = "";
|
||||
syncStatusMessage.value = "同步完成";
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
syncRequestGeneration.value !== generation ||
|
||||
currentUser.value?.id !== userId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
downloadedError.value = (error as Error)?.message || "同步已安装应用失败";
|
||||
syncStatusMessage.value = downloadedError.value;
|
||||
} finally {
|
||||
if (
|
||||
syncRequestGeneration.value === generation &&
|
||||
currentUser.value?.id === userId
|
||||
) {
|
||||
syncLoading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const syncInstalledAppsNow = (): void => {
|
||||
void syncInstalledAppsToAccount();
|
||||
};
|
||||
|
||||
const openRestoreFromAccount = async (): Promise<void> => {
|
||||
if (!requireLogin("云端同步需要登录星火账号。")) return;
|
||||
const userId = currentUser.value?.id;
|
||||
if (userId === undefined) return;
|
||||
const generation = restoreRequestGeneration.value + 1;
|
||||
restoreRequestGeneration.value = generation;
|
||||
showRestoreModal.value = true;
|
||||
restoreLoading.value = true;
|
||||
restoreError.value = "";
|
||||
restoreItems.value = [];
|
||||
try {
|
||||
const refreshed = await refreshInstalledSyncCandidates(() =>
|
||||
isCurrentRestoreRequest(generation, userId),
|
||||
);
|
||||
if (!refreshed) return;
|
||||
const result = await fetchSyncedAppList();
|
||||
if (!isCurrentRestoreRequest(generation, userId)) return;
|
||||
restoreItems.value = result?.items || [];
|
||||
} catch (error: unknown) {
|
||||
if (!isCurrentRestoreRequest(generation, userId)) return;
|
||||
restoreError.value = (error as Error)?.message || "读取云端应用列表失败";
|
||||
} finally {
|
||||
if (isCurrentRestoreRequest(generation, userId)) {
|
||||
restoreLoading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const installCloudItems = (items: SyncedAppListItem[]): void => {
|
||||
for (const item of items) {
|
||||
const app = resolveCloudInstallCandidate(item, apps.value);
|
||||
if (!app) continue;
|
||||
void onDetailInstall(app);
|
||||
}
|
||||
showRestoreModal.value = false;
|
||||
};
|
||||
|
||||
const maybePromptInstalledSync = async (): Promise<void> => {
|
||||
if (
|
||||
import.meta.env.MODE === "test" ||
|
||||
!isLoggedIn.value ||
|
||||
installedSyncPromptShown.value ||
|
||||
installedSyncEnabled.value !== null
|
||||
) {
|
||||
if (isLoggedIn.value && installedSyncEnabled.value === true) {
|
||||
await syncInstalledAppsToAccount();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
installedSyncPromptShown.value = true;
|
||||
const enabled = window.confirm(
|
||||
"是否启用已安装应用列表自动同步到星火账号?仅同步商店识别的非依赖应用。",
|
||||
);
|
||||
setInstalledSyncEnabled(enabled);
|
||||
if (enabled) await syncInstalledAppsToAccount();
|
||||
};
|
||||
|
||||
const openUserManagement = async () => {
|
||||
if (!requireLogin("请登录后查看和管理账号信息。")) return;
|
||||
showUserManagementModal.value = true;
|
||||
isSidebarOpen.value = false;
|
||||
showLoginPrompt.value = false;
|
||||
await loadDownloadedHistory();
|
||||
};
|
||||
|
||||
const openReviewUserProfile = (review: AppReview): void => {
|
||||
const current = currentUser.value;
|
||||
const isCurrentUser =
|
||||
review.isAuthor === true ||
|
||||
(review.userId !== undefined &&
|
||||
current?.id !== undefined &&
|
||||
review.userId === current.id);
|
||||
|
||||
selectedReviewUserProfile.value = {
|
||||
displayName: review.userDisplayName || "星火用户",
|
||||
username: isCurrentUser ? current?.username : undefined,
|
||||
avatarUrl:
|
||||
review.userAvatarUrl || (isCurrentUser ? current?.avatarUrl : undefined),
|
||||
coverUrl: isCurrentUser ? current?.coverUrl : undefined,
|
||||
forumGroups: isCurrentUser ? current?.forumGroups : undefined,
|
||||
};
|
||||
showReviewUserProfileModal.value = true;
|
||||
};
|
||||
|
||||
// 以下引用通过注入方式从 authState / useInstalledApps 获取,避免循环依赖:
|
||||
// logout / setAuthSession(authState)、isInstalledAppInfo / mapInstalledAppToCatalogApp
|
||||
// (useInstalledApps)、pendingDownloadRecords.clear(useDownloads)
|
||||
let logoutRef: () => void = () => undefined;
|
||||
export const registerLogout = (fn: () => void) => {
|
||||
logoutRef = fn;
|
||||
};
|
||||
let setAuthSessionRef: (session: unknown) => void = () => undefined;
|
||||
export const registerSetAuthSession = (fn: (session: unknown) => void) => {
|
||||
setAuthSessionRef = fn;
|
||||
};
|
||||
let isInstalledAppInfoRef: (value: unknown) => boolean = () => false;
|
||||
export const registerIsInstalledAppInfo = (fn: (value: unknown) => boolean) => {
|
||||
isInstalledAppInfoRef = fn;
|
||||
};
|
||||
let mapInstalledAppToCatalogAppRef: (
|
||||
app: unknown,
|
||||
origin: "spark" | "apm",
|
||||
) => App | null = () => null;
|
||||
export const registerMapInstalledAppToCatalogApp = (
|
||||
fn: (app: unknown, origin: "spark" | "apm") => App | null,
|
||||
) => {
|
||||
mapInstalledAppToCatalogAppRef = fn;
|
||||
};
|
||||
let pendingDownloadRecordsClear: () => void = () => undefined;
|
||||
export const registerPendingDownloadRecordsClear = (fn: () => void) => {
|
||||
pendingDownloadRecordsClear = fn;
|
||||
};
|
||||
|
||||
export {
|
||||
openLoginFromPrompt,
|
||||
handleLogout,
|
||||
handleFlarumLogin,
|
||||
loadDownloadedHistory,
|
||||
refreshInstalledSyncCandidates,
|
||||
syncInstalledAppsToAccount,
|
||||
syncInstalledAppsNow,
|
||||
openRestoreFromAccount,
|
||||
installCloudItems,
|
||||
maybePromptInstalledSync,
|
||||
openUserManagement,
|
||||
openReviewUserProfile,
|
||||
clearDownloadedState,
|
||||
clearRestoreState,
|
||||
clearInstalledSyncState,
|
||||
};
|
||||
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* useAppDetail —— 应用详情弹窗的获取与打开/关闭逻辑。
|
||||
*
|
||||
* 从原 App.vue 原样搬移(fetchAppFromStore / openDetail / openDetailFromInstalled /
|
||||
* createFallbackApp / checkAppInstalled / loadScreenshots / closeDetail /
|
||||
* openScreenPreview / closeScreenPreview / prevScreen / nextScreen /
|
||||
* selectDetailOrigin / handleDetailRequestLogin / currentDisplayApp /
|
||||
* currentReviewAppKey / currentReviewTags),逻辑零改动。
|
||||
*
|
||||
* 共享状态来自 useAppState;loadFavoriteMetadataForDetail 来自 useFavorites。
|
||||
*/
|
||||
import { computed, nextTick } from "vue";
|
||||
import type { App } from "../global/typedefinition";
|
||||
import {
|
||||
apps,
|
||||
currentApp,
|
||||
currentAppSparkInstalled,
|
||||
currentAppApmInstalled,
|
||||
showModal,
|
||||
showPreview,
|
||||
currentScreenIndex,
|
||||
screenshots,
|
||||
storeFilter,
|
||||
favoriteFolders,
|
||||
favoriteLoading,
|
||||
systemInfo,
|
||||
} from "./useAppState";
|
||||
import { isLoggedIn } from "../global/authState";
|
||||
import { APM_STORE_BASE_URL } from "../global/storeConfig";
|
||||
import { getHybridDefaultOrigin } from "../global/storeConfig";
|
||||
import {
|
||||
getDisplayApp,
|
||||
buildReviewAppKey,
|
||||
buildReviewTags,
|
||||
} from "../modules/appIdentity";
|
||||
import { loadFavoriteMetadataForDetail } from "./useFavorites";
|
||||
import type { ReviewTags } from "../global/typedefinition";
|
||||
import type { Ref } from "vue";
|
||||
|
||||
const clientArch = computed(() => window.apm_store.arch || "amd64");
|
||||
|
||||
const currentDisplayApp = computed(() => getDisplayApp(currentApp.value));
|
||||
|
||||
const currentReviewAppKey = computed(() => {
|
||||
if (!currentDisplayApp.value) return "";
|
||||
return buildReviewAppKey(currentDisplayApp.value, clientArch.value);
|
||||
});
|
||||
|
||||
const currentReviewTags = computed<ReviewTags | null>(() => {
|
||||
if (!currentDisplayApp.value) return null;
|
||||
return buildReviewTags(currentDisplayApp.value, {
|
||||
clientArch: clientArch.value,
|
||||
distro: systemInfo.value.distro,
|
||||
});
|
||||
});
|
||||
|
||||
// 从仓库获取应用详细信息的辅助函数
|
||||
const fetchAppFromStore = async (
|
||||
pkgname: string,
|
||||
category: string,
|
||||
origin: "spark" | "apm",
|
||||
): Promise<App | null> => {
|
||||
try {
|
||||
const arch = window.apm_store.arch || "amd64";
|
||||
const finalArch = origin === "spark" ? `${arch}-store` : `${arch}-apm`;
|
||||
// 路径参数需编码,避免特殊字符破坏请求路径或造成路径穿越
|
||||
const appJsonUrl = `${APM_STORE_BASE_URL}/${finalArch}/${encodeURIComponent(
|
||||
category,
|
||||
)}/${encodeURIComponent(pkgname)}/app.json`;
|
||||
// 接入 rootAbortController.signal,确保组件卸载/详情关闭时请求可被取消,
|
||||
// 避免竞态与内存泄漏(onUnmounted 会 abort 该 controller)
|
||||
const response = await fetch(appJsonUrl, {
|
||||
signal: (await import("./useHttp")).rootAbortController.signal,
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const appJson = await response.json();
|
||||
// img_urls 可能为字符串形式的 JSON,解析失败时安全回退为空数组
|
||||
const parsedImgUrls = (() => {
|
||||
if (typeof appJson.img_urls === "string") {
|
||||
try {
|
||||
return JSON.parse(appJson.img_urls) as string[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return (appJson.img_urls as string[]) || [];
|
||||
})();
|
||||
return {
|
||||
name: appJson.Name || "",
|
||||
pkgname: appJson.Pkgname || pkgname,
|
||||
version: appJson.Version || "",
|
||||
filename: appJson.Filename || "",
|
||||
torrent_address: appJson.Torrent_address || "",
|
||||
author: appJson.Author || "",
|
||||
contributor: appJson.Contributor || "",
|
||||
website: appJson.Website || "",
|
||||
update: appJson.Update || "",
|
||||
size: appJson.Size || "",
|
||||
more: appJson.More || "",
|
||||
tags: appJson.Tags || "",
|
||||
img_urls: parsedImgUrls,
|
||||
icons: appJson.icons || "",
|
||||
category,
|
||||
origin,
|
||||
currentStatus: "not-installed",
|
||||
};
|
||||
} catch (e) {
|
||||
// 组件卸载/详情关闭触发 abort 时静默返回,避免刷 AbortError 日志
|
||||
if ((e as Error)?.name === "AbortError") return null;
|
||||
|
||||
console.warn(`Failed to fetch ${origin} app info for ${pkgname}`, e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 已安装应用页查看详情:复用所有应用页的合并详情视图(始终展示 APM/Spark 两种包)
|
||||
const openDetailFromInstalled = (app: App) => {
|
||||
openDetail({ ...app, _fromInstalled: true });
|
||||
};
|
||||
|
||||
// openDetail 输入类型:完整 App 或仅含必要字段的轻量对象(含内部来源标记)
|
||||
interface OpenDetailInput extends Partial<App> {
|
||||
pkgname?: string;
|
||||
category?: string;
|
||||
_fromHomeView?: boolean;
|
||||
_fromInstalled?: boolean;
|
||||
_fromDeepLink?: boolean;
|
||||
origin?: "spark" | "apm";
|
||||
}
|
||||
|
||||
// 提取远程/本地均无匹配时的回退 App 构造(两处兜底分支共用,避免重复字段映射)
|
||||
const createFallbackApp = (
|
||||
raw: Record<string, unknown>,
|
||||
pkgname: string,
|
||||
category: string,
|
||||
): App => ({
|
||||
name: (raw.name as string) || "",
|
||||
pkgname,
|
||||
version: (raw.version as string) || "",
|
||||
filename: (raw.filename as string) || "",
|
||||
category,
|
||||
torrent_address: "",
|
||||
author: "",
|
||||
contributor: "",
|
||||
website: "",
|
||||
update: "",
|
||||
size: "",
|
||||
more: (raw.more as string) || "",
|
||||
tags: "",
|
||||
img_urls: [],
|
||||
icons: "",
|
||||
origin: (raw.origin as "spark" | "apm") || "apm",
|
||||
currentStatus: "not-installed",
|
||||
});
|
||||
|
||||
const openDetail = async (app: App | OpenDetailInput) => {
|
||||
// 提取 pkgname 和 category(必须存在)
|
||||
const pkgname = app?.pkgname;
|
||||
if (!pkgname) {
|
||||
console.warn("openDetail called without pkgname");
|
||||
return;
|
||||
}
|
||||
const category = app.category || "unknown";
|
||||
// 检查是否来自 HomeView 或 DeepLink(需要重新获取完整信息)
|
||||
// 内部来源标记仅存在于轻量输入对象上,按 OpenDetailInput 读取以兼容联合类型
|
||||
const marker = app as OpenDetailInput;
|
||||
const fromHomeView = marker._fromHomeView === true;
|
||||
const fromDeepLink = marker._fromDeepLink === true;
|
||||
// 已安装应用页:始终按"所有应用页"的方式双来源拉取并合并展示(不移除另一类型)
|
||||
const fromInstalled = marker._fromInstalled === true;
|
||||
const needFetchFromStore = fromHomeView || fromDeepLink || fromInstalled;
|
||||
|
||||
// 首先尝试从当前已经处理好(合并/筛选)的 filteredApps 中查找
|
||||
// 优先匹配点击来源 origin(例如排行点击 APM 应用时不应误匹配到 Spark 版)
|
||||
const clickedOrigin = app.origin as "spark" | "apm" | undefined;
|
||||
let fullApp = filteredAppsFind(pkgname, clickedOrigin);
|
||||
// 如果没找到,回退到全局 apps 中查找(同样优先 origin)
|
||||
if (!fullApp) {
|
||||
fullApp = apps.value.find(
|
||||
(a) =>
|
||||
a.pkgname === pkgname && (!clickedOrigin || a.origin === clickedOrigin),
|
||||
);
|
||||
}
|
||||
// 仍无匹配则退化为仅按 pkgname 匹配(兼容无 origin 的场景,如搜索结果)
|
||||
if (!fullApp) {
|
||||
fullApp =
|
||||
filteredAppsFind(pkgname, undefined) ||
|
||||
apps.value.find((a) => a.pkgname === pkgname);
|
||||
}
|
||||
|
||||
let finalApp: App;
|
||||
|
||||
// 来自 HomeView 或 DeepLink 的应用需要重新从仓库获取完整信息
|
||||
if (needFetchFromStore) {
|
||||
// 从 Spark 和 APM 仓库获取完整的应用信息
|
||||
// 已安装页忽略当前商店单一模式限制,始终尝试拉取两种来源,保证另一类型不被隐藏
|
||||
let [sparkApp, apmApp] = await Promise.all([
|
||||
fromInstalled || storeFilter.value !== "apm"
|
||||
? fetchAppFromStore(pkgname, category, "spark")
|
||||
: Promise.resolve(null),
|
||||
fromInstalled || storeFilter.value !== "spark"
|
||||
? fetchAppFromStore(pkgname, category, "apm")
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
// 已安装页:若某来源仓库拉取失败(如分类不匹配),用本地已合并的应用补全,避免丢失另一类型
|
||||
if (fromInstalled && fullApp && fullApp.isMerged) {
|
||||
const merged = fullApp as App;
|
||||
if (!sparkApp && merged.sparkApp) sparkApp = merged.sparkApp;
|
||||
if (!apmApp && merged.apmApp) apmApp = merged.apmApp;
|
||||
}
|
||||
|
||||
// 构建合并的应用对象
|
||||
if (sparkApp || apmApp) {
|
||||
// 如果两个仓库都有这个应用,创建合并对象
|
||||
if (sparkApp && apmApp) {
|
||||
// 优先遵从点击来源 origin;无点击来源时再按优先级配置决定默认显示
|
||||
const defaultOrigin =
|
||||
clickedOrigin && (clickedOrigin === "spark" ? sparkApp : apmApp)
|
||||
? clickedOrigin
|
||||
: getHybridDefaultOrigin(sparkApp);
|
||||
finalApp = {
|
||||
...(defaultOrigin === "spark" ? sparkApp : apmApp), // 根据优先级选择主显示
|
||||
isMerged: true,
|
||||
sparkApp: sparkApp,
|
||||
apmApp: apmApp,
|
||||
viewingOrigin: defaultOrigin, // 默认查看来源版本
|
||||
};
|
||||
} else if (sparkApp) {
|
||||
finalApp = sparkApp;
|
||||
} else {
|
||||
finalApp = apmApp!;
|
||||
}
|
||||
} else if (fullApp) {
|
||||
finalApp = fullApp;
|
||||
} else {
|
||||
// 两个仓库都没有找到,且本地也没有,构造一个最小可用的 App 对象
|
||||
finalApp = createFallbackApp(
|
||||
app as Record<string, unknown>,
|
||||
pkgname,
|
||||
category,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 非 HomeView 来源,使用原来的逻辑
|
||||
if (fullApp) {
|
||||
finalApp = fullApp;
|
||||
} else {
|
||||
// 构造一个最小可用的 App 对象
|
||||
finalApp = createFallbackApp(
|
||||
app as Record<string, unknown>,
|
||||
pkgname,
|
||||
category,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 Spark/APM 安装状态,已安装的版本优先展示
|
||||
if (finalApp.isMerged && (finalApp.sparkApp || finalApp.apmApp)) {
|
||||
const [sparkInstalled, apmInstalled] = await Promise.all([
|
||||
finalApp.sparkApp
|
||||
? (window.ipcRenderer.invoke("check-installed", {
|
||||
pkgname: finalApp.sparkApp.pkgname,
|
||||
origin: "spark",
|
||||
}) as Promise<boolean>)
|
||||
: Promise.resolve(false),
|
||||
finalApp.apmApp
|
||||
? (window.ipcRenderer.invoke("check-installed", {
|
||||
pkgname: finalApp.apmApp.pkgname,
|
||||
origin: "apm",
|
||||
}) as Promise<boolean>)
|
||||
: Promise.resolve(false),
|
||||
]);
|
||||
// 来源默认展示规则:
|
||||
// 1) 已安装页打开:按安装类型打开。
|
||||
// - 仅一个来源已安装 → 强制展示该已装来源(无需策略覆盖)
|
||||
// - 多个来源已安装 → 按「设置的优先标签」打开(策略)
|
||||
// 2) 其他页面:一律按「设置的优先标签」打开(策略 > 服务端混合默认),不强制。
|
||||
// 注:forceViewingOrigin 仅用于「已安装页 + 唯一安装来源」这一显式安装类型场景,
|
||||
// 其余情况都不强制,交由详情页按用户标签策略重算。
|
||||
let forceOrigin: "spark" | "apm" | undefined = undefined;
|
||||
if (fromInstalled) {
|
||||
const installedOrigins = (app as Record<string, unknown>).origins as
|
||||
| Array<"spark" | "apm">
|
||||
| undefined;
|
||||
if (installedOrigins && installedOrigins.length === 1) {
|
||||
// 单来源安装:按安装类型强制
|
||||
forceOrigin = installedOrigins[0];
|
||||
} else if (installedOrigins && installedOrigins.length > 1) {
|
||||
// 多来源安装:交由策略(按设置优先标签),不强制
|
||||
forceOrigin = undefined;
|
||||
} else {
|
||||
// origins 未随事件携带时,用 IPC 检测结果兜底判断安装类型
|
||||
if (sparkInstalled && !apmInstalled) forceOrigin = "spark";
|
||||
else if (apmInstalled && !sparkInstalled) forceOrigin = "apm";
|
||||
}
|
||||
}
|
||||
// 非强制分支:清除可能由「已安装页」入口遗留的 forceViewingOrigin 粘性标志,
|
||||
// 避免同一应用从其他页面再次打开时被锁死在旧来源(表现为设置切换不生效)。
|
||||
finalApp.forceViewingOrigin = false;
|
||||
if (
|
||||
forceOrigin &&
|
||||
(forceOrigin === "spark" ? finalApp.sparkApp : finalApp.apmApp)
|
||||
) {
|
||||
// 已安装页 + 唯一安装来源 → 强制展示该安装类型,优先级高于用户标签策略
|
||||
finalApp.viewingOrigin = forceOrigin;
|
||||
finalApp.forceViewingOrigin = true;
|
||||
} else if (sparkInstalled && !apmInstalled) {
|
||||
// 仅 Spark 安装(其他页面):默认回退展示已装版本,不强制(仍受用户策略覆盖)
|
||||
finalApp.viewingOrigin = "spark";
|
||||
} else if (apmInstalled && !sparkInstalled) {
|
||||
finalApp.viewingOrigin = "apm";
|
||||
} else {
|
||||
// 都安装/都未安装且未指定来源:交由「标签优先显示策略」决定默认展示。
|
||||
// 此处仅写入混合默认作为回退(供 appIdentity / 截图等下游使用),
|
||||
// 但不置 forceViewingOrigin,详情页会按用户策略重算。
|
||||
finalApp.viewingOrigin = getHybridDefaultOrigin(
|
||||
finalApp.sparkApp || finalApp,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const displayAppForScreenshots =
|
||||
finalApp.viewingOrigin !== undefined && finalApp.isMerged
|
||||
? ((finalApp.viewingOrigin === "spark"
|
||||
? finalApp.sparkApp
|
||||
: finalApp.apmApp) ?? finalApp)
|
||||
: finalApp;
|
||||
|
||||
currentApp.value = finalApp;
|
||||
currentScreenIndex.value = 0;
|
||||
loadScreenshots(displayAppForScreenshots);
|
||||
showModal.value = true;
|
||||
|
||||
currentAppSparkInstalled.value = false;
|
||||
currentAppApmInstalled.value = false;
|
||||
checkAppInstalled(finalApp);
|
||||
if (
|
||||
isLoggedIn.value &&
|
||||
favoriteFolders.value.length === 0 &&
|
||||
!favoriteLoading.value
|
||||
) {
|
||||
void loadFavoriteMetadataForDetail();
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
const modal = document.querySelector(
|
||||
'[data-app-modal="detail"] [data-testid="detail-scroll-content"]',
|
||||
);
|
||||
if (modal) (modal as HTMLElement).scrollTop = 0;
|
||||
});
|
||||
};
|
||||
|
||||
const checkAppInstalled = (app: App) => {
|
||||
if (app.isMerged) {
|
||||
if (app.sparkApp) {
|
||||
window.ipcRenderer
|
||||
.invoke("check-installed", {
|
||||
pkgname: app.sparkApp.pkgname,
|
||||
origin: "spark",
|
||||
})
|
||||
.then((isInstalled: boolean) => {
|
||||
currentAppSparkInstalled.value = isInstalled;
|
||||
});
|
||||
}
|
||||
if (app.apmApp) {
|
||||
window.ipcRenderer
|
||||
.invoke("check-installed", {
|
||||
pkgname: app.apmApp.pkgname,
|
||||
origin: "apm",
|
||||
})
|
||||
.then((isInstalled: boolean) => {
|
||||
currentAppApmInstalled.value = isInstalled;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
window.ipcRenderer
|
||||
.invoke("check-installed", { pkgname: app.pkgname, origin: app.origin })
|
||||
.then((isInstalled: boolean) => {
|
||||
if (app.origin === "spark") {
|
||||
currentAppSparkInstalled.value = isInstalled;
|
||||
} else {
|
||||
currentAppApmInstalled.value = isInstalled;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const loadScreenshots = (app: App) => {
|
||||
screenshots.value = [];
|
||||
const arch = window.apm_store.arch || "amd64";
|
||||
const finalArch = app.origin === "spark" ? `${arch}-store` : `${arch}-apm`;
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const screenshotUrl = `${APM_STORE_BASE_URL}/${finalArch}/${app.category}/${app.pkgname}/screen_${i}.png`;
|
||||
screenshots.value.push(screenshotUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const closeDetail = () => {
|
||||
showModal.value = false;
|
||||
currentApp.value = null;
|
||||
};
|
||||
|
||||
const openScreenPreview = (index: number) => {
|
||||
currentScreenIndex.value = index;
|
||||
showPreview.value = true;
|
||||
};
|
||||
|
||||
const closeScreenPreview = () => {
|
||||
showPreview.value = false;
|
||||
};
|
||||
|
||||
const prevScreen = () => {
|
||||
if (currentScreenIndex.value > 0) {
|
||||
currentScreenIndex.value--;
|
||||
}
|
||||
};
|
||||
|
||||
const nextScreen = () => {
|
||||
if (currentScreenIndex.value < screenshots.value.length - 1) {
|
||||
currentScreenIndex.value++;
|
||||
}
|
||||
};
|
||||
|
||||
const selectDetailOrigin = (origin: "spark" | "apm") => {
|
||||
if (currentApp.value?.isMerged) {
|
||||
currentApp.value = { ...currentApp.value, viewingOrigin: origin };
|
||||
}
|
||||
};
|
||||
|
||||
const handleDetailRequestLogin = (message: string) => {
|
||||
requireLoginRef(message);
|
||||
};
|
||||
|
||||
// requireLogin 定义在 useAccountSync,这里通过函数引用注入,避免循环依赖。
|
||||
// 使用模块级可赋值引用,由 useAccountSync 在初始化时登记。
|
||||
let requireLoginRef: (message: string) => boolean = () => true;
|
||||
export const registerRequireLogin = (fn: (message: string) => boolean) => {
|
||||
requireLoginRef = fn;
|
||||
};
|
||||
|
||||
// filteredApps 由 App.vue 汇总派生(依赖多个 composable 状态),此处仅做查找辅助,
|
||||
// 实际引用由 App.vue 通过 setFilteredApps 注入,保证与改造前完全一致。
|
||||
let filteredAppsSource: Ref<App[]> | null = null;
|
||||
export const setFilteredApps = (appsRef: Ref<App[]>) => {
|
||||
filteredAppsSource = appsRef;
|
||||
};
|
||||
const filteredAppsFind = (
|
||||
pkgname: string,
|
||||
origin: "spark" | "apm" | undefined,
|
||||
): App | undefined =>
|
||||
filteredAppsSource?.value.find(
|
||||
(a) => a.pkgname === pkgname && (!origin || a.origin === origin),
|
||||
);
|
||||
|
||||
export {
|
||||
clientArch,
|
||||
currentDisplayApp,
|
||||
currentReviewAppKey,
|
||||
currentReviewTags,
|
||||
fetchAppFromStore,
|
||||
openDetail,
|
||||
openDetailFromInstalled,
|
||||
createFallbackApp,
|
||||
checkAppInstalled,
|
||||
loadScreenshots,
|
||||
closeDetail,
|
||||
openScreenPreview,
|
||||
closeScreenPreview,
|
||||
prevScreen,
|
||||
nextScreen,
|
||||
selectDetailOrigin,
|
||||
handleDetailRequestLogin,
|
||||
};
|
||||
export type { OpenDetailInput };
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* useAppState —— 跨 composable 共享的全局原始响应式状态(单例)。
|
||||
*
|
||||
* 设计说明(避免循环依赖与重复实例化):
|
||||
* App.vue 原文件中的逻辑被拆分到多个 composable(useCatalog / useRanking /
|
||||
* useAppDetail / useInstalledApps / useFavorites / useAccountSync / useDownloads)。
|
||||
* 这些 composable 之间存在大量共享状态(apps / currentApp / installedApps /
|
||||
* favorite* / 各类弹窗开关等)。若各自持有会出现多实例与状态不同步,并引发循环依赖。
|
||||
*
|
||||
* 故把"贯穿整个应用生命周期的全局原始状态"集中在本模块以单例形式导出,
|
||||
* 所有 composable 与 App.vue 均从本模块取用同一份 ref 实例。
|
||||
* 这与现有 global/authState.ts、global/storeConfig.ts 的单例风格一致。
|
||||
*
|
||||
* 本模块**只持有状态与极少量纯派生 computed**,不含任何业务逻辑/副作用,
|
||||
* 确保拆分前后运行时行为完全一致(7 维度审计:功能/状态一致)。
|
||||
*
|
||||
* 注意:currentApp / currentAppSparkInstalled / currentAppApmInstalled 同时在
|
||||
* storeConfig 中导出(供其它模块直接引用)。本模块从 storeConfig 再导出以保持单一实例。
|
||||
*/
|
||||
import { ref, computed } from "vue";
|
||||
import type { Ref } from "vue";
|
||||
import type {
|
||||
App,
|
||||
AppJson,
|
||||
CategoryInfo,
|
||||
SidebarEntry,
|
||||
HomeLink,
|
||||
FavoriteFolder,
|
||||
FavoriteItem,
|
||||
InstalledAppInfo,
|
||||
ResolvedFavoriteItem,
|
||||
SystemInfo,
|
||||
DownloadedAppRecord,
|
||||
SyncedAppListItem,
|
||||
AppReview,
|
||||
ReviewUserProfile,
|
||||
DownloadItem,
|
||||
} from "../global/typedefinition";
|
||||
|
||||
// ===== 目录数据 =====
|
||||
export const apps: Ref<App[]> = ref([]);
|
||||
export const categories: Ref<Record<string, CategoryInfo>> = ref({});
|
||||
export const tabCategories: Ref<Record<string, Record<string, CategoryInfo>>> =
|
||||
ref({});
|
||||
export const tabApps: Ref<Record<string, App[]>> = ref({});
|
||||
export const loadingTabs = ref<Set<string>>(new Set());
|
||||
export const homeListUrls = ref<
|
||||
Record<string, { spark?: string; apm?: string }>
|
||||
>({});
|
||||
export const sidebarEntries: Ref<SidebarEntry[]> = ref([]);
|
||||
export const sparkAvailable = ref(false);
|
||||
export const apmAvailable = ref(false);
|
||||
export const storeFilter = ref<"spark" | "apm" | "both">("both");
|
||||
export const initialCatalogLoaded = ref(false);
|
||||
export const loading = ref(true);
|
||||
|
||||
// ===== 首页 / 排行 =====
|
||||
export const homeLinks = ref<HomeLink[]>([]);
|
||||
export const homeLoading = ref(false);
|
||||
export const homeError = ref("");
|
||||
export const apmRanking = ref<App[]>([]);
|
||||
export const sparkRanking = ref<App[]>([]);
|
||||
export const rankingLoading = ref(false);
|
||||
|
||||
// ===== 导航 =====
|
||||
export type MainView = "default" | "favorites";
|
||||
export const currentView = ref<MainView>("default");
|
||||
export const activeTab = ref("home");
|
||||
export const selectedCategory = ref("all");
|
||||
export const searchQuery = ref("");
|
||||
export const isSidebarOpen = ref(false);
|
||||
|
||||
// ===== 详情弹窗 =====
|
||||
export {
|
||||
currentApp,
|
||||
currentAppSparkInstalled,
|
||||
currentAppApmInstalled,
|
||||
} from "../global/storeConfig";
|
||||
export const showModal = ref(false);
|
||||
export const showPreview = ref(false);
|
||||
export const currentScreenIndex = ref(0);
|
||||
export const screenshots = ref<string[]>([]);
|
||||
|
||||
// ===== 已安装应用 =====
|
||||
export const showInstalledModal = ref(false);
|
||||
export const installedApps = ref<App[]>([]);
|
||||
export const installedLoading = ref(false);
|
||||
export const installedError = ref("");
|
||||
export const installedWarning = ref("");
|
||||
export const installedRefreshGeneration = ref(0);
|
||||
export const installedSyncPromptShown = ref(false);
|
||||
|
||||
// ===== 收藏夹 =====
|
||||
export const favoriteFolders = ref<FavoriteFolder[]>([]);
|
||||
export const activeFavoriteFolderId = ref<number | null>(null);
|
||||
export const favoriteItems = ref<FavoriteItem[]>([]);
|
||||
export const favoriteItemsByFolder = ref<Record<number, FavoriteItem[]>>({});
|
||||
export const showFavoriteSelector = ref(false);
|
||||
export const favoriteTargetApp = ref<App | null>(null);
|
||||
export const favoriteSelectorDraftFolderIds = ref<Array<
|
||||
number | "default"
|
||||
> | null>(null);
|
||||
export const favoriteLoading = ref(false);
|
||||
export const favoriteError = ref("");
|
||||
export const favoriteRequestGeneration = ref(0);
|
||||
|
||||
// ===== 账号 / 下载历史 / 云端同步 / 恢复 =====
|
||||
export const showUserManagementModal = ref(false);
|
||||
export const downloadedApps = ref<DownloadedAppRecord[]>([]);
|
||||
export const downloadedLoading = ref(false);
|
||||
export const downloadedError = ref("");
|
||||
export const downloadedRequestGeneration = ref(0);
|
||||
export const syncLoading = ref(false);
|
||||
export const syncStatusMessage = ref("");
|
||||
export const syncRequestGeneration = ref(0);
|
||||
export const syncCandidateApps = ref<App[]>([]);
|
||||
export const restoreLoading = ref(false);
|
||||
export const restoreError = ref("");
|
||||
export const showRestoreModal = ref(false);
|
||||
export const restoreItems = ref<SyncedAppListItem[]>([]);
|
||||
export const restoreRequestGeneration = ref(0);
|
||||
export const showLoginModal = ref(false);
|
||||
export const loginLoading = ref(false);
|
||||
export const loginError = ref("");
|
||||
export const showLoginPrompt = ref(false);
|
||||
export const loginPromptMessage = ref("请登录星火账号后继续操作。");
|
||||
export const showLoginPromptMessage = loginPromptMessage;
|
||||
export const showReviewUserProfileModal = ref(false);
|
||||
export const selectedReviewUserProfile = ref<ReviewUserProfile | null>(null);
|
||||
export const systemInfo = ref<SystemInfo>({ distro: "unknown" });
|
||||
|
||||
// ===== 下载队列 =====
|
||||
export { downloads } from "../global/downloadStatus";
|
||||
export const showDownloadDetailModal = ref(false);
|
||||
export const currentDownload: Ref<DownloadItem | null> = ref(null);
|
||||
|
||||
// ===== 其它弹窗 / 卸载 / 更新中心编排 =====
|
||||
export const showUninstallModal = ref(false);
|
||||
export const uninstallTargetApp: Ref<App | null> = ref(null);
|
||||
export const showAboutModal = ref(false);
|
||||
export const showSettingsModal = ref(false);
|
||||
export const showApmInstallDialog = ref(false);
|
||||
|
||||
// 可用来源 computed(供 storeFilter 相关判断复用)
|
||||
export const availableSources = computed(() => ({
|
||||
spark: sparkAvailable.value,
|
||||
apm: apmAvailable.value,
|
||||
}));
|
||||
|
||||
// 运算符别名(仅导出类型引用,避免重复定义)
|
||||
export type {
|
||||
App,
|
||||
AppJson,
|
||||
CategoryInfo,
|
||||
SidebarEntry,
|
||||
HomeLink,
|
||||
FavoriteFolder,
|
||||
FavoriteItem,
|
||||
InstalledAppInfo,
|
||||
ResolvedFavoriteItem,
|
||||
SystemInfo,
|
||||
DownloadedAppRecord,
|
||||
SyncedAppListItem,
|
||||
AppReview,
|
||||
ReviewUserProfile,
|
||||
DownloadItem,
|
||||
};
|
||||
@@ -0,0 +1,567 @@
|
||||
/**
|
||||
* useCatalog —— 应用目录 / 分类 / 侧边栏入口的数据加载。
|
||||
*
|
||||
* 从原 App.vue 原样搬移(loadCategories / loadSidebarConfig / loadTabCategories /
|
||||
* loadTabApps / loadApps / normalizeAppJson / loadHomeListEntries / loadHomeListApps /
|
||||
* preloadHomeListApps / preloadSidebarTabApps),逻辑零改动。
|
||||
*
|
||||
* 共享状态(apps / categories / tabCategories / tabApps / loadingTabs /
|
||||
* homeListUrls / sidebarEntries / initialCatalogLoaded)来自 useAppState 单例。
|
||||
*/
|
||||
import type {
|
||||
App,
|
||||
AppJson,
|
||||
CategoryInfo,
|
||||
SidebarEntry,
|
||||
} from "../global/typedefinition";
|
||||
import {
|
||||
apps,
|
||||
categories,
|
||||
tabCategories,
|
||||
tabApps,
|
||||
loadingTabs,
|
||||
homeListUrls,
|
||||
sidebarEntries,
|
||||
initialCatalogLoaded,
|
||||
storeFilter,
|
||||
} from "./useAppState";
|
||||
import { axiosInstance, fetchWithRetry, rootAbortController } from "./useHttp";
|
||||
import { loadPriorityConfig, APM_STORE_BASE_URL } from "../global/storeConfig";
|
||||
|
||||
export const loadCategories = async () => {
|
||||
try {
|
||||
const arch = window.apm_store.arch || "amd64";
|
||||
const modes: Array<"spark" | "apm"> = storeFilterMode();
|
||||
|
||||
const categoryData: Record<string, { zh: string; origins: string[] }> = {};
|
||||
|
||||
for (const mode of modes) {
|
||||
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
|
||||
const path = `/${finalArch}/categories.json`;
|
||||
|
||||
try {
|
||||
const response = await axiosInstance.get(path);
|
||||
const data = response.data;
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (categoryData[key]) {
|
||||
if (!categoryData[key].origins.includes(mode)) {
|
||||
categoryData[key].origins.push(mode);
|
||||
}
|
||||
} else {
|
||||
categoryData[key] = {
|
||||
zh: data[key].zh || data[key],
|
||||
origins: [mode],
|
||||
};
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// 读取 categories.json 失败(如某来源无此文件),静默忽略该来源
|
||||
|
||||
console.warn(`读取 ${mode} categories.json 失败:`, e);
|
||||
}
|
||||
}
|
||||
categories.value = categoryData;
|
||||
|
||||
// 加载优先级配置(从 spark 目录)
|
||||
await loadPriorityConfig(arch);
|
||||
} catch (error) {
|
||||
console.error(`读取 categories 失败:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
export const loadSidebarConfig = async () => {
|
||||
try {
|
||||
const arch = window.apm_store.arch || "amd64";
|
||||
const modes: Array<"spark" | "apm"> = storeFilterMode();
|
||||
|
||||
const entryMap = new Map<string, SidebarEntry>();
|
||||
|
||||
for (const mode of modes) {
|
||||
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
|
||||
const path = `/${finalArch}/sidebar-config.json`;
|
||||
|
||||
try {
|
||||
const response = await axiosInstance.get(path);
|
||||
const data = response.data;
|
||||
const entries = Array.isArray(data) ? data : data.entries || [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.id && entry.name) {
|
||||
const existing = entryMap.get(entry.id);
|
||||
if (existing) {
|
||||
// 多仓库共有入口,合并来源
|
||||
if (existing.origins && !existing.origins.includes(mode)) {
|
||||
existing.origins.push(mode);
|
||||
}
|
||||
} else {
|
||||
entryMap.set(entry.id, {
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
icon: entry.icon || "",
|
||||
type: entry.type || "category",
|
||||
value: entry.value || entry.id,
|
||||
origins: [mode],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`读取 ${mode} sidebar-config.json 失败:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
sidebarEntries.value = Array.from(entryMap.values());
|
||||
if (sidebarEntries.value.length > 0) {
|
||||
console.info(`已加载 ${sidebarEntries.value.length} 个侧边栏配置入口`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`读取 sidebar-config 失败:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
export const normalizeAppJson = (
|
||||
appJson: AppJson,
|
||||
category: string,
|
||||
origin: "spark" | "apm",
|
||||
): App => ({
|
||||
name: appJson.Name,
|
||||
pkgname: appJson.Pkgname,
|
||||
version: appJson.Version,
|
||||
filename: appJson.Filename,
|
||||
torrent_address: appJson.Torrent_address,
|
||||
author: appJson.Author,
|
||||
contributor: appJson.Contributor,
|
||||
website: appJson.Website,
|
||||
update: appJson.Update,
|
||||
size: appJson.Size,
|
||||
more: appJson.More,
|
||||
tags: appJson.Tags,
|
||||
img_urls: (() => {
|
||||
if (typeof appJson.img_urls === "string") {
|
||||
try {
|
||||
return JSON.parse(appJson.img_urls) as string[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return (appJson.img_urls as string[]) || [];
|
||||
})(),
|
||||
icons: appJson.icons,
|
||||
category: category,
|
||||
origin: origin,
|
||||
currentStatus: "not-installed" as const,
|
||||
});
|
||||
|
||||
export const loadTabCategories = async () => {
|
||||
const arch = window.apm_store.arch || "amd64";
|
||||
const modes: Array<"spark" | "apm"> = storeFilterMode();
|
||||
const newTabCategories: Record<string, Record<string, CategoryInfo>> = {};
|
||||
|
||||
// 并行加载所有侧边栏入口的子分类,减少串行等待
|
||||
const categoryEntries = sidebarEntries.value.filter(
|
||||
(e) => e.type === "category",
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
categoryEntries.map(async (entry) => {
|
||||
const folderName = entry.value || entry.id;
|
||||
const catData: Record<string, { zh: string; origins: string[] }> = {};
|
||||
// 只查询该入口实际存在的来源仓库,避免对不存在目录的 404 重试
|
||||
const entryModes = entry.origins?.length
|
||||
? entry.origins.filter((o) => modes.includes(o))
|
||||
: modes;
|
||||
|
||||
await Promise.all(
|
||||
entryModes.map(async (mode) => {
|
||||
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
|
||||
const path = `/${finalArch}/${folderName}/categories.json`;
|
||||
|
||||
try {
|
||||
const response = await axiosInstance.get(path);
|
||||
const data = response.data;
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (catData[key]) {
|
||||
if (!catData[key].origins.includes(mode)) {
|
||||
catData[key].origins.push(mode);
|
||||
}
|
||||
} else {
|
||||
catData[key] = {
|
||||
zh: data[key].zh || data[key],
|
||||
origins: [mode],
|
||||
};
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// 该入口没有子分类,静默忽略
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (Object.keys(catData).length > 0) {
|
||||
newTabCategories[entry.id] = catData;
|
||||
|
||||
console.info(
|
||||
`入口 "${entry.id}" 加载到 ${Object.keys(catData).length} 个子分类`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
tabCategories.value = newTabCategories;
|
||||
};
|
||||
|
||||
export const loadTabApps = async (entryId: string) => {
|
||||
if (tabApps.value[entryId]) return;
|
||||
// 防止重复加载:如果正在加载中则跳过
|
||||
if (loadingTabs.value.has(entryId)) return;
|
||||
|
||||
const entry = sidebarEntries.value.find((e) => e.id === entryId);
|
||||
if (!entry || entry.type !== "category") return;
|
||||
|
||||
// 标记为加载中
|
||||
loadingTabs.value = new Set(loadingTabs.value).add(entryId);
|
||||
|
||||
const arch = window.apm_store.arch || "amd64";
|
||||
const allModes: Array<"spark" | "apm"> = storeFilterMode();
|
||||
// 只查询该入口实际存在的来源仓库,避免对不存在目录的 404 重试
|
||||
const modes = entry.origins?.length
|
||||
? entry.origins.filter((o) => allModes.includes(o))
|
||||
: allModes;
|
||||
const folderName = entry.value || entry.id;
|
||||
const subCats = tabCategories.value[entryId];
|
||||
|
||||
// 收集所有需要发起的请求任务(mode × 子分类),然后全并发加载
|
||||
const tasks: Promise<App[]>[] = [];
|
||||
|
||||
for (const mode of modes) {
|
||||
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
|
||||
|
||||
if (subCats && Object.keys(subCats).length > 0) {
|
||||
for (const [subCat, catInfo] of Object.entries(subCats)) {
|
||||
if (
|
||||
catInfo.origins &&
|
||||
catInfo.origins.length > 0 &&
|
||||
!catInfo.origins.includes(mode)
|
||||
)
|
||||
continue;
|
||||
|
||||
const path = `/${finalArch}/${folderName}/${subCat}/applist.json`;
|
||||
|
||||
console.info(`加载入口子分类: ${entryId}/${subCat} (来源: ${mode})`);
|
||||
tasks.push(
|
||||
fetchWithRetry<AppJson[]>(path, rootAbortController.signal)
|
||||
.then((categoryApps) =>
|
||||
(categoryApps || []).map((aj) =>
|
||||
normalizeAppJson(aj, subCat, mode),
|
||||
),
|
||||
)
|
||||
.catch((e: unknown) => {
|
||||
console.warn(
|
||||
`加载入口子分类 ${entryId}/${subCat} (${mode}) 失败:`,
|
||||
e,
|
||||
);
|
||||
return [] as App[];
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const path = `/${finalArch}/${folderName}/applist.json`;
|
||||
|
||||
console.info(`加载入口目录: ${entryId} (来源: ${mode})`);
|
||||
tasks.push(
|
||||
fetchWithRetry<AppJson[]>(path, rootAbortController.signal)
|
||||
.then((categoryApps) =>
|
||||
(categoryApps || []).map((aj) =>
|
||||
normalizeAppJson(aj, folderName, mode),
|
||||
),
|
||||
)
|
||||
.catch((e: unknown) => {
|
||||
console.warn(`加载入口目录 ${entryId} (${mode}) 失败:`, e);
|
||||
return [] as App[];
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const results = await Promise.all(tasks);
|
||||
const loadedApps = results.flat();
|
||||
|
||||
tabApps.value = { ...tabApps.value, [entryId]: loadedApps };
|
||||
|
||||
// 移除加载标记
|
||||
const next = new Set(loadingTabs.value);
|
||||
next.delete(entryId);
|
||||
loadingTabs.value = next;
|
||||
|
||||
console.info(`入口 "${entryId}" 加载完成,共 ${loadedApps.length} 个应用`);
|
||||
};
|
||||
|
||||
export const loadApps = async (onFirstBatch?: () => void) => {
|
||||
try {
|
||||
console.info("开始加载应用数据(全并发带重试)...");
|
||||
|
||||
const categoriesList = Object.keys(categories.value || {});
|
||||
let firstBatchCallDone = false;
|
||||
const arch = window.apm_store.arch || "amd64";
|
||||
|
||||
// 并发加载所有分类,每个分类自带重试机制
|
||||
await Promise.all(
|
||||
categoriesList.map(async (category) => {
|
||||
const catInfo = categories.value[category];
|
||||
if (!catInfo) return;
|
||||
const origins = (catInfo.origins ||
|
||||
(catInfo.origin ? [catInfo.origin] : [])) as string[];
|
||||
|
||||
await Promise.all(
|
||||
origins.map(async (mode) => {
|
||||
try {
|
||||
const finalArch =
|
||||
mode === "spark" ? `${arch}-store` : `${arch}-apm`;
|
||||
|
||||
const path = `/${finalArch}/${category}/applist.json`;
|
||||
|
||||
console.info(`加载分类: ${category} (来源: ${mode})`);
|
||||
const categoryApps = await fetchWithRetry<AppJson[]>(
|
||||
path,
|
||||
rootAbortController.signal,
|
||||
);
|
||||
|
||||
const normalizedApps = (categoryApps || []).map((appJson) =>
|
||||
normalizeAppJson(appJson, category, mode as "spark" | "apm"),
|
||||
);
|
||||
|
||||
// 增量式更新,让用户尽快看到部分数据
|
||||
// 用赋值替代 push(...),避免对响应式数组逐元素触发 re-render
|
||||
apps.value = [...apps.value, ...normalizedApps];
|
||||
|
||||
// 只要有一个分类加载成功,就可以考虑关闭整体 loading(如果是首批逻辑)
|
||||
if (!firstBatchCallDone && typeof onFirstBatch === "function") {
|
||||
firstBatchCallDone = true;
|
||||
onFirstBatch();
|
||||
// 标记初始目录加载完成,使 apps.length watcher 开始在目录变更时刷新已安装列表
|
||||
initialCatalogLoaded.value = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`加载分类 ${category} 来源 ${mode} 最终失败:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
// 确保即使全部失败也结束 loading
|
||||
if (!firstBatchCallDone && typeof onFirstBatch === "function") {
|
||||
onFirstBatch();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`加载应用数据流程异常:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载首页推荐列表为侧边栏入口(按名称合并 spark/apm,置于分类入口上方)
|
||||
export const loadHomeListEntries = async () => {
|
||||
try {
|
||||
const arch = window.apm_store.arch || "amd64";
|
||||
const modes: Array<"spark" | "apm"> = storeFilterMode();
|
||||
|
||||
// 按列表名称合并各来源的 jsonUrl
|
||||
const byName = new Map<
|
||||
string,
|
||||
{ name: string; urls: { spark?: string; apm?: string } }
|
||||
>();
|
||||
|
||||
// 并发拉取各来源的 homelist.json(spark/apm),缩短首页入口加载耗时;
|
||||
// 各来源独立解析后合并到局部 byName,不逐个触发响应式更新。
|
||||
await Promise.all(
|
||||
modes.map(async (mode) => {
|
||||
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
|
||||
const base = `${APM_STORE_BASE_URL}/${finalArch}/home`;
|
||||
try {
|
||||
const res = await fetch(`${base}/homelist.json`);
|
||||
if (!res.ok) return;
|
||||
const lists = await res.json();
|
||||
lists.forEach(
|
||||
(item: { name?: string; type?: string; jsonUrl?: string }) => {
|
||||
if (item.type === "appList" && item.jsonUrl) {
|
||||
const name = item.name || "推荐";
|
||||
const existing = byName.get(name);
|
||||
if (existing) {
|
||||
existing.urls[mode] = item.jsonUrl;
|
||||
} else {
|
||||
byName.set(name, {
|
||||
name,
|
||||
urls: { [mode]: item.jsonUrl } as {
|
||||
spark?: string;
|
||||
apm?: string;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn(`Failed to load ${mode} homelist.json`, e);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const entries: SidebarEntry[] = [];
|
||||
const urlsMap: Record<string, { spark?: string; apm?: string }> = {};
|
||||
|
||||
byName.forEach((info, name) => {
|
||||
const id = `home-list-${name}`;
|
||||
entries.push({
|
||||
id,
|
||||
name,
|
||||
icon: "fas fa-star",
|
||||
type: "homeList",
|
||||
});
|
||||
urlsMap[id] = info.urls;
|
||||
});
|
||||
|
||||
if (entries.length > 0) {
|
||||
// 首页推荐入口置于分类入口上方
|
||||
sidebarEntries.value = [...entries, ...sidebarEntries.value];
|
||||
homeListUrls.value = { ...homeListUrls.value, ...urlsMap };
|
||||
|
||||
console.info(`已加载 ${entries.length} 个首页推荐列表入口`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`加载首页推荐列表入口失败: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载首页推荐列表的应用数据(合并展示 spark+apm,按 pkgname 去重,spark 优先)
|
||||
export const loadHomeListApps = async (entryId: string) => {
|
||||
if (tabApps.value[entryId]) return;
|
||||
// 防止重复加载:如果正在加载中则跳过
|
||||
if (loadingTabs.value.has(entryId)) return;
|
||||
|
||||
const urls = homeListUrls.value[entryId];
|
||||
if (!urls) return;
|
||||
|
||||
// 标记为加载中
|
||||
loadingTabs.value = new Set(loadingTabs.value).add(entryId);
|
||||
|
||||
const arch = window.apm_store.arch || "amd64";
|
||||
const loadedApps: App[] = [];
|
||||
const seenPkgnames = new Set<string>();
|
||||
|
||||
const parseAppList = (
|
||||
rawApps: Record<string, string>[],
|
||||
mode: "spark" | "apm",
|
||||
): App[] =>
|
||||
rawApps.map((a) => {
|
||||
// 首页推荐列表的 jsonUrl 形如 /home/lists/xxx.json;服务端原始数据不含 category 字段,
|
||||
// 这里提取 URL 首段目录作为分类(无真实分类时回退 "unknown"),避免污染后续优先级匹配。
|
||||
const urlCategory =
|
||||
(urls[mode] || "").split("/").filter(Boolean)[0] || "unknown";
|
||||
const category = a.Category || a.category || urlCategory;
|
||||
|
||||
let img_urls: string[] = [];
|
||||
const rawImgUrls = a.img_urls;
|
||||
if (typeof rawImgUrls === "string") {
|
||||
try {
|
||||
img_urls = JSON.parse(rawImgUrls);
|
||||
} catch {
|
||||
img_urls = [];
|
||||
}
|
||||
} else if (Array.isArray(rawImgUrls)) {
|
||||
img_urls = rawImgUrls;
|
||||
}
|
||||
|
||||
return {
|
||||
name: a.Name || a.name || a.Pkgname || a.pkgname || "",
|
||||
pkgname: a.Pkgname || a.pkgname || "",
|
||||
version: a.Version || "",
|
||||
filename: a.Filename || a.filename || "",
|
||||
torrent_address: a.Torrent_address || "",
|
||||
author: a.Author || "",
|
||||
contributor: a.Contributor || "",
|
||||
website: a.Website || "",
|
||||
update: a.Update || "",
|
||||
size: a.Size || "",
|
||||
more: a.More || a.more || "",
|
||||
tags: a.Tags || "",
|
||||
img_urls,
|
||||
icons: a.icons || "",
|
||||
category,
|
||||
origin: mode,
|
||||
currentStatus: "not-installed" as const,
|
||||
} as App;
|
||||
});
|
||||
|
||||
// 按优先级顺序加载:spark 优先,apm 中与 spark 同名的跳过
|
||||
const modes: Array<"spark" | "apm"> = ["spark", "apm"];
|
||||
for (const mode of modes) {
|
||||
const jsonUrl = urls[mode];
|
||||
if (!jsonUrl) continue;
|
||||
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
|
||||
|
||||
try {
|
||||
const path = `/${finalArch}${jsonUrl}`;
|
||||
const rawApps =
|
||||
(await fetchWithRetry<Record<string, string>[]>(
|
||||
path,
|
||||
rootAbortController.signal,
|
||||
)) || [];
|
||||
const apps = parseAppList(rawApps, mode);
|
||||
for (const app of apps) {
|
||||
if (!app.pkgname || seenPkgnames.has(app.pkgname)) continue;
|
||||
seenPkgnames.add(app.pkgname);
|
||||
loadedApps.push(app);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`加载首页列表 ${entryId} (${mode}) 失败:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
tabApps.value = { ...tabApps.value, [entryId]: loadedApps };
|
||||
|
||||
// 移除加载标记
|
||||
const next = new Set(loadingTabs.value);
|
||||
next.delete(entryId);
|
||||
loadingTabs.value = next;
|
||||
|
||||
console.info(
|
||||
`首页列表 "${entryId}" 加载完成,共 ${loadedApps.length} 个应用`,
|
||||
);
|
||||
};
|
||||
|
||||
// 仅并行预加载首页 homeList 板块入口(区域2 数据来源,不依赖全量应用)
|
||||
export const preloadHomeListApps = (): Promise<void> => {
|
||||
const tasks: Promise<void>[] = [];
|
||||
for (const entry of sidebarEntries.value) {
|
||||
if (entry.type === "homeList") {
|
||||
tasks.push(
|
||||
loadHomeListApps(entry.id).catch((e: unknown) =>
|
||||
console.warn(`预加载首页列表 ${entry.id} 失败:`, e),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return Promise.all(tasks).then(() => undefined);
|
||||
};
|
||||
|
||||
// 并行预加载其余分类侧边栏入口(用户点击分类时才需要,可延后)
|
||||
export const preloadSidebarTabApps = (): Promise<void> => {
|
||||
const tasks: Promise<void>[] = [];
|
||||
for (const entry of sidebarEntries.value) {
|
||||
if (entry.type === "category") {
|
||||
tasks.push(
|
||||
loadTabApps(entry.id).catch((e: unknown) =>
|
||||
console.warn(`预加载入口 ${entry.id} 失败:`, e),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return Promise.all(tasks).then(() => undefined);
|
||||
};
|
||||
|
||||
// 来源模式解析:both -> [spark, apm],否则单一来源
|
||||
function storeFilterMode(): Array<"spark" | "apm"> {
|
||||
return storeFilter.value === "both" ? ["spark", "apm"] : [storeFilter.value];
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* useDownloads —— 下载队列控制与安装触发。
|
||||
*
|
||||
* 从原 App.vue 原样搬移(pendingDownloadRecords / onDetailInstall /
|
||||
* handleInstallCompleteForDownloadRecord / onDetailRemove / onDetailFavorite /
|
||||
* pauseDownload / resumeDownload / cancelDownload / retryDownload /
|
||||
* clearCompletedDownloads / showDownloadDetailModalFunc / closeDownloadDetail /
|
||||
* openDownloadedApp / installCompleteCallback / watchDownloadsChange 注册),逻辑零改动。
|
||||
*
|
||||
* 共享状态来自 useAppState;handleInstall / handleRetry 来自 modules/processInstall;
|
||||
* openFavoriteSelector 来自 useFavorites。
|
||||
*/
|
||||
import type {
|
||||
App,
|
||||
DownloadItem,
|
||||
DownloadResult,
|
||||
DownloadedAppRecord,
|
||||
} from "../global/typedefinition";
|
||||
import type { IpcRendererEvent } from "electron";
|
||||
import {
|
||||
downloads,
|
||||
currentDownload,
|
||||
showDownloadDetailModal,
|
||||
currentApp,
|
||||
} from "./useAppState";
|
||||
import { handleInstall, handleRetry } from "../modules/processInstall";
|
||||
import { watchDownloadsChange } from "../global/downloadStatus";
|
||||
import { buildFavoriteAppKey, parsePackageArch } from "../modules/appIdentity";
|
||||
import { openFavoriteSelector } from "./useFavorites";
|
||||
import { recordDownloadedApp } from "../modules/backendApi";
|
||||
import { isLoggedIn, currentUser } from "../global/authState";
|
||||
|
||||
interface PendingDownloadRecord {
|
||||
userId: number;
|
||||
appKey: string;
|
||||
pkgname: string;
|
||||
name: string;
|
||||
category: string;
|
||||
selectedOrigin: "spark" | "apm";
|
||||
version: string;
|
||||
packageArch: string;
|
||||
}
|
||||
|
||||
const pendingDownloadRecords = new Map<number, PendingDownloadRecord>();
|
||||
|
||||
const onDetailInstall = async (app: App) => {
|
||||
const initiatingUserId = currentUser.value?.id ?? null;
|
||||
const download = await handleInstall(app);
|
||||
if (
|
||||
!download ||
|
||||
initiatingUserId === null ||
|
||||
!isLoggedIn.value ||
|
||||
currentUser.value?.id !== initiatingUserId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingDownloadRecords.set(download.id, {
|
||||
userId: initiatingUserId,
|
||||
appKey: buildFavoriteAppKey(app),
|
||||
pkgname: app.pkgname,
|
||||
name: app.name,
|
||||
category: app.category,
|
||||
selectedOrigin: app.origin,
|
||||
version: app.version,
|
||||
packageArch: app.arch || parsePackageArch(app.filename),
|
||||
});
|
||||
};
|
||||
|
||||
const handleInstallCompleteForDownloadRecord = async (
|
||||
_event: IpcRendererEvent,
|
||||
result: DownloadResult,
|
||||
) => {
|
||||
const pendingRecord = pendingDownloadRecords.get(result.id);
|
||||
if (!pendingRecord) return;
|
||||
|
||||
if (result.success) {
|
||||
pendingDownloadRecords.delete(result.id);
|
||||
}
|
||||
|
||||
if (
|
||||
!result.success ||
|
||||
!isLoggedIn.value ||
|
||||
currentUser.value?.id !== pendingRecord.userId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const downloadRecord: Omit<DownloadedAppRecord, "id" | "downloadedAt"> = {
|
||||
appKey: pendingRecord.appKey,
|
||||
pkgname: pendingRecord.pkgname,
|
||||
name: pendingRecord.name,
|
||||
category: pendingRecord.category,
|
||||
selectedOrigin: pendingRecord.selectedOrigin,
|
||||
version: pendingRecord.version,
|
||||
packageArch: pendingRecord.packageArch,
|
||||
};
|
||||
|
||||
try {
|
||||
await recordDownloadedApp(downloadRecord);
|
||||
} catch (error: unknown) {
|
||||
console.warn({ err: error }, "记录下载应用失败");
|
||||
}
|
||||
};
|
||||
|
||||
const onDetailRemove = (app: App) => {
|
||||
requestUninstallRef(app);
|
||||
};
|
||||
|
||||
const onDetailFavorite = async (app: App) => {
|
||||
await openFavoriteSelector(app);
|
||||
};
|
||||
|
||||
// TODO: 目前 APM 商店不能暂停下载
|
||||
const pauseDownload = (id: DownloadItem) => {
|
||||
const download = downloads.value.find((d) => d.id === id.id);
|
||||
if (download && download.status === "installing") {
|
||||
// 'installing' matches type definition, previously 'downloading'
|
||||
download.status = "paused";
|
||||
download.logs.push({
|
||||
time: Date.now(),
|
||||
message: "下载已暂停",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: 同理,暂未实现
|
||||
const resumeDownload = (id: DownloadItem) => {
|
||||
const download = downloads.value.find((d) => d.id === id.id);
|
||||
if (download && download.status === "paused") {
|
||||
download.status = "installing"; // previously 'downloading'
|
||||
download.logs.push({
|
||||
time: Date.now(),
|
||||
message: "继续下载...",
|
||||
});
|
||||
// simulateDownload(download); // removed or undefined?
|
||||
}
|
||||
};
|
||||
|
||||
const cancelDownload = (id: DownloadItem) => {
|
||||
const index = downloads.value.findIndex((d) => d.id === id.id);
|
||||
if (index !== -1) {
|
||||
const download = downloads.value[index];
|
||||
// 发送到主进程取消
|
||||
window.ipcRenderer.send("cancel-install", download.id);
|
||||
|
||||
download.status = "failed";
|
||||
download.logs.push({
|
||||
time: Date.now(),
|
||||
message: "下载已取消",
|
||||
});
|
||||
// 保留在队列中以便用户可以重试或查看日志
|
||||
}
|
||||
};
|
||||
|
||||
const retryDownload = (id: DownloadItem) => {
|
||||
const download = downloads.value.find((d) => d.id === id.id);
|
||||
if (download && download.status === "failed") {
|
||||
download.status = "queued";
|
||||
download.progress = 0;
|
||||
download.downloadedSize = 0;
|
||||
download.logs.push({
|
||||
time: Date.now(),
|
||||
message: "重新开始下载...",
|
||||
});
|
||||
handleRetry(download);
|
||||
}
|
||||
};
|
||||
|
||||
const clearCompletedDownloads = () => {
|
||||
downloads.value = downloads.value.filter((d) => d.status !== "completed");
|
||||
};
|
||||
|
||||
const showDownloadDetailModalFunc = (download: DownloadItem) => {
|
||||
currentDownload.value = download;
|
||||
showDownloadDetailModal.value = true;
|
||||
};
|
||||
|
||||
const closeDownloadDetail = () => {
|
||||
showDownloadDetailModal.value = false;
|
||||
currentDownload.value = null;
|
||||
};
|
||||
|
||||
const openDownloadedApp = (pkgname: string, origin?: "spark" | "apm") => {
|
||||
// const encodedPkg = encodeURIComponent(download.pkgname);
|
||||
// openApmStoreUrl(`apmstore://launch?pkg=${encodedPkg}`, {
|
||||
// fallbackText: `打开应用: ${download.pkgname}`
|
||||
// });
|
||||
window.ipcRenderer
|
||||
.invoke("launch-app", { pkgname, origin })
|
||||
.catch((err) => console.error("启动应用失败 (launch-app):", err));
|
||||
};
|
||||
|
||||
const installCompleteCallback = (pkgname?: string) => {
|
||||
if (currentApp.value && (!pkgname || currentApp.value.pkgname === pkgname)) {
|
||||
checkAppInstalledRef(currentApp.value);
|
||||
}
|
||||
};
|
||||
|
||||
watchDownloadsChange(installCompleteCallback);
|
||||
|
||||
// checkAppInstalled 由 useAppDetail 持有,通过注入引用获取,避免循环依赖
|
||||
let checkAppInstalledRef: (app: App) => void = () => undefined;
|
||||
export const registerCheckAppInstalled = (fn: (app: App) => void) => {
|
||||
checkAppInstalledRef = fn;
|
||||
};
|
||||
// requestUninstall 由 useInstalledApps 持有,通过注入引用获取,避免循环依赖
|
||||
let requestUninstallRef: (app: App) => void = () => undefined;
|
||||
export const registerRequestUninstall = (fn: (app: App) => void) => {
|
||||
requestUninstallRef = fn;
|
||||
};
|
||||
// 登出时清空待下载记录(原 App.vue logout 中的 pendingDownloadRecords.clear())
|
||||
export const clearPendingDownloadRecords = (): void => {
|
||||
pendingDownloadRecords.clear();
|
||||
};
|
||||
|
||||
// onDetailInstall 需要被 useFavorites / useAccountSync 调用,此处导出
|
||||
export {
|
||||
pendingDownloadRecords,
|
||||
onDetailInstall,
|
||||
handleInstallCompleteForDownloadRecord,
|
||||
onDetailRemove,
|
||||
onDetailFavorite,
|
||||
pauseDownload,
|
||||
resumeDownload,
|
||||
cancelDownload,
|
||||
retryDownload,
|
||||
clearCompletedDownloads,
|
||||
showDownloadDetailModalFunc,
|
||||
closeDownloadDetail,
|
||||
openDownloadedApp,
|
||||
installCompleteCallback,
|
||||
};
|
||||
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* useFavorites —— 收藏夹相关的全部状态与逻辑。
|
||||
*
|
||||
* 从原 App.vue 原样搬移(loadFavoriteFolders / loadActiveFavoriteItems /
|
||||
* loadAllFavoriteItems / loadFavoriteMetadataForDetail / refreshFavorites /
|
||||
* openFavoriteSelector / toFavoritePayload / saveCurrentFavoriteFolders /
|
||||
* createFavoriteFolderFromSelector / openFavoriteManagement / selectFavoriteFolder /
|
||||
* createFavoriteFolderFromPrompt / removeSelectedFavorites / installResolvedFavorites /
|
||||
* clearFavoriteState / 代次守卫 / currentFavoriteMetadata / currentFavoriteFolderIds /
|
||||
* resolvedFavoriteItems),逻辑零改动。
|
||||
*
|
||||
* 共享状态来自 useAppState;clientArch 来自 useAppDetail。
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import type {
|
||||
App,
|
||||
FavoriteItem,
|
||||
ResolvedFavoriteItem,
|
||||
} from "../global/typedefinition";
|
||||
import {
|
||||
favoriteFolders,
|
||||
favoriteItems,
|
||||
favoriteItemsByFolder,
|
||||
favoriteLoading,
|
||||
favoriteError,
|
||||
favoriteRequestGeneration,
|
||||
favoriteTargetApp,
|
||||
favoriteSelectorDraftFolderIds,
|
||||
activeFavoriteFolderId,
|
||||
showFavoriteSelector,
|
||||
currentView,
|
||||
activeTab,
|
||||
isSidebarOpen,
|
||||
showLoginPrompt,
|
||||
apps,
|
||||
installedApps,
|
||||
availableSources,
|
||||
storeFilter,
|
||||
} from "./useAppState";
|
||||
import { clientArch } from "./useAppDetail";
|
||||
import {
|
||||
listFavoriteFolders,
|
||||
listFavoriteItems,
|
||||
addFavoriteItem,
|
||||
deleteFavoriteItem,
|
||||
createFavoriteFolder,
|
||||
bulkDeleteFavoriteItems,
|
||||
} from "../modules/backendApi";
|
||||
import { buildFavoriteAppKey } from "../modules/appIdentity";
|
||||
import { resolveFavoriteItems } from "../modules/favoriteAvailability";
|
||||
import { isLoggedIn } from "../global/authState";
|
||||
import { refreshFavoriteInstalledApps } from "./useInstalledApps";
|
||||
|
||||
const nextFavoriteRequestGeneration = (): number => {
|
||||
favoriteRequestGeneration.value += 1;
|
||||
return favoriteRequestGeneration.value;
|
||||
};
|
||||
|
||||
const isCurrentFavoriteRequest = (generation: number): boolean =>
|
||||
favoriteRequestGeneration.value === generation && isLoggedIn.value;
|
||||
|
||||
const loadFavoriteFolders = async (
|
||||
generation = favoriteRequestGeneration.value,
|
||||
): Promise<boolean> => {
|
||||
const folders = await listFavoriteFolders();
|
||||
if (!isCurrentFavoriteRequest(generation)) return false;
|
||||
|
||||
favoriteFolders.value = folders;
|
||||
const activeFolderExists = folders.some(
|
||||
(folder) => folder.id === activeFavoriteFolderId.value,
|
||||
);
|
||||
if (!activeFolderExists) {
|
||||
activeFavoriteFolderId.value = folders[0]?.id ?? null;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const loadActiveFavoriteItems = async (
|
||||
generation = favoriteRequestGeneration.value,
|
||||
): Promise<boolean> => {
|
||||
if (!activeFavoriteFolderId.value) {
|
||||
if (!isCurrentFavoriteRequest(generation)) return false;
|
||||
favoriteItems.value = [];
|
||||
return true;
|
||||
}
|
||||
const items = await listFavoriteItems(activeFavoriteFolderId.value);
|
||||
if (!isCurrentFavoriteRequest(generation)) return false;
|
||||
|
||||
favoriteItems.value = items;
|
||||
favoriteItemsByFolder.value = {
|
||||
...favoriteItemsByFolder.value,
|
||||
[activeFavoriteFolderId.value]: items,
|
||||
};
|
||||
return true;
|
||||
};
|
||||
|
||||
const loadAllFavoriteItems = async (
|
||||
generation = favoriteRequestGeneration.value,
|
||||
): Promise<boolean> => {
|
||||
const folderIds = favoriteFolders.value.map((folder) => folder.id);
|
||||
const entries = await Promise.all(
|
||||
folderIds.map(async (folderId) => ({
|
||||
folderId,
|
||||
items: await listFavoriteItems(folderId),
|
||||
})),
|
||||
);
|
||||
if (!isCurrentFavoriteRequest(generation)) return false;
|
||||
|
||||
favoriteItemsByFolder.value = Object.fromEntries(
|
||||
entries.map(({ folderId, items }) => [folderId, items]),
|
||||
);
|
||||
favoriteItems.value = activeFavoriteFolderId.value
|
||||
? (favoriteItemsByFolder.value[activeFavoriteFolderId.value] ?? [])
|
||||
: [];
|
||||
return true;
|
||||
};
|
||||
|
||||
const loadFavoriteMetadataForDetail = async (): Promise<void> => {
|
||||
const generation = favoriteRequestGeneration.value;
|
||||
try {
|
||||
const folders = await listFavoriteFolders();
|
||||
if (!isCurrentFavoriteRequest(generation)) return;
|
||||
const entries = await Promise.all(
|
||||
folders.map(async (folder) => ({
|
||||
folderId: folder.id,
|
||||
items: await listFavoriteItems(folder.id),
|
||||
})),
|
||||
);
|
||||
if (!isCurrentFavoriteRequest(generation)) return;
|
||||
|
||||
favoriteFolders.value = folders;
|
||||
favoriteItemsByFolder.value = Object.fromEntries(
|
||||
entries.map(({ folderId, items }) => [folderId, items]),
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
if (!isCurrentFavoriteRequest(generation)) return;
|
||||
favoriteError.value = (error as Error)?.message || "读取收藏夹失败";
|
||||
}
|
||||
};
|
||||
|
||||
const refreshFavorites = async (): Promise<void> => {
|
||||
const generation = nextFavoriteRequestGeneration();
|
||||
favoriteLoading.value = true;
|
||||
favoriteError.value = "";
|
||||
try {
|
||||
await Promise.all([
|
||||
refreshFavoriteInstalledApps(),
|
||||
loadFavoriteFolders(generation),
|
||||
]);
|
||||
if (!isCurrentFavoriteRequest(generation)) return;
|
||||
await loadAllFavoriteItems(generation);
|
||||
} catch (error: unknown) {
|
||||
if (!isCurrentFavoriteRequest(generation)) return;
|
||||
favoriteError.value = (error as Error)?.message || "读取收藏夹失败";
|
||||
} finally {
|
||||
if (isCurrentFavoriteRequest(generation)) favoriteLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openFavoriteSelector = async (app: App) => {
|
||||
if (!requireLoginRef("收藏应用需要登录星火账号。")) return;
|
||||
const generation = nextFavoriteRequestGeneration();
|
||||
favoriteTargetApp.value = app;
|
||||
favoriteSelectorDraftFolderIds.value = null;
|
||||
favoriteError.value = "";
|
||||
try {
|
||||
const foldersLoaded = await loadFavoriteFolders(generation);
|
||||
if (!foldersLoaded || !isCurrentFavoriteRequest(generation)) return;
|
||||
const itemsLoaded = await loadAllFavoriteItems(generation);
|
||||
if (!itemsLoaded || !isCurrentFavoriteRequest(generation)) return;
|
||||
showFavoriteSelector.value = true;
|
||||
} catch (error: unknown) {
|
||||
if (!isCurrentFavoriteRequest(generation)) return;
|
||||
favoriteError.value = (error as Error)?.message || "读取收藏夹失败";
|
||||
}
|
||||
};
|
||||
|
||||
const toFavoritePayload = (
|
||||
app: App,
|
||||
): Omit<FavoriteItem, "id" | "createdAt"> => ({
|
||||
appKey: buildFavoriteAppKey(app),
|
||||
pkgname: app.pkgname,
|
||||
name: app.name,
|
||||
category: app.category,
|
||||
iconUrl: app.icons,
|
||||
});
|
||||
|
||||
const saveCurrentFavoriteFolders = async (
|
||||
folderIds: Array<number | "default">,
|
||||
) => {
|
||||
const generation = favoriteRequestGeneration.value;
|
||||
const app = favoriteTargetApp.value;
|
||||
if (!app) return;
|
||||
try {
|
||||
const numericFolderIds = folderIds.filter(
|
||||
(folderId): folderId is number => typeof folderId === "number",
|
||||
);
|
||||
const includesFallbackDefault = folderIds.includes("default");
|
||||
const nextFolderIds = new Set(numericFolderIds);
|
||||
const existingByFolder = favoriteFolders.value
|
||||
.map((folder) => ({
|
||||
folderId: folder.id,
|
||||
item: (favoriteItemsByFolder.value[folder.id] ?? []).find(
|
||||
(favorite) =>
|
||||
favorite.pkgname === app.pkgname &&
|
||||
favorite.category === app.category,
|
||||
),
|
||||
}))
|
||||
.filter(
|
||||
(entry): entry is { folderId: number; item: FavoriteItem } =>
|
||||
entry.item !== undefined,
|
||||
);
|
||||
const existingFolderIds = new Set(
|
||||
existingByFolder.map((entry) => entry.folderId),
|
||||
);
|
||||
const payload = toFavoritePayload(app);
|
||||
const addedItemPromises = numericFolderIds
|
||||
.filter((folderId) => !existingFolderIds.has(folderId))
|
||||
.map(async (folderId) => ({
|
||||
folderId,
|
||||
item: await addFavoriteItem(folderId, payload),
|
||||
}));
|
||||
const deletedEntries = existingByFolder.filter(
|
||||
({ folderId }) => !nextFolderIds.has(folderId),
|
||||
);
|
||||
|
||||
const [addedEntries] = await Promise.all([
|
||||
Promise.all(addedItemPromises),
|
||||
...(includesFallbackDefault ? [addFavoriteItem("default", payload)] : []),
|
||||
...deletedEntries.map(({ folderId, item }) =>
|
||||
deleteFavoriteItem(folderId, item.id),
|
||||
),
|
||||
]);
|
||||
if (!isCurrentFavoriteRequest(generation)) return;
|
||||
const nextItemsByFolder = { ...favoriteItemsByFolder.value };
|
||||
deletedEntries.forEach(({ folderId, item }) => {
|
||||
nextItemsByFolder[folderId] = (nextItemsByFolder[folderId] ?? []).filter(
|
||||
(favorite) => favorite.id !== item.id,
|
||||
);
|
||||
});
|
||||
addedEntries.forEach(({ folderId, item }) => {
|
||||
nextItemsByFolder[folderId] = [
|
||||
...(nextItemsByFolder[folderId] ?? []).filter(
|
||||
(favorite) =>
|
||||
favorite.pkgname !== app.pkgname ||
|
||||
favorite.category !== app.category,
|
||||
),
|
||||
item,
|
||||
];
|
||||
});
|
||||
favoriteItemsByFolder.value = nextItemsByFolder;
|
||||
if (activeFavoriteFolderId.value) {
|
||||
favoriteItems.value =
|
||||
nextItemsByFolder[activeFavoriteFolderId.value] ?? [];
|
||||
}
|
||||
showFavoriteSelector.value = false;
|
||||
favoriteTargetApp.value = null;
|
||||
favoriteSelectorDraftFolderIds.value = null;
|
||||
if (includesFallbackDefault) await refreshFavorites();
|
||||
} catch (error: unknown) {
|
||||
if (!isCurrentFavoriteRequest(generation)) return;
|
||||
favoriteError.value = (error as Error)?.message || "更新收藏失败";
|
||||
}
|
||||
};
|
||||
|
||||
const createFavoriteFolderFromSelector = async (
|
||||
draftFolderIds: Array<
|
||||
number | "default"
|
||||
> = favoriteSelectorDraftFolderIds.value ?? [],
|
||||
): Promise<void> => {
|
||||
const generation = favoriteRequestGeneration.value;
|
||||
const name = window.prompt("请输入收藏夹名称");
|
||||
const folderName = name?.trim();
|
||||
if (!folderName) return;
|
||||
const app = favoriteTargetApp.value;
|
||||
if (!app) return;
|
||||
favoriteLoading.value = true;
|
||||
favoriteError.value = "";
|
||||
try {
|
||||
const folder = await createFavoriteFolder(folderName);
|
||||
if (!isCurrentFavoriteRequest(generation)) return;
|
||||
favoriteFolders.value = [
|
||||
...favoriteFolders.value.filter((item) => item.id !== folder.id),
|
||||
folder,
|
||||
];
|
||||
favoriteItemsByFolder.value = {
|
||||
...favoriteItemsByFolder.value,
|
||||
[folder.id]: favoriteItemsByFolder.value[folder.id] ?? [],
|
||||
};
|
||||
favoriteSelectorDraftFolderIds.value = [
|
||||
...new Set([...draftFolderIds, folder.id]),
|
||||
];
|
||||
showFavoriteSelector.value = true;
|
||||
} catch (error: unknown) {
|
||||
if (!isCurrentFavoriteRequest(generation)) return;
|
||||
favoriteError.value = (error as Error)?.message || "创建收藏夹失败";
|
||||
} finally {
|
||||
if (isCurrentFavoriteRequest(generation)) favoriteLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openFavoriteManagement = async () => {
|
||||
if (!requireLoginRef("请登录后查看我的收藏。")) return;
|
||||
currentView.value = "favorites";
|
||||
activeTab.value = "favorites";
|
||||
isSidebarOpen.value = false;
|
||||
showLoginPrompt.value = false;
|
||||
await refreshFavorites();
|
||||
};
|
||||
|
||||
const selectFavoriteFolder = async (folderId: number) => {
|
||||
const generation = nextFavoriteRequestGeneration();
|
||||
activeFavoriteFolderId.value = folderId;
|
||||
favoriteLoading.value = true;
|
||||
favoriteError.value = "";
|
||||
try {
|
||||
await loadActiveFavoriteItems(generation);
|
||||
} catch (error: unknown) {
|
||||
if (!isCurrentFavoriteRequest(generation)) return;
|
||||
favoriteError.value = (error as Error)?.message || "读取收藏应用失败";
|
||||
} finally {
|
||||
if (isCurrentFavoriteRequest(generation)) favoriteLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const createFavoriteFolderFromPrompt = async () => {
|
||||
const name = window.prompt("请输入收藏夹名称");
|
||||
const folderName = name?.trim();
|
||||
if (!folderName) return;
|
||||
const generation = favoriteRequestGeneration.value;
|
||||
favoriteLoading.value = true;
|
||||
favoriteError.value = "";
|
||||
try {
|
||||
const folder = await createFavoriteFolder(folderName);
|
||||
if (!isCurrentFavoriteRequest(generation)) return;
|
||||
favoriteFolders.value = [
|
||||
...favoriteFolders.value.filter((item) => item.id !== folder.id),
|
||||
folder,
|
||||
];
|
||||
favoriteItemsByFolder.value = {
|
||||
...favoriteItemsByFolder.value,
|
||||
[folder.id]: favoriteItemsByFolder.value[folder.id] ?? [],
|
||||
};
|
||||
favoriteItems.value = [];
|
||||
activeFavoriteFolderId.value = folder.id;
|
||||
} catch (error: unknown) {
|
||||
if (!isCurrentFavoriteRequest(generation)) return;
|
||||
favoriteError.value = (error as Error)?.message || "创建收藏夹失败";
|
||||
} finally {
|
||||
if (isCurrentFavoriteRequest(generation)) favoriteLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const removeSelectedFavorites = async (ids: number[]) => {
|
||||
if (!activeFavoriteFolderId.value || ids.length === 0) return;
|
||||
const generation = favoriteRequestGeneration.value;
|
||||
favoriteLoading.value = true;
|
||||
favoriteError.value = "";
|
||||
try {
|
||||
await bulkDeleteFavoriteItems(activeFavoriteFolderId.value, ids);
|
||||
if (!isCurrentFavoriteRequest(generation)) return;
|
||||
favoriteItemsByFolder.value = {
|
||||
...favoriteItemsByFolder.value,
|
||||
[activeFavoriteFolderId.value]: (
|
||||
favoriteItemsByFolder.value[activeFavoriteFolderId.value] ?? []
|
||||
).filter((favorite) => !ids.includes(favorite.id)),
|
||||
};
|
||||
await refreshFavorites();
|
||||
} catch (error: unknown) {
|
||||
if (!isCurrentFavoriteRequest(generation)) return;
|
||||
favoriteError.value = (error as Error)?.message || "移除收藏失败";
|
||||
} finally {
|
||||
if (isCurrentFavoriteRequest(generation)) favoriteLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const installResolvedFavorites = async (items: ResolvedFavoriteItem[]) => {
|
||||
for (const item of items) {
|
||||
if (item.selectedApp) {
|
||||
await onDetailInstallRef(item.selectedApp);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearFavoriteState = () => {
|
||||
favoriteRequestGeneration.value += 1;
|
||||
favoriteFolders.value = [];
|
||||
activeFavoriteFolderId.value = null;
|
||||
favoriteItems.value = [];
|
||||
favoriteItemsByFolder.value = {};
|
||||
showFavoriteSelector.value = false;
|
||||
favoriteTargetApp.value = null;
|
||||
favoriteSelectorDraftFolderIds.value = null;
|
||||
favoriteLoading.value = false;
|
||||
favoriteError.value = "";
|
||||
};
|
||||
|
||||
// requireLogin / onDetailInstall 分别定义在 useAccountSync / useDownloads,
|
||||
// 通过模块级可赋值引用注入,避免循环依赖。
|
||||
let requireLoginRef: (message: string) => boolean = () => true;
|
||||
export const registerRequireLogin = (fn: (message: string) => boolean) => {
|
||||
requireLoginRef = fn;
|
||||
};
|
||||
let onDetailInstallRef: (app: App) => Promise<void> = async () => undefined;
|
||||
export const registerOnDetailInstall = (fn: (app: App) => Promise<void>) => {
|
||||
onDetailInstallRef = fn;
|
||||
};
|
||||
|
||||
// 这些 computed 依赖 currentDisplayApp / favorite 状态,放在本模块汇总派生。
|
||||
export const currentFavoriteMetadata = computed(
|
||||
(): {
|
||||
favorited: boolean;
|
||||
folderName: string;
|
||||
} => {
|
||||
// 延迟引用 currentDisplayApp,避免顶层循环导入时的初始化顺序问题
|
||||
const app = currentDisplayAppRef();
|
||||
if (!app) return { favorited: false, folderName: "" };
|
||||
|
||||
const folder = favoriteFolders.value.find((favoriteFolder) => {
|
||||
const items = favoriteItemsByFolder.value[favoriteFolder.id] ?? [];
|
||||
return items.some(
|
||||
(favorite) =>
|
||||
favorite.pkgname === app.pkgname &&
|
||||
favorite.category === app.category,
|
||||
);
|
||||
});
|
||||
if (!folder) return { favorited: false, folderName: "" };
|
||||
|
||||
return { favorited: true, folderName: folder.name.trim() };
|
||||
},
|
||||
);
|
||||
|
||||
export const currentFavoriteFolderIds = computed(
|
||||
(): Array<number | "default"> => {
|
||||
if (favoriteSelectorDraftFolderIds.value) {
|
||||
return favoriteSelectorDraftFolderIds.value;
|
||||
}
|
||||
|
||||
const app = favoriteTargetApp.value ?? currentDisplayAppRef();
|
||||
if (!app) return [];
|
||||
|
||||
return favoriteFolders.value
|
||||
.filter((folder) =>
|
||||
(favoriteItemsByFolder.value[folder.id] ?? []).some(
|
||||
(favorite) =>
|
||||
favorite.pkgname === app.pkgname &&
|
||||
favorite.category === app.category,
|
||||
),
|
||||
)
|
||||
.map((folder) => folder.id);
|
||||
},
|
||||
);
|
||||
|
||||
export const resolvedFavoriteItems = computed<ResolvedFavoriteItem[]>(() =>
|
||||
resolveFavoriteItems(
|
||||
favoriteItems.value,
|
||||
apps.value,
|
||||
installedApps.value,
|
||||
availableSources.value,
|
||||
storeFilter.value,
|
||||
clientArch.value,
|
||||
),
|
||||
);
|
||||
|
||||
// currentDisplayApp 由 useAppDetail 持有,此处通过注入引用获取,避免循环导入
|
||||
let currentDisplayAppRef: () => App | null = () => null;
|
||||
export const registerCurrentDisplayApp = (fn: () => App | null) => {
|
||||
currentDisplayAppRef = fn;
|
||||
};
|
||||
|
||||
export {
|
||||
loadFavoriteFolders,
|
||||
loadActiveFavoriteItems,
|
||||
loadAllFavoriteItems,
|
||||
loadFavoriteMetadataForDetail,
|
||||
refreshFavorites,
|
||||
openFavoriteSelector,
|
||||
toFavoritePayload,
|
||||
saveCurrentFavoriteFolders,
|
||||
createFavoriteFolderFromSelector,
|
||||
openFavoriteManagement,
|
||||
selectFavoriteFolder,
|
||||
createFavoriteFolderFromPrompt,
|
||||
removeSelectedFavorites,
|
||||
installResolvedFavorites,
|
||||
clearFavoriteState,
|
||||
nextFavoriteRequestGeneration,
|
||||
isCurrentFavoriteRequest,
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* useHttp —— 全局 HTTP 工具(axios 实例 + 带重试请求 + 超时包装)。
|
||||
*
|
||||
* 从原 App.vue 原样搬移,逻辑零改动,仅改为导出供各 composable 复用。
|
||||
* 含缓存穿透拦截器(createCacheBusterInterceptor),与之前的审计加固一致。
|
||||
*/
|
||||
import axios, { AxiosError } from "axios";
|
||||
import { APM_STORE_BASE_URL } from "../global/storeConfig";
|
||||
import { createCacheBusterInterceptor } from "../global/cacheBusterInterceptor";
|
||||
|
||||
// Axios 全局配置
|
||||
export const axiosInstance = axios.create({
|
||||
baseURL: APM_STORE_BASE_URL,
|
||||
timeout: 5000, // 增加到 5 秒,避免网络波动导致的超时
|
||||
});
|
||||
|
||||
// C2:数据 JSON(applist / categories / sidebar-config 等)追加 ?_t 版本戳,
|
||||
// 穿透 CDN 边缘缓存,确保返回最新列表。复用共享缓存穿透拦截器(带 TTL 复用戳,
|
||||
// 避免同会话频繁击穿缓存)。与主进程 onBeforeSendHeaders 注入的 no-cache 互为兜底。
|
||||
// 这是有意的缓存击穿策略(非缺陷),用于解决商店目录强缓存导致的更新延迟。
|
||||
axiosInstance.interceptors.request.use(createCacheBusterInterceptor());
|
||||
|
||||
// 5xx / 网络错误 / 超时重试;4xx(如 404)快速失败
|
||||
const RETRYABLE_STATUS = new Set([502, 503, 504]);
|
||||
|
||||
export const fetchWithRetry = async <T>(
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
retries = 2,
|
||||
retryDelayMs = 500,
|
||||
): Promise<T | null> => {
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
const resp = await axiosInstance.get<T>(path, { signal });
|
||||
return resp.data;
|
||||
} catch (err) {
|
||||
const ae = err as AxiosError;
|
||||
const status = ae.response?.status;
|
||||
// 4xx(含 404)快速失败,不重试
|
||||
if (status && status < 500 && status !== 429) {
|
||||
return null;
|
||||
}
|
||||
// 仅对网络错误 / 5xx / 429 重试
|
||||
const retryable =
|
||||
!status || RETRYABLE_STATUS.has(status) || status === 429;
|
||||
if (!retryable || attempt === retries) {
|
||||
return null;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, retryDelayMs));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 全局请求取消控制器(目录加载期间可整体取消)
|
||||
export const rootAbortController = new AbortController();
|
||||
|
||||
// 给 Promise 包一层超时,超时即 reject。finally 中清理定时器,避免泄漏。
|
||||
export const withTimeout = async <T>(
|
||||
promise: Promise<T>,
|
||||
ms: number,
|
||||
label = "operation",
|
||||
): Promise<T> => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`${label} 超时 (${ms}ms)`)), ms);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([promise, timeout]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* useInstalledApps —— 已安装应用列表的查询、合并、卸载触发逻辑。
|
||||
*
|
||||
* 从原 App.vue 原样搬移(isInstalledAppInfo / LIST_INSTALLED_TIMEOUT_MS /
|
||||
* resolveInstalledOrigins / refreshInstalledApps / mapInstalledAppToCatalogApp /
|
||||
* refreshFavoriteInstalledApps / requestUninstall / openInstalledModal /
|
||||
* closeInstalledModal / uninstallInstalledApp / installedCloudKeys /
|
||||
* installedCloudPackageKeys),逻辑零改动。
|
||||
*
|
||||
* 共享状态来自 useAppState;withTimeout / rootAbortController 来自 useHttp。
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import type { App, InstalledAppInfo } from "../global/typedefinition";
|
||||
import {
|
||||
apps,
|
||||
installedApps,
|
||||
installedLoading,
|
||||
installedError,
|
||||
installedWarning,
|
||||
installedRefreshGeneration,
|
||||
showInstalledModal,
|
||||
sparkAvailable,
|
||||
apmAvailable,
|
||||
storeFilter,
|
||||
showUninstallModal,
|
||||
uninstallTargetApp,
|
||||
syncCandidateApps,
|
||||
availableSources,
|
||||
} from "./useAppState";
|
||||
import { withTimeout } from "./useHttp";
|
||||
import {
|
||||
isOriginUsable,
|
||||
isOriginEnabled,
|
||||
getEffectiveStoreFilter,
|
||||
} from "../modules/storeFilter";
|
||||
import { cloudItemKey, cloudPackageKey } from "../modules/appListSync";
|
||||
import { removeDownloadItem } from "../global/downloadStatus";
|
||||
|
||||
const isInstalledAppInfo = (value: unknown): value is InstalledAppInfo => {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
return typeof v.pkgname === "string" && typeof v.origin === "string";
|
||||
};
|
||||
|
||||
const LIST_INSTALLED_TIMEOUT_MS = 15000;
|
||||
|
||||
// 根据当前启动模式和系统可用性,确定需要查询哪些 origin 的已安装应用
|
||||
const resolveInstalledOrigins = (): Array<"spark" | "apm"> => {
|
||||
const origins: Array<"spark" | "apm"> = [];
|
||||
if (isOriginUsable(storeFilter.value, "spark", availableSources.value)) {
|
||||
origins.push("spark");
|
||||
}
|
||||
if (isOriginUsable(storeFilter.value, "apm", availableSources.value)) {
|
||||
origins.push("apm");
|
||||
}
|
||||
return origins;
|
||||
};
|
||||
|
||||
const refreshInstalledApps = async () => {
|
||||
// 异步竞态防护:进入时若模态框已关闭(极小概率),直接放弃本轮请求
|
||||
if (!showInstalledModal.value) return;
|
||||
installedLoading.value = true;
|
||||
installedError.value = "";
|
||||
installedWarning.value = "";
|
||||
// 代次计数器:每次进入自增;await 之后若代次已变(新一轮刷新 / 关闭重开),丢弃本轮结果
|
||||
const generation = ++installedRefreshGeneration.value;
|
||||
try {
|
||||
const origins = resolveInstalledOrigins();
|
||||
// Spark 已安装列表依赖商店目录(apps.value)来枚举包名。
|
||||
// 仅当目录中存在可枚举的 Spark 包时才查询 Spark,
|
||||
// 否则空目录会触发"全量扫描整个系统"的陷阱导致列表被全部跳过而误报为空。
|
||||
const sparkPkgnameList = apps.value
|
||||
.filter((a) => a.origin === "spark")
|
||||
.map((a) => a.pkgname);
|
||||
const effectiveOrigins = origins.filter(
|
||||
(o) => o !== "spark" || sparkPkgnameList.length > 0,
|
||||
);
|
||||
|
||||
if (effectiveOrigins.length === 0) {
|
||||
installedApps.value = [];
|
||||
// 目录尚未加载完成(但来源可用)时给出过渡提示,待目录加载后会自动重查
|
||||
installedError.value =
|
||||
apps.value.length === 0
|
||||
? "正在加载应用目录,请稍候…"
|
||||
: "当前系统不可用应用管理功能";
|
||||
return;
|
||||
}
|
||||
|
||||
// 并行查询每个 origin 的已安装应用:
|
||||
// 用 allSettled + 每源超时,避免单一来源(如 APM)响应慢/挂起阻塞整体;
|
||||
// 超时或失败的来源在下方循环标记为 failedOrigin,不影响其它来源结果。
|
||||
const results = await Promise.allSettled(
|
||||
effectiveOrigins.map((origin) =>
|
||||
withTimeout(
|
||||
window.ipcRenderer.invoke("list-installed", {
|
||||
origin,
|
||||
pkgnameList: origin === "spark" ? sparkPkgnameList : undefined,
|
||||
}),
|
||||
LIST_INSTALLED_TIMEOUT_MS,
|
||||
`${origin} list-installed`,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 异步结束后的回调阶段重新校验代次与模态可见性,避免陈旧竞态写入 ref
|
||||
if (generation !== installedRefreshGeneration.value) return;
|
||||
if (!showInstalledModal.value) return;
|
||||
|
||||
const combinedApps: App[] = [];
|
||||
// 同一 pkgname 可能同时以 APM 与 Spark 两种来源安装,这里聚合其来源集合
|
||||
const originsByPkg = new Map<string, Set<"spark" | "apm">>();
|
||||
const failedOrigins: string[] = [];
|
||||
|
||||
for (let i = 0; i < effectiveOrigins.length; i++) {
|
||||
const origin = effectiveOrigins[i];
|
||||
const settled = results[i];
|
||||
// allSettled: rejected(超时/异常)或 success=false 都视为该来源失败
|
||||
if (settled.status !== "fulfilled" || !settled.value?.success) {
|
||||
failedOrigins.push(origin);
|
||||
continue;
|
||||
}
|
||||
const result = settled.value;
|
||||
|
||||
const appList = Array.isArray(result?.apps) ? result.apps : [];
|
||||
for (const rawApp of appList) {
|
||||
// 运行时类型守卫,避免后端字段缺失造成下游访问 undefined 抛出
|
||||
if (!isInstalledAppInfo(rawApp)) continue;
|
||||
const app = rawApp;
|
||||
|
||||
// Find matching remote app to enrich data. We look exactly for that origin.
|
||||
let appInfo = apps.value.find(
|
||||
(a) => a.pkgname === app.pkgname && a.origin === origin,
|
||||
);
|
||||
|
||||
if (origin === "spark" && !appInfo) {
|
||||
// Only show Spark packages that exist in the App Store catalogue
|
||||
continue;
|
||||
}
|
||||
|
||||
if (appInfo) {
|
||||
appInfo.flags = app.flags;
|
||||
appInfo.arch = app.arch;
|
||||
appInfo.currentStatus = "installed";
|
||||
appInfo.isDependency = app.isDependency;
|
||||
} else {
|
||||
// 如果在当前应用列表中找不到该应用,创建一个最小的 App 对象
|
||||
appInfo = {
|
||||
name: app.name || app.pkgname,
|
||||
pkgname: app.pkgname,
|
||||
version: app.version,
|
||||
category: "unknown",
|
||||
tags: "",
|
||||
more: "",
|
||||
filename: "",
|
||||
torrent_address: "",
|
||||
author: "",
|
||||
contributor: "",
|
||||
website: "",
|
||||
update: "",
|
||||
size: "",
|
||||
img_urls: [],
|
||||
icons: app.icon || "",
|
||||
origin: app.origin || (app.arch?.includes("apm") ? "apm" : "spark"),
|
||||
currentStatus: "installed",
|
||||
arch: app.arch,
|
||||
flags: app.flags,
|
||||
isDependency: app.isDependency,
|
||||
};
|
||||
}
|
||||
// 合并同一 pkgname 在多个来源的安装记录为单条,避免列表出现重复项(相同 pkgname 键冲突)
|
||||
const existingIdx = combinedApps.findIndex(
|
||||
(a) => a.pkgname === appInfo.pkgname,
|
||||
);
|
||||
if (existingIdx === -1) {
|
||||
combinedApps.push(appInfo);
|
||||
}
|
||||
// 记录该 pkgname 当前这一来源,供卸载时判断走 APM 还是 Spark
|
||||
const originSet =
|
||||
originsByPkg.get(appInfo.pkgname) ?? new Set<"spark" | "apm">();
|
||||
originSet.add(origin);
|
||||
originsByPkg.set(appInfo.pkgname, originSet);
|
||||
}
|
||||
}
|
||||
|
||||
// 将来源集合回写到每条已安装应用,供卸载时判断应走 APM 还是 Spark
|
||||
for (const app of combinedApps) {
|
||||
const set = originsByPkg.get(app.pkgname);
|
||||
if (set) app.origins = Array.from(set);
|
||||
}
|
||||
|
||||
installedApps.value = combinedApps;
|
||||
|
||||
// 部分来源失败使用轻量 warning(不与列表同时呈现红色致命错误条),致命/全失败仍用 error
|
||||
if (failedOrigins.length > 0) {
|
||||
const labels = failedOrigins
|
||||
.map((o) => (o === "spark" ? "Spark" : "APM"))
|
||||
.join("、");
|
||||
if (combinedApps.length > 0) {
|
||||
installedWarning.value = `部分来源加载失败(${labels}),已安装列表可能不完整`;
|
||||
} else {
|
||||
installedError.value = `读取${labels}已安装应用失败`;
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (generation !== installedRefreshGeneration.value) return;
|
||||
if (!showInstalledModal.value) return;
|
||||
installedApps.value = [];
|
||||
installedError.value = (error as Error)?.message || "读取已安装应用失败";
|
||||
} finally {
|
||||
// 仅最末一代次负责清理 loading,防止陈旧代次提前关闭 loading 影响后续刷新
|
||||
if (generation === installedRefreshGeneration.value) {
|
||||
installedLoading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const mapInstalledAppToCatalogApp = (
|
||||
app: InstalledAppInfo,
|
||||
origin: "spark" | "apm",
|
||||
): App | null => {
|
||||
const appInfo = apps.value.find(
|
||||
(catalogApp) =>
|
||||
catalogApp.pkgname === app.pkgname && catalogApp.origin === origin,
|
||||
);
|
||||
|
||||
if (origin === "spark" && !appInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (appInfo) {
|
||||
appInfo.flags = app.flags;
|
||||
appInfo.arch = app.arch;
|
||||
appInfo.currentStatus = "installed";
|
||||
appInfo.isDependency = app.isDependency;
|
||||
return appInfo;
|
||||
}
|
||||
|
||||
return {
|
||||
name: app.name || app.pkgname,
|
||||
pkgname: app.pkgname,
|
||||
version: app.version,
|
||||
category: "unknown",
|
||||
tags: "",
|
||||
more: "",
|
||||
filename: "",
|
||||
torrent_address: "",
|
||||
author: "",
|
||||
contributor: "",
|
||||
website: "",
|
||||
update: "",
|
||||
size: "",
|
||||
img_urls: [],
|
||||
icons: app.icon || "",
|
||||
origin: app.origin || (app.arch?.includes("apm") ? "apm" : "spark"),
|
||||
currentStatus: "installed",
|
||||
arch: app.arch,
|
||||
flags: app.flags,
|
||||
isDependency: app.isDependency,
|
||||
};
|
||||
};
|
||||
|
||||
const refreshFavoriteInstalledApps = async (): Promise<void> => {
|
||||
const origins: Array<"spark" | "apm"> = [];
|
||||
if (isOriginEnabled(storeFilter.value, "spark") && sparkAvailable.value) {
|
||||
origins.push("spark");
|
||||
}
|
||||
if (isOriginEnabled(storeFilter.value, "apm") && apmAvailable.value) {
|
||||
origins.push("apm");
|
||||
}
|
||||
|
||||
const refreshedApps: App[] = [];
|
||||
await Promise.all(
|
||||
origins.map(async (origin) => {
|
||||
const pkgnameList =
|
||||
origin === "spark"
|
||||
? apps.value
|
||||
.filter((app) => app.origin === "spark")
|
||||
.map((app) => app.pkgname)
|
||||
: undefined;
|
||||
const result = await window.ipcRenderer.invoke("list-installed", {
|
||||
origin,
|
||||
pkgnameList,
|
||||
});
|
||||
if (!result?.success) return;
|
||||
|
||||
const appList = Array.isArray(result?.apps) ? result.apps : [];
|
||||
for (const rawApp of appList) {
|
||||
// 运行时类型守卫:避免后端字段缺失时下游访问 undefined
|
||||
if (!isInstalledAppInfo(rawApp)) continue;
|
||||
const appInfo = mapInstalledAppToCatalogApp(rawApp, origin);
|
||||
if (appInfo) refreshedApps.push(appInfo);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const refreshedKeys = new Set(
|
||||
refreshedApps.map((app) => `${app.origin}:${app.pkgname}`),
|
||||
);
|
||||
installedApps.value = [
|
||||
...installedApps.value.filter(
|
||||
(app) =>
|
||||
!origins.includes(app.origin) &&
|
||||
!refreshedKeys.has(`${app.origin}:${app.pkgname}`),
|
||||
),
|
||||
...refreshedApps,
|
||||
];
|
||||
};
|
||||
|
||||
const requestUninstall = (app: App) => {
|
||||
uninstallTargetApp.value = app;
|
||||
showUninstallModal.value = true;
|
||||
removeDownloadItem(app.pkgname);
|
||||
};
|
||||
|
||||
const openInstalledModal = () => {
|
||||
if (
|
||||
getEffectiveStoreFilter(storeFilter.value, availableSources.value) === null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
showInstalledModal.value = true;
|
||||
refreshInstalledApps();
|
||||
};
|
||||
|
||||
const closeInstalledModal = () => {
|
||||
showInstalledModal.value = false;
|
||||
// 关闭模态框时同步清空错误 / 警告,避免下次打开时残留过期状态
|
||||
installedError.value = "";
|
||||
installedWarning.value = "";
|
||||
};
|
||||
|
||||
const uninstallInstalledApp = (app: App) => {
|
||||
requestUninstall(app);
|
||||
};
|
||||
|
||||
// 应用在更新中心/详情页的安装状态变更后,刷新已安装列表(由 App.vue 编排调用)
|
||||
const onUninstallSuccess = () => {
|
||||
if (showInstalledModal.value) {
|
||||
refreshInstalledApps();
|
||||
}
|
||||
};
|
||||
|
||||
// 已安装应用与云端同步候选用 key 集合(供恢复模态判断已装项)
|
||||
const installedCloudKeys = computed(
|
||||
() => new Set(installedApps.value.map((app) => cloudItemKey(app))),
|
||||
);
|
||||
|
||||
const installedCloudPackageKeys = computed(
|
||||
() => new Set(syncCandidateApps.value.map((app) => cloudPackageKey(app))),
|
||||
);
|
||||
|
||||
export {
|
||||
isInstalledAppInfo,
|
||||
LIST_INSTALLED_TIMEOUT_MS,
|
||||
resolveInstalledOrigins,
|
||||
refreshInstalledApps,
|
||||
mapInstalledAppToCatalogApp,
|
||||
refreshFavoriteInstalledApps,
|
||||
requestUninstall,
|
||||
openInstalledModal,
|
||||
closeInstalledModal,
|
||||
uninstallInstalledApp,
|
||||
onUninstallSuccess,
|
||||
installedCloudKeys,
|
||||
installedCloudPackageKeys,
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* useRanking —— 首页区域(links + 推荐列表入口)与下载排行榜数据。
|
||||
*
|
||||
* 从原 App.vue 原样搬移(loadHome / loadRanking / fetchDownloadCount / 缓存逻辑 /
|
||||
* publishRanking),逻辑零改动。
|
||||
*
|
||||
* 共享状态(apps / homeLinks / apmRanking / sparkRanking / rankingLoading /
|
||||
* homeLoading / homeError / storeFilter)来自 useAppState 单例。
|
||||
*/
|
||||
import type { App, HomeLink } from "../global/typedefinition";
|
||||
import {
|
||||
apps,
|
||||
homeLinks,
|
||||
homeLoading,
|
||||
homeError,
|
||||
apmRanking,
|
||||
sparkRanking,
|
||||
rankingLoading,
|
||||
storeFilter,
|
||||
} from "./useAppState";
|
||||
import { rootAbortController } from "./useHttp";
|
||||
import { APM_STORE_BASE_URL } from "../global/storeConfig";
|
||||
|
||||
const DOWNLOAD_COUNT_CACHE_KEY = "spark-store:download-counts:v1";
|
||||
const DOWNLOAD_COUNT_CACHE_TTL_MS = 60 * 60 * 1000; // 1 小时
|
||||
|
||||
interface CachedCount {
|
||||
count: number;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
const downloadCountCache = new Map<string, CachedCount>();
|
||||
|
||||
const cacheKey = (app: App) => `${app.origin}:${app.category}:${app.pkgname}`;
|
||||
|
||||
const loadCacheFromStorage = () => {
|
||||
try {
|
||||
const raw = localStorage.getItem(DOWNLOAD_COUNT_CACHE_KEY);
|
||||
if (!raw) return;
|
||||
const data = JSON.parse(raw) as Record<string, CachedCount>;
|
||||
const now = Date.now();
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (now - v.ts < DOWNLOAD_COUNT_CACHE_TTL_MS) {
|
||||
downloadCountCache.set(k, v);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore corrupted cache
|
||||
}
|
||||
};
|
||||
|
||||
const saveCacheToStorage = () => {
|
||||
try {
|
||||
const obj: Record<string, CachedCount> = {};
|
||||
downloadCountCache.forEach((v, k) => {
|
||||
obj[k] = v;
|
||||
});
|
||||
localStorage.setItem(DOWNLOAD_COUNT_CACHE_KEY, JSON.stringify(obj));
|
||||
} catch {
|
||||
// ignore quota errors
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDownloadCount = async (app: App): Promise<number> => {
|
||||
const key = cacheKey(app);
|
||||
const cached = downloadCountCache.get(key);
|
||||
if (cached) return cached.count;
|
||||
const arch = window.apm_store.arch || "amd64";
|
||||
const finalArch = app.origin === "spark" ? `${arch}-store` : `${arch}-apm`;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${APM_STORE_BASE_URL}/${finalArch}/${app.category}/${app.pkgname}/download-times.txt`,
|
||||
{ signal: rootAbortController.signal },
|
||||
);
|
||||
if (!resp.ok) return 0;
|
||||
const text = (await resp.text()).trim();
|
||||
const n = parseInt(text, 10);
|
||||
const count = Number.isFinite(n) ? n : 0;
|
||||
downloadCountCache.set(key, { count, ts: Date.now() });
|
||||
return count;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const publishRanking = (results: App[]) => {
|
||||
apmRanking.value = results
|
||||
.filter((a) => a.origin === "apm")
|
||||
.sort((x, y) => (y.downloadCount || 0) - (x.downloadCount || 0))
|
||||
.slice(0, 10);
|
||||
sparkRanking.value = results
|
||||
.filter((a) => a.origin === "spark")
|
||||
.sort((x, y) => (y.downloadCount || 0) - (x.downloadCount || 0))
|
||||
.slice(0, 10);
|
||||
};
|
||||
|
||||
let rankingGeneration = 0;
|
||||
|
||||
const loadRanking = async () => {
|
||||
if (apps.value.length === 0) return;
|
||||
const gen = ++rankingGeneration;
|
||||
rankingLoading.value = true;
|
||||
const all = apps.value.slice();
|
||||
const CONCURRENCY = 15;
|
||||
const results: App[] = [];
|
||||
for (let i = 0; i < all.length; i += CONCURRENCY) {
|
||||
if (gen !== rankingGeneration) {
|
||||
saveCacheToStorage();
|
||||
return;
|
||||
}
|
||||
const batch = all.slice(i, i + CONCURRENCY);
|
||||
const settled = await Promise.all(
|
||||
batch.map(async (app) => ({
|
||||
...app,
|
||||
downloadCount: await fetchDownloadCount(app),
|
||||
})),
|
||||
);
|
||||
results.push(...settled);
|
||||
// 边拉边发:每批完成后立即发布增量排名
|
||||
if (gen === rankingGeneration) publishRanking(results);
|
||||
}
|
||||
if (gen !== rankingGeneration) {
|
||||
saveCacheToStorage();
|
||||
return;
|
||||
}
|
||||
rankingLoading.value = false;
|
||||
saveCacheToStorage();
|
||||
};
|
||||
|
||||
// 启动时即加载本地缓存(无需等待 apps),二次启动首屏即可见缓存排行
|
||||
loadCacheFromStorage();
|
||||
|
||||
// 排行榜在 loadApps 全量完成后(onMounted)触发一次,确保 spark/apm 应用均已就绪
|
||||
|
||||
const loadHome = async () => {
|
||||
homeLoading.value = true;
|
||||
homeError.value = "";
|
||||
homeLinks.value = [];
|
||||
try {
|
||||
const arch = window.apm_store.arch || "amd64";
|
||||
const modes: Array<"spark" | "apm"> =
|
||||
storeFilter.value === "both" ? ["spark", "apm"] : [storeFilter.value];
|
||||
|
||||
// 按名称去重,spark 优先:同名链接 spark 覆盖 apm
|
||||
const seenNames = new Set<string>();
|
||||
|
||||
// 并行请求各来源的 homelinks.json,缩短首页加载耗时
|
||||
const modeResults = await Promise.all(
|
||||
modes.map(async (mode) => {
|
||||
const finalArch = mode === "spark" ? `${arch}-store` : `${arch}-apm`;
|
||||
const base = `${APM_STORE_BASE_URL}/${finalArch}/home`;
|
||||
try {
|
||||
const res = await fetch(`${base}/homelinks.json`);
|
||||
if (res.ok) return { mode, raw: (await res.json()) as unknown };
|
||||
} catch (e) {
|
||||
console.warn(`Failed to load ${mode} homelinks.json`, e);
|
||||
}
|
||||
return { mode, raw: undefined };
|
||||
}),
|
||||
);
|
||||
|
||||
for (const { mode, raw } of modeResults) {
|
||||
if (!raw) continue;
|
||||
// 校验 links 为数组,且每项均为对象(避免后端返回异常结构导致运行时错误)
|
||||
const links = Array.isArray(raw)
|
||||
? (raw.filter((x) => x && typeof x === "object") as Record<
|
||||
string,
|
||||
unknown
|
||||
>[])
|
||||
: [];
|
||||
for (const l of links) {
|
||||
// 远程数据不可信,使用 typeof 运行时守卫替代 `as string` 断言,
|
||||
// 避免非字符串字段(如数字/对象)被注入状态导致下游显示异常。
|
||||
const name =
|
||||
typeof l.Name === "string"
|
||||
? l.Name
|
||||
: typeof l.name === "string"
|
||||
? l.name
|
||||
: "";
|
||||
if (!name) continue; // 跳过空名称,避免空字符串污染 seenNames 与去重逻辑
|
||||
if (seenNames.has(name)) continue; // 已由更高优先级来源(spark)占据
|
||||
// 仅校验 url 必需;远程 homelinks.json 不含 icon 字段(图片由 imgUrl 提供),
|
||||
// 故 icon 不作为硬性校验,缺省为空串以兼容 HomeLink 类型。
|
||||
const url =
|
||||
typeof l.Url === "string"
|
||||
? l.Url
|
||||
: typeof l.url === "string"
|
||||
? l.url
|
||||
: "";
|
||||
if (!url) continue;
|
||||
const icon =
|
||||
typeof l.Icon === "string"
|
||||
? l.Icon
|
||||
: typeof l.icon === "string"
|
||||
? l.icon
|
||||
: "";
|
||||
seenNames.add(name);
|
||||
// 显式提取已知字段构造,避免通过展开运算符 { ...l } 把远程不可信数据中的未知属性注入响应式状态
|
||||
const safeLink: HomeLink = {
|
||||
name,
|
||||
url,
|
||||
icon,
|
||||
more: typeof l.more === "string" ? l.more : undefined,
|
||||
imgUrl: typeof l.imgUrl === "string" ? l.imgUrl : undefined,
|
||||
type: typeof l.type === "string" ? l.type : undefined,
|
||||
origin: mode,
|
||||
};
|
||||
homeLinks.value.push(safeLink);
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
homeError.value = (error as Error)?.message || "加载首页失败";
|
||||
} finally {
|
||||
homeLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
export { loadHome, loadRanking };
|
||||
Reference in New Issue
Block a user