update:添加apm与普通商店双支持

This commit is contained in:
2026-03-11 08:36:24 +08:00
parent 8f2c758bf5
commit edd9368c56
11 changed files with 12218 additions and 128 deletions

View File

@@ -152,6 +152,7 @@ import {
APM_STORE_BASE_URL,
currentApp,
currentAppIsInstalled,
currentStoreMode,
} from "./global/storeConfig";
import {
downloads,
@@ -377,8 +378,13 @@ const checkAppInstalled = (app: App) => {
const loadScreenshots = (app: App) => {
screenshots.value = [];
const arch = window.apm_store.arch || "amd64-apm";
const finalArch =
app.origin === "spark"
? arch.replace("-apm", "-store")
: arch.replace("-store", "-apm");
for (let i = 1; i <= 5; i++) {
const screenshotUrl = `${APM_STORE_BASE_URL}/${window.apm_store.arch}/${app.category}/${app.pkgname}/screen_${i}.png`;
const screenshotUrl = `${APM_STORE_BASE_URL}/${finalArch}/${app.category}/${app.pkgname}/screen_${i}.png`;
screenshots.value.push(screenshotUrl);
}
};
@@ -411,73 +417,88 @@ const loadHome = async () => {
homeLinks.value = [];
homeLists.value = [];
try {
const base = `${APM_STORE_BASE_URL}/${window.apm_store.arch}/home`;
// homelinks.json
try {
const res = await fetch(`${base}/homelinks.json`);
if (res.ok) {
homeLinks.value = await res.json();
const arch = window.apm_store.arch || "amd64-apm";
const modes: Array<"spark" | "apm"> = [];
if (currentStoreMode.value === "hybrid") modes.push("spark", "apm");
else modes.push(currentStoreMode.value as "spark" | "apm");
for (const mode of modes) {
const finalArch =
mode === "spark"
? arch.replace("-apm", "-store")
: arch.replace("-store", "-apm");
const base = `${APM_STORE_BASE_URL}/${finalArch}/home`;
// homelinks.json
try {
const res = await fetch(cacheBuster(`${base}/homelinks.json`));
if (res.ok) {
const links = await res.json();
const taggedLinks = links.map((l: any) => ({ ...l, origin: mode }));
homeLinks.value.push(...taggedLinks);
}
} catch (e) {
console.warn(`Failed to load ${mode} homelinks.json`, e);
}
} catch (e) {
// ignore single file failures
console.warn("Failed to load homelinks.json", e);
}
// homelist.json
try {
const res2 = await fetch(`${base}/homelist.json`);
if (res2.ok) {
const lists = await res2.json();
for (const item of lists) {
if (item.type === "appList" && item.jsonUrl) {
try {
const url = `${APM_STORE_BASE_URL}/${window.apm_store.arch}${item.jsonUrl}`;
const r = await fetch(url);
if (r.ok) {
const appsJson = await r.json();
const rawApps = appsJson || [];
const apps = await Promise.all(
rawApps.map(async (a: Record<string, unknown>) => {
const baseApp = {
name: a.Name || a.name || a.Pkgname || a.PkgName || "",
pkgname: a.Pkgname || a.pkgname || "",
category: a.Category || a.category || "unknown",
more: a.More || a.more || "",
version: a.Version || "",
filename: a.Filename || a.filename || "",
};
// homelist.json
try {
const res2 = await fetch(cacheBuster(`${base}/homelist.json`));
if (res2.ok) {
const lists = await res2.json();
for (const item of lists) {
if (item.type === "appList" && item.jsonUrl) {
try {
const url = `${APM_STORE_BASE_URL}/${finalArch}${item.jsonUrl}`;
const r = await fetch(cacheBuster(url));
if (r.ok) {
const appsJson = await r.json();
const rawApps = appsJson || [];
const apps = await Promise.all(
rawApps.map(async (a: any) => {
const baseApp = {
name: a.Name || a.name || a.Pkgname || a.PkgName || "",
pkgname: a.Pkgname || a.pkgname || "",
category: a.Category || a.category || "unknown",
more: a.More || a.more || "",
version: a.Version || "",
filename: a.Filename || a.filename || "",
origin: mode as "spark" | "apm",
};
// 根据官网的要求读取Category和Pkgname拼接出 源地址/架构/Category/Pkgname/app.json来获取对应的真实json
try {
const realAppUrl = `${APM_STORE_BASE_URL}/${window.apm_store.arch}/${baseApp.category}/${baseApp.pkgname}/app.json`;
const realRes = await fetch(realAppUrl);
if (realRes.ok) {
const realApp = await realRes.json();
// 用真实json的filename字段和More字段来增补和覆盖当前的json
if (realApp.Filename)
baseApp.filename = realApp.Filename;
if (realApp.More) baseApp.more = realApp.More;
if (realApp.Name) baseApp.name = realApp.Name;
try {
const realAppUrl = `${APM_STORE_BASE_URL}/${finalArch}/${baseApp.category}/${baseApp.pkgname}/app.json`;
const realRes = await fetch(cacheBuster(realAppUrl));
if (realRes.ok) {
const realApp = await realRes.json();
if (realApp.Filename)
baseApp.filename = realApp.Filename;
if (realApp.More) baseApp.more = realApp.More;
if (realApp.Name) baseApp.name = realApp.Name;
}
} catch (e) {
console.warn(
`Failed to fetch real app.json for ${baseApp.pkgname}`,
e,
);
}
} catch (e) {
console.warn(
`Failed to fetch real app.json for ${baseApp.pkgname}`,
e,
);
}
return baseApp;
}),
);
homeLists.value.push({ title: item.name || "推荐", apps });
return baseApp;
}),
);
homeLists.value.push({
title: `${item.name || "推荐"} (${mode === "spark" ? "星火" : "APM"})`,
apps,
});
}
} catch (e) {
console.warn("Failed to load home list", item, e);
}
} catch (e) {
console.warn("Failed to load home list", item, e);
}
}
}
} catch (e) {
console.warn(`Failed to load ${mode} homelist.json`, e);
}
} catch (e) {
console.warn("Failed to load homelist.json", e);
}
} catch (error: unknown) {
homeError.value = (error as Error)?.message || "加载首页失败";
@@ -598,6 +619,7 @@ const upgradeSingleApp = (app: UpdateAppItem) => {
size: "",
img_urls: [],
icons: "",
origin: "apm", // Default to APM if unknown, or try to guess
currentStatus: "installed",
};
handleUpgrade(minimalApp);
@@ -656,6 +678,7 @@ const refreshInstalledApps = async () => {
size: "",
img_urls: [],
icons: "",
origin: app.origin || (app.arch?.includes("apm") ? "apm" : "spark"),
currentStatus: "installed",
arch: app.arch,
flags: app.flags,
@@ -800,12 +823,36 @@ const openDownloadedApp = (pkgname: string) => {
const loadCategories = async () => {
try {
const response = await axiosInstance.get(
cacheBuster(`/${window.apm_store.arch}/categories.json`),
);
categories.value = response.data;
const arch = window.apm_store.arch || "amd64-apm";
const modes: Array<"spark" | "apm"> = [];
if (currentStoreMode.value === "hybrid") modes.push("spark", "apm");
else modes.push(currentStoreMode.value as "spark" | "apm");
const categoryData: Record<string, { zh: string; origin: string }> = {};
for (const mode of modes) {
const finalArch =
mode === "spark"
? arch.replace("-apm", "-store")
: arch.replace("-store", "-apm");
const path = mode === "spark" ? "/store/categories.json" : `/${finalArch}/categories.json`;
try {
const response = await axiosInstance.get(cacheBuster(path));
const data = response.data;
Object.keys(data).forEach((key) => {
categoryData[key] = {
zh: data[key].zh || data[key],
origin: mode,
};
});
} catch (e) {
logger.error(`读取 ${mode} categories.json 失败: ${e}`);
}
}
categories.value = categoryData;
} catch (error) {
logger.error(`读取 categories.json 失败: ${error}`);
logger.error(`读取 categories 失败: ${error}`);
}
};
@@ -815,14 +862,27 @@ const loadApps = async (onFirstBatch?: () => void) => {
const categoriesList = Object.keys(categories.value || {});
let firstBatchCallDone = false;
const arch = window.apm_store.arch || "amd64-apm";
// 并发加载所有分类,每个分类自带重试机制
await Promise.all(
categoriesList.map(async (category) => {
try {
logger.info(`加载分类: ${category}`);
const catInfo = categories.value[category];
const mode = catInfo.origin;
const finalArch =
mode === "spark"
? arch.replace("-apm", "-store")
: arch.replace("-store", "-apm");
const path =
mode === "spark"
? `/store/${category}/applist.json`
: `/${finalArch}/${category}/applist.json`;
logger.info(`加载分类: ${category} (来源: ${mode})`);
const categoryApps = await fetchWithRetry<AppJson[]>(
cacheBuster(`/${window.apm_store.arch}/${category}/applist.json`),
cacheBuster(path),
);
const normalizedApps = (categoryApps || []).map((appJson) => ({
@@ -844,6 +904,7 @@ const loadApps = async (onFirstBatch?: () => void) => {
: appJson.img_urls,
icons: appJson.icons,
category: category,
origin: mode as "spark" | "apm",
currentStatus: "not-installed" as const,
}));

View File

@@ -17,10 +17,22 @@
/>
</div>
<div class="flex flex-1 flex-col gap-1 overflow-hidden">
<div
class="truncate text-base font-semibold text-slate-900 dark:text-white"
>
{{ app.name || "" }}
<div class="flex items-center gap-2">
<div
class="truncate text-base font-semibold text-slate-900 dark:text-white"
>
{{ app.name || "" }}
</div>
<span
:class="[
'rounded-md px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider shadow-sm',
app.origin === 'spark'
? 'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400'
: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400',
]"
>
{{ app.origin === "spark" ? "Spark" : "APM" }}
</span>
</div>
<div class="text-sm text-slate-500 dark:text-slate-400">
{{ app.pkgname || "" }} · {{ app.version || "" }}
@@ -52,7 +64,12 @@ const loadedIcon = ref(
);
const iconPath = computed(() => {
return `${APM_STORE_BASE_URL}/${window.apm_store.arch}/${props.app.category}/${props.app.pkgname}/icon.png`;
const arch = window.apm_store.arch || "amd64-apm";
const finalArch =
props.app.origin === "spark"
? arch.replace("-apm", "-store")
: arch.replace("-store", "-apm");
return `${APM_STORE_BASE_URL}/${finalArch}/${props.app.category}/${props.app.pkgname}/icon.png`;
});
const description = computed(() => {

View File

@@ -36,7 +36,17 @@
<p class="text-2xl font-bold text-slate-900 dark:text-white">
{{ app?.name || "" }}
</p>
<!-- Close button for mobile layout could be considered here if needed, but for now sticking to desktop layout logic mainly -->
<span
v-if="app"
:class="[
'rounded-md px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider shadow-sm',
app.origin === 'spark'
? 'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400'
: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400',
]"
>
{{ app.origin === "spark" ? "Spark" : "APM" }}
</span>
</div>
<p class="text-sm text-slate-500 dark:text-slate-400">
{{ app?.pkgname || "" }} · {{ app?.version || "" }}
@@ -269,7 +279,12 @@ const installBtnText = computed(() => {
});
const iconPath = computed(() => {
if (!props.app) return "";
return `${APM_STORE_BASE_URL}/${window.apm_store.arch}/${props.app.category}/${props.app.pkgname}/icon.png`;
const arch = window.apm_store.arch || "amd64-apm";
const finalArch =
props.app.origin === "spark"
? arch.replace("-apm", "-store")
: arch.replace("-store", "-apm");
return `${APM_STORE_BASE_URL}/${finalArch}/${props.app.category}/${props.app.pkgname}/icon.png`;
});
const downloadCount = ref<string>("");
@@ -281,7 +296,12 @@ watch(
if (newApp) {
downloadCount.value = "";
try {
const url = `${APM_STORE_BASE_URL}/${window.apm_store.arch}/${newApp.category}/${newApp.pkgname}/download-times.txt`;
const arch = window.apm_store.arch || "amd64-apm";
const finalArch =
newApp.origin === "spark"
? arch.replace("-apm", "-store")
: arch.replace("-store", "-apm");
const url = `${APM_STORE_BASE_URL}/${finalArch}/${newApp.category}/${newApp.pkgname}/download-times.txt`;
const resp = await axios.get(url, { responseType: "text" });
if (resp.status === 200) {
downloadCount.value = String(resp.data).trim();

View File

@@ -28,6 +28,7 @@
</div>
<ThemeToggle :theme-mode="themeMode" @toggle="toggleTheme" />
<StoreModeSwitcher />
<div class="flex-1 space-y-2 overflow-y-auto scrollbar-muted pr-2">
<button
@@ -88,6 +89,7 @@
<script setup lang="ts">
import ThemeToggle from "./ThemeToggle.vue";
import StoreModeSwitcher from "./StoreModeSwitcher.vue";
import amberLogo from "../assets/imgs/spark-store.svg";
defineProps<{

View File

@@ -15,7 +15,7 @@
:title="link.more"
>
<img
:src="computedImgUrl(link.imgUrl)"
:src="computedImgUrl(link)"
class="h-20 w-full object-contain"
loading="lazy"
/>
@@ -62,10 +62,14 @@ defineEmits<{
(e: "open-detail", app: Record<string, unknown>): void;
}>();
const computedImgUrl = (imgUrl: string) => {
if (!imgUrl) return "";
// imgUrl is like /home/links/bbs.png -> join with base
return `${APM_STORE_BASE_URL}/${window.apm_store.arch}${imgUrl}`;
const computedImgUrl = (link: Record<string, any>) => {
if (!link.imgUrl) return "";
const arch = window.apm_store.arch || "amd64-apm";
const finalArch =
link.origin === "spark"
? arch.replace("-apm", "-store")
: arch.replace("-store", "-apm");
return `${APM_STORE_BASE_URL}/${finalArch}${link.imgUrl}`;
};
const onLinkClick = (link: Record<string, unknown>) => {

View File

@@ -0,0 +1,38 @@
<template>
<div class="flex flex-col gap-2 p-4 rounded-2xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200/70 dark:border-slate-700/70">
<span class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 px-1">商店模式</span>
<div class="grid grid-cols-3 gap-1 p-1 bg-slate-200/50 dark:bg-slate-900/50 rounded-xl">
<button
v-for="mode in modes"
:key="mode.id"
type="button"
class="flex flex-col items-center justify-center py-2 px-1 rounded-lg text-[10px] font-medium transition-all duration-200"
:class="currentStoreMode === mode.id
? 'bg-white dark:bg-slate-700 text-brand shadow-sm scale-105 z-10'
: 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200'"
@click="setMode(mode.id as StoreMode)"
>
<i :class="mode.icon" class="mb-1 text-xs"></i>
{{ mode.label }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { currentStoreMode } from "../global/storeConfig";
import type { StoreMode } from "../global/typedefinition";
const modes = [
{ id: "spark", label: "星火", icon: "fas fa-fire" },
{ id: "apm", label: "APM", icon: "fas fa-box-open" },
{ id: "hybrid", label: "混合", icon: "fas fa-layer-group" },
];
const setMode = (mode: StoreMode) => {
currentStoreMode.value = mode;
localStorage.setItem("store_mode", mode);
// Reload page to re-fetch data based on new mode
window.location.reload();
};
</script>

View File

@@ -1,5 +1,5 @@
import { ref } from "vue";
import type { App } from "./typedefinition";
import type { App, StoreMode } from "./typedefinition";
export const APM_STORE_BASE_URL: string =
import.meta.env.VITE_APM_STORE_BASE_URL || "";
@@ -10,3 +10,14 @@ export const APM_STORE_STATS_BASE_URL: string =
// 下面的变量用于存储当前应用的信息,其实用在多个组件中
export const currentApp = ref<App | null>(null);
export const currentAppIsInstalled = ref(false);
const initialMode = (localStorage.getItem("store_mode") as StoreMode) || "hybrid";
export const currentStoreMode = ref<StoreMode>(initialMode);
declare global {
interface Window {
apm_store: {
arch: string;
};
}
}

View File

@@ -23,6 +23,8 @@ export type DownloadItemStatus =
| "failed"
| "queued"; // 可根据实际状态扩展
export type StoreMode = "spark" | "apm" | "hybrid";
export interface DownloadItem {
id: number;
name: string;
@@ -42,6 +44,7 @@ export interface DownloadItem {
message: string; // 日志消息
}>;
source: string; // 例如 'APM Store'
origin: "spark" | "apm"; // 数据来源
retry: boolean; // 当前是否为重试下载
upgradeOnly?: boolean; // 是否为仅升级任务
error?: string;
@@ -99,6 +102,7 @@ export interface App {
img_urls: string[];
icons: string;
category: string; // Frontend added
origin: "spark" | "apm"; // 数据来源
installed?: boolean; // Frontend state
flags?: string; // Tags in apm packages manager, e.g. "automatic" for dependencies
arch?: string; // Architecture, e.g. "amd64", "arm64"

View File

@@ -33,12 +33,19 @@ export const handleInstall = () => {
downloadIdCounter += 1;
// 创建下载任务
const arch = window.apm_store.arch || "amd64-apm";
const finalArch =
currentApp.value.origin === "spark"
? arch.replace("-apm", "-store")
: arch.replace("-store", "-apm");
const download: DownloadItem = {
id: downloadIdCounter,
name: currentApp.value.name,
pkgname: currentApp.value.pkgname,
version: currentApp.value.version,
icon: `${APM_STORE_BASE_URL}/${window.apm_store.arch}/${currentApp.value.category}/${currentApp.value.pkgname}/icon.png`,
icon: `${APM_STORE_BASE_URL}/${finalArch}/${currentApp.value.category}/${currentApp.value.pkgname}/icon.png`,
origin: currentApp.value.origin,
status: "queued",
progress: 0,
downloadedSize: 0,
@@ -97,12 +104,18 @@ export const handleUpgrade = (app: App) => {
}
downloadIdCounter += 1;
const arch = window.apm_store.arch || "amd64-apm";
const finalArch =
app.origin === "spark"
? arch.replace("-apm", "-store")
: arch.replace("-store", "-apm");
const download: DownloadItem = {
id: downloadIdCounter,
name: app.name,
pkgname: app.pkgname,
version: app.version,
icon: `${APM_STORE_BASE_URL}/${window.apm_store.arch}/${app.category}/${app.pkgname}/icon.png`,
icon: `${APM_STORE_BASE_URL}/${finalArch}/${app.category}/${app.pkgname}/icon.png`,
status: "queued",
progress: 0,
downloadedSize: 0,
@@ -114,6 +127,7 @@ export const handleUpgrade = (app: App) => {
source: "APM Update",
retry: false,
upgradeOnly: true,
origin: app.origin,
};
downloads.value.push(download);
@@ -122,7 +136,10 @@ export const handleUpgrade = (app: App) => {
export const handleRemove = () => {
if (!currentApp.value?.pkgname) return;
window.ipcRenderer.send("remove-installed", currentApp.value.pkgname);
window.ipcRenderer.send("remove-installed", {
pkgname: currentApp.value.pkgname,
origin: currentApp.value.origin,
});
};
window.ipcRenderer.on("remove-complete", (_event, log: DownloadResult) => {