update:在商店混合模式下,同包名的软件合并在同一个详情页面并加入切换开关

This commit is contained in:
2026-03-11 09:18:59 +08:00
parent 66bf0124bd
commit d24a5225de
8 changed files with 150 additions and 67 deletions

View File

@@ -65,8 +65,8 @@
:screenshots="screenshots" :screenshots="screenshots"
:isinstalled="currentAppIsInstalled" :isinstalled="currentAppIsInstalled"
@close="closeDetail" @close="closeDetail"
@install="handleInstall" @install="onDetailInstall"
@remove="requestUninstallFromDetail" @remove="onDetailRemove"
@open-preview="openScreenPreview" @open-preview="openScreenPreview"
@open-app="openDownloadedApp" @open-app="openDownloadedApp"
/> />
@@ -163,6 +163,7 @@ import {
handleInstall, handleInstall,
handleRetry, handleRetry,
handleUpgrade, handleUpgrade,
handleRemove,
} from "./modules/processInstall"; } from "./modules/processInstall";
import type { import type {
App, App,
@@ -295,6 +296,27 @@ watch(currentStoreMode, async (newMode, oldMode) => {
const filteredApps = computed(() => { const filteredApps = computed(() => {
let result = [...apps.value]; let result = [...apps.value];
// 合并相同包名的应用 (混合模式)
if (currentStoreMode.value === "hybrid") {
const mergedMap = new Map<string, App>();
for (const app of result) {
const existing = mergedMap.get(app.pkgname);
if (existing) {
if (!existing.isMerged) {
existing.isMerged = true;
// 根据当前的 origin 分配到对应的属性
if (existing.origin === "spark") existing.sparkApp = { ...existing };
else if (existing.origin === "apm") existing.apmApp = { ...existing };
}
if (app.origin === "spark") existing.sparkApp = app;
else if (app.origin === "apm") existing.apmApp = app;
} else {
mergedMap.set(app.pkgname, { ...app });
}
}
result = Array.from(mergedMap.values());
}
// 按分类筛选 // 按分类筛选
if (activeCategory.value !== "all") { if (activeCategory.value !== "all") {
result = result.filter((app) => app.category === activeCategory.value); result = result.filter((app) => app.category === activeCategory.value);
@@ -383,8 +405,12 @@ const openDetail = (app: App | Record<string, unknown>) => {
return; return;
} }
// 尝试从全局 apps 中查找完整 App // 首先尝试从当前已经处理好(合并/筛选)的 filteredApps 中查找,以便获取 isMerged 状态等
let fullApp = apps.value.find(a => a.pkgname === pkgname); let fullApp = filteredApps.value.find(a => a.pkgname === pkgname);
// 如果没找到(可能是从已安装列表之类的其他入口打开的),回退到全局 apps 中查找完整 App
if (!fullApp) {
fullApp = apps.value.find(a => a.pkgname === pkgname);
}
if (!fullApp) { if (!fullApp) {
// 构造一个最小可用的 App 对象 // 构造一个最小可用的 App 对象
fullApp = { fullApp = {
@@ -769,6 +795,14 @@ const requestUninstallFromDetail = () => {
} }
}; };
const onDetailRemove = (app: App) => {
requestUninstall(app);
};
const onDetailInstall = (app: App) => {
handleInstall(app);
};
const closeUninstallModal = () => { const closeUninstallModal = () => {
showUninstallModal.value = false; showUninstallModal.value = false;
uninstallTargetApp.value = null; uninstallTargetApp.value = null;
@@ -785,8 +819,8 @@ const onUninstallSuccess = () => {
} }
}; };
const installCompleteCallback = (pkgname?: string) => { const installCompleteCallback = (pkgname?: string, status?: string) => {
if (currentApp.value && (!pkgname || currentApp.value.pkgname === pkgname)) { if (currentApp.value && (!pkgname || currentApp.value.pkgname === pkgname) && status === "completed") {
checkAppInstalled(currentApp.value); checkAppInstalled(currentApp.value);
} }
}; };

View File

@@ -22,6 +22,7 @@ describe("downloadStatus", () => {
startTime: Date.now(), startTime: Date.now(),
logs: [], logs: [],
source: "Test", source: "Test",
origin: "apm",
retry: false, retry: false,
}); });

View File

@@ -26,12 +26,14 @@
<span <span
:class="[ :class="[
'rounded-md px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider shadow-sm', 'rounded-md px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider shadow-sm',
app.origin === 'spark' app.isMerged
? 'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400' ? 'bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400'
: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400', : 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" }} {{ app.isMerged ? "Spark/APM" : app.origin === "spark" ? "Spark" : "APM" }}
</span> </span>
</div> </div>
<div class="text-sm text-slate-500 dark:text-slate-400"> <div class="text-sm text-slate-500 dark:text-slate-400">

View File

@@ -34,22 +34,42 @@
<div class="space-y-1"> <div class="space-y-1">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<p class="text-2xl font-bold text-slate-900 dark:text-white"> <p class="text-2xl font-bold text-slate-900 dark:text-white">
{{ app?.name || "" }} {{ displayApp?.name || "" }}
</p> </p>
<div v-if="app?.isMerged" class="flex gap-1 overflow-hidden rounded-md shadow-sm border border-slate-200 dark:border-slate-700 font-medium ml-1">
<button
v-if="app.sparkApp"
type="button"
class="px-2 py-0.5 text-[10px] uppercase tracking-wider transition-colors"
:class="viewingOrigin === 'spark' ? 'bg-orange-500 text-white' : 'bg-slate-100/50 text-slate-500 dark:bg-slate-800 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-700'"
@click="viewingOrigin = 'spark'"
>
Spark
</button>
<button
v-if="app.apmApp"
type="button"
class="px-2 py-0.5 text-[10px] uppercase tracking-wider transition-colors"
:class="viewingOrigin === 'apm' ? 'bg-blue-500 text-white' : 'bg-slate-100/50 text-slate-500 dark:bg-slate-800 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-700'"
@click="viewingOrigin = 'apm'"
>
APM
</button>
</div>
<span <span
v-if="app" v-else-if="displayApp"
:class="[ :class="[
'rounded-md px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider shadow-sm', 'rounded-md px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider shadow-sm',
app.origin === 'spark' displayApp.origin === 'spark'
? 'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400' ? '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', : 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400',
]" ]"
> >
{{ app.origin === "spark" ? "Spark" : "APM" }} {{ displayApp.origin === "spark" ? "Spark" : "APM" }}
</span> </span>
</div> </div>
<p class="text-sm text-slate-500 dark:text-slate-400"> <p class="text-sm text-slate-500 dark:text-slate-400">
{{ app?.pkgname || "" }} · {{ app?.version || "" }} {{ displayApp?.pkgname || "" }} · {{ displayApp?.version || "" }}
<span v-if="downloadCount"> · 下载量{{ downloadCount }}</span> <span v-if="downloadCount"> · 下载量{{ downloadCount }}</span>
</p> </p>
</div> </div>
@@ -77,7 +97,7 @@
<button <button
type="button" type="button"
class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-r from-brand to-brand-dark px-4 py-2 text-sm font-semibold text-white shadow-lg transition hover:-translate-y-0.5" class="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-r from-brand to-brand-dark px-4 py-2 text-sm font-semibold text-white shadow-lg transition hover:-translate-y-0.5"
@click="emit('open-app', app?.pkgname || '')" @click="emit('open-app', displayApp?.pkgname || '')"
> >
<i class="fas fa-external-link-alt"></i> <i class="fas fa-external-link-alt"></i>
<span>打开</span> <span>打开</span>
@@ -130,82 +150,82 @@
<div class="mt-6 grid gap-4 sm:grid-cols-2"> <div class="mt-6 grid gap-4 sm:grid-cols-2">
<div <div
v-if="app?.author" v-if="displayApp?.author"
class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60" class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"
> >
<p class="text-xs uppercase tracking-wide text-slate-400">作者</p> <p class="text-xs uppercase tracking-wide text-slate-400">作者</p>
<p class="text-sm font-medium text-slate-800 dark:text-slate-200"> <p class="text-sm font-medium text-slate-800 dark:text-slate-200">
{{ app.author }} {{ displayApp.author }}
</p> </p>
</div> </div>
<div <div
v-if="app?.contributor" v-if="displayApp?.contributor"
class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60" class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"
> >
<p class="text-xs uppercase tracking-wide text-slate-400">贡献者</p> <p class="text-xs uppercase tracking-wide text-slate-400">贡献者</p>
<p class="text-sm font-medium text-slate-800 dark:text-slate-200"> <p class="text-sm font-medium text-slate-800 dark:text-slate-200">
{{ app.contributor }} {{ displayApp.contributor }}
</p> </p>
</div> </div>
<div <div
v-if="app?.size" v-if="displayApp?.size"
class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60" class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"
> >
<p class="text-xs uppercase tracking-wide text-slate-400">大小</p> <p class="text-xs uppercase tracking-wide text-slate-400">大小</p>
<p class="text-sm font-medium text-slate-800 dark:text-slate-200"> <p class="text-sm font-medium text-slate-800 dark:text-slate-200">
{{ app.size }} {{ displayApp.size }}
</p> </p>
</div> </div>
<div <div
v-if="app?.update" v-if="displayApp?.update"
class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60" class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"
> >
<p class="text-xs uppercase tracking-wide text-slate-400"> <p class="text-xs uppercase tracking-wide text-slate-400">
更新时间 更新时间
</p> </p>
<p class="text-sm font-medium text-slate-800 dark:text-slate-200"> <p class="text-sm font-medium text-slate-800 dark:text-slate-200">
{{ app.update }} {{ displayApp.update }}
</p> </p>
</div> </div>
<div <div
v-if="app?.website" v-if="displayApp?.website"
class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60" class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"
> >
<p class="text-xs uppercase tracking-wide text-slate-400">网站</p> <p class="text-xs uppercase tracking-wide text-slate-400">网站</p>
<a <a
:href="app.website" :href="displayApp.website"
target="_blank" target="_blank"
class="text-sm font-medium text-brand hover:underline" class="text-sm font-medium text-brand hover:underline"
>{{ app.website }}</a >{{ displayApp.website }}</a
> >
</div> </div>
<div <div
v-if="app?.version" v-if="displayApp?.version"
class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60" class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"
> >
<p class="text-xs uppercase tracking-wide text-slate-400">版本</p> <p class="text-xs uppercase tracking-wide text-slate-400">版本</p>
<p class="text-sm font-medium text-slate-800 dark:text-slate-200"> <p class="text-sm font-medium text-slate-800 dark:text-slate-200">
{{ app.version }} {{ displayApp.version }}
</p> </p>
</div> </div>
<div <div
v-if="app?.tags" v-if="displayApp?.tags"
class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60" class="rounded-2xl border border-slate-200/60 p-4 dark:border-slate-800/60"
> >
<p class="text-xs uppercase tracking-wide text-slate-400">标签</p> <p class="text-xs uppercase tracking-wide text-slate-400">标签</p>
<p class="text-sm font-medium text-slate-800 dark:text-slate-200"> <p class="text-sm font-medium text-slate-800 dark:text-slate-200">
{{ app.tags }} {{ displayApp.tags }}
</p> </p>
</div> </div>
</div> </div>
<div v-if="app?.more && app.more.trim() !== ''" class="mt-6 space-y-3"> <div v-if="displayApp?.more && displayApp.more.trim() !== ''" class="mt-6 space-y-3">
<h3 class="text-lg font-semibold text-slate-900 dark:text-white"> <h3 class="text-lg font-semibold text-slate-900 dark:text-white">
应用详情 应用详情
</h3> </h3>
<div <div
class="max-h-60 space-y-2 overflow-y-auto rounded-2xl border border-slate-200/60 bg-slate-50/80 p-4 text-sm leading-relaxed text-slate-600 dark:border-slate-800/60 dark:bg-slate-900/60 dark:text-slate-300" class="max-h-60 space-y-2 overflow-y-auto rounded-2xl border border-slate-200/60 bg-slate-50/80 p-4 text-sm leading-relaxed text-slate-600 dark:border-slate-800/60 dark:bg-slate-900/60 dark:text-slate-300"
v-html="app.more.replace(/\n/g, '<br>')" v-html="displayApp.more.replace(/\n/g, '<br>')"
></div> ></div>
</div> </div>
</div> </div>
@@ -235,8 +255,8 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
(e: "close"): void; (e: "close"): void;
(e: "install"): void; (e: "install", app: App): void;
(e: "remove"): void; (e: "remove", app: App): void;
(e: "open-preview", index: number): void; (e: "open-preview", index: number): void;
(e: "open-app", pkgname: string): void; (e: "open-app", pkgname: string): void;
}>(); }>();
@@ -245,15 +265,33 @@ const appPkgname = computed(() => props.app?.pkgname);
const isIconLoaded = ref(false); const isIconLoaded = ref(false);
const viewingOrigin = ref<"spark" | "apm">("spark");
watch( watch(
() => props.app, () => props.app,
() => { (newApp) => {
isIconLoaded.value = false; isIconLoaded.value = false;
if (newApp) {
if (newApp.isMerged) {
viewingOrigin.value = newApp.sparkApp ? "spark" : "apm";
} else {
viewingOrigin.value = newApp.origin;
}
}
}, },
{ immediate: true },
); );
const displayApp = computed(() => {
if (!props.app) return null;
if (!props.app.isMerged) return props.app;
return viewingOrigin.value === "spark"
? props.app.sparkApp || props.app
: props.app.apmApp || props.app;
});
const activeDownload = computed(() => { const activeDownload = computed(() => {
return downloads.value.find((d) => d.pkgname === props.app?.pkgname); return downloads.value.find((d) => d.pkgname === displayApp.value?.pkgname);
}); });
const { installFeedback } = useInstallFeedback(appPkgname); const { installFeedback } = useInstallFeedback(appPkgname);
@@ -278,20 +316,20 @@ const installBtnText = computed(() => {
return "安装"; return "安装";
}); });
const iconPath = computed(() => { const iconPath = computed(() => {
if (!props.app) return ""; if (!displayApp.value) return "";
const arch = window.apm_store.arch || "amd64-apm"; const arch = window.apm_store.arch || "amd64-apm";
const finalArch = const finalArch =
props.app.origin === "spark" displayApp.value.origin === "spark"
? arch.replace("-apm", "-store") ? arch.replace("-apm", "-store")
: arch.replace("-store", "-apm"); : arch.replace("-store", "-apm");
return `${APM_STORE_BASE_URL}/${finalArch}/${props.app.category}/${props.app.pkgname}/icon.png`; return `${APM_STORE_BASE_URL}/${finalArch}/${displayApp.value.category}/${displayApp.value.pkgname}/icon.png`;
}); });
const downloadCount = ref<string>(""); const downloadCount = ref<string>("");
// 监听 app 变化获取新app的下载量 // 监听 app 变化获取新app的下载量
watch( watch(
() => props.app, () => displayApp.value,
async (newApp) => { async (newApp) => {
if (newApp) { if (newApp) {
downloadCount.value = ""; downloadCount.value = "";
@@ -322,11 +360,15 @@ const closeModal = () => {
}; };
const handleInstall = () => { const handleInstall = () => {
emit("install"); if (displayApp.value) {
emit("install", displayApp.value);
}
}; };
const handleRemove = () => { const handleRemove = () => {
emit("remove"); if (displayApp.value) {
emit("remove", displayApp.value);
}
}; };
const openPreview = (index: number) => { const openPreview = (index: number) => {

View File

@@ -52,14 +52,14 @@ import AppCard from "./AppCard.vue";
import { APM_STORE_BASE_URL } from "../global/storeConfig"; import { APM_STORE_BASE_URL } from "../global/storeConfig";
defineProps<{ defineProps<{
links: Array<Record<string, unknown>>; links: Array<any>;
lists: Array<{ title: string; apps: Record<string, unknown>[] }>; lists: Array<{ title: string; apps: any[] }>;
loading: boolean; loading: boolean;
error: string; error: string;
}>(); }>();
defineEmits<{ defineEmits<{
(e: "open-detail", app: Record<string, unknown>): void; (e: "open-detail", app: any): void;
}>(); }>();
const computedImgUrl = (link: Record<string, any>) => { const computedImgUrl = (link: Record<string, any>) => {
@@ -72,7 +72,7 @@ const computedImgUrl = (link: Record<string, any>) => {
return `${APM_STORE_BASE_URL}/${finalArch}${link.imgUrl}`; return `${APM_STORE_BASE_URL}/${finalArch}${link.imgUrl}`;
}; };
const onLinkClick = (link: Record<string, unknown>) => { const onLinkClick = (link: any) => {
if (link.type === "_blank") { if (link.type === "_blank") {
window.open(link.url, "_blank"); window.open(link.url, "_blank");
} else { } else {

View File

@@ -16,8 +16,6 @@ export const currentStoreMode = ref<StoreMode>(initialMode);
declare global { declare global {
interface Window { interface Window {
apm_store: { apm_store: any;
arch: string;
};
} }
} }

View File

@@ -107,6 +107,10 @@ export interface App {
flags?: string; // Tags in apm packages manager, e.g. "automatic" for dependencies flags?: string; // Tags in apm packages manager, e.g. "automatic" for dependencies
arch?: string; // Architecture, e.g. "amd64", "arm64" arch?: string; // Architecture, e.g. "amd64", "arm64"
currentStatus: "not-installed" | "installed"; // Current installation status currentStatus: "not-installed" | "installed"; // Current installation status
isMerged?: boolean; // FLAG for overlapping apps
sparkApp?: App; // Optional reference to the spark version
apmApp?: App; // Optional reference to the apm version
viewingOrigin?: "spark" | "apm"; // Currently viewed origin inside the app modal
} }
export interface UpdateAppItem { export interface UpdateAppItem {

View File

@@ -23,11 +23,12 @@ import axios from "axios";
let downloadIdCounter = 0; let downloadIdCounter = 0;
const logger = pino({ name: "processInstall.ts" }); const logger = pino({ name: "processInstall.ts" });
export const handleInstall = () => { export const handleInstall = (appObj?: App) => {
if (!currentApp.value?.pkgname) return; const targetApp = appObj || currentApp.value;
if (!targetApp?.pkgname) return;
if (downloads.value.find((d) => d.pkgname === currentApp.value?.pkgname)) { if (downloads.value.find((d) => d.pkgname === targetApp.pkgname)) {
logger.info(`任务已存在,忽略重复添加: ${currentApp.value.pkgname}`); logger.info(`任务已存在,忽略重复添加: ${targetApp.pkgname}`);
return; return;
} }
@@ -35,17 +36,17 @@ export const handleInstall = () => {
// 创建下载任务 // 创建下载任务
const arch = window.apm_store.arch || "amd64-apm"; const arch = window.apm_store.arch || "amd64-apm";
const finalArch = const finalArch =
currentApp.value.origin === "spark" targetApp.origin === "spark"
? arch.replace("-apm", "-store") ? arch.replace("-apm", "-store")
: arch.replace("-store", "-apm"); : arch.replace("-store", "-apm");
const download: DownloadItem = { const download: DownloadItem = {
id: downloadIdCounter, id: downloadIdCounter,
name: currentApp.value.name, name: targetApp.name,
pkgname: currentApp.value.pkgname, pkgname: targetApp.pkgname,
version: currentApp.value.version, version: targetApp.version,
icon: `${APM_STORE_BASE_URL}/${finalArch}/${currentApp.value.category}/${currentApp.value.pkgname}/icon.png`, icon: `${APM_STORE_BASE_URL}/${finalArch}/${targetApp.category}/${targetApp.pkgname}/icon.png`,
origin: currentApp.value.origin, origin: targetApp.origin,
status: "queued", status: "queued",
progress: 0, progress: 0,
downloadedSize: 0, downloadedSize: 0,
@@ -56,8 +57,8 @@ export const handleInstall = () => {
logs: [{ time: Date.now(), message: "开始下载..." }], logs: [{ time: Date.now(), message: "开始下载..." }],
source: "APM Store", source: "APM Store",
retry: false, retry: false,
filename: currentApp.value.filename, filename: targetApp.filename,
metalinkUrl: `${window.apm_store.arch}/${currentApp.value.category}/${currentApp.value.pkgname}/${currentApp.value.filename}.metalink`, metalinkUrl: `${window.apm_store.arch}/${targetApp.category}/${targetApp.pkgname}/${targetApp.filename}.metalink`,
}; };
downloads.value.push(download); downloads.value.push(download);
@@ -75,7 +76,7 @@ export const handleInstall = () => {
.post( .post(
"/handle_post", "/handle_post",
{ {
path: `${window.apm_store.arch}/${currentApp.value.category}/${currentApp.value.pkgname}`, path: `${window.apm_store.arch}/${targetApp.category}/${targetApp.pkgname}`,
}, },
{ {
headers: { headers: {
@@ -134,11 +135,12 @@ export const handleUpgrade = (app: App) => {
window.ipcRenderer.send("queue-install", JSON.stringify(download)); window.ipcRenderer.send("queue-install", JSON.stringify(download));
}; };
export const handleRemove = () => { export const handleRemove = (appObj?: App) => {
if (!currentApp.value?.pkgname) return; const targetApp = appObj || currentApp.value;
if (!targetApp?.pkgname) return;
window.ipcRenderer.send("remove-installed", { window.ipcRenderer.send("remove-installed", {
pkgname: currentApp.value.pkgname, pkgname: targetApp.pkgname,
origin: currentApp.value.origin, origin: targetApp.origin,
}); });
}; };