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"
:isinstalled="currentAppIsInstalled"
@close="closeDetail"
@install="handleInstall"
@remove="requestUninstallFromDetail"
@install="onDetailInstall"
@remove="onDetailRemove"
@open-preview="openScreenPreview"
@open-app="openDownloadedApp"
/>
@@ -163,6 +163,7 @@ import {
handleInstall,
handleRetry,
handleUpgrade,
handleRemove,
} from "./modules/processInstall";
import type {
App,
@@ -295,6 +296,27 @@ watch(currentStoreMode, async (newMode, oldMode) => {
const filteredApps = computed(() => {
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") {
result = result.filter((app) => app.category === activeCategory.value);
@@ -383,8 +405,12 @@ const openDetail = (app: App | Record<string, unknown>) => {
return;
}
// 尝试从全局 apps 中查找完整 App
let fullApp = apps.value.find(a => a.pkgname === pkgname);
// 首先尝试从当前已经处理好(合并/筛选)的 filteredApps 中查找,以便获取 isMerged 状态等
let fullApp = filteredApps.value.find(a => a.pkgname === pkgname);
// 如果没找到(可能是从已安装列表之类的其他入口打开的),回退到全局 apps 中查找完整 App
if (!fullApp) {
fullApp = apps.value.find(a => a.pkgname === pkgname);
}
if (!fullApp) {
// 构造一个最小可用的 App 对象
fullApp = {
@@ -769,6 +795,14 @@ const requestUninstallFromDetail = () => {
}
};
const onDetailRemove = (app: App) => {
requestUninstall(app);
};
const onDetailInstall = (app: App) => {
handleInstall(app);
};
const closeUninstallModal = () => {
showUninstallModal.value = false;
uninstallTargetApp.value = null;
@@ -785,8 +819,8 @@ const onUninstallSuccess = () => {
}
};
const installCompleteCallback = (pkgname?: string) => {
if (currentApp.value && (!pkgname || currentApp.value.pkgname === pkgname)) {
const installCompleteCallback = (pkgname?: string, status?: string) => {
if (currentApp.value && (!pkgname || currentApp.value.pkgname === pkgname) && status === "completed") {
checkAppInstalled(currentApp.value);
}
};

View File

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

View File

@@ -26,12 +26,14 @@
<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.isMerged
? 'bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-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>
</div>
<div class="text-sm text-slate-500 dark:text-slate-400">

View File

@@ -34,22 +34,42 @@
<div class="space-y-1">
<div class="flex items-center gap-3">
<p class="text-2xl font-bold text-slate-900 dark:text-white">
{{ app?.name || "" }}
{{ displayApp?.name || "" }}
</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
v-if="app"
v-else-if="displayApp"
:class="[
'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-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>
</div>
<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>
</p>
</div>
@@ -77,7 +97,7 @@
<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"
@click="emit('open-app', app?.pkgname || '')"
@click="emit('open-app', displayApp?.pkgname || '')"
>
<i class="fas fa-external-link-alt"></i>
<span>打开</span>
@@ -130,82 +150,82 @@
<div class="mt-6 grid gap-4 sm:grid-cols-2">
<div
v-if="app?.author"
v-if="displayApp?.author"
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-sm font-medium text-slate-800 dark:text-slate-200">
{{ app.author }}
{{ displayApp.author }}
</p>
</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"
>
<p class="text-xs uppercase tracking-wide text-slate-400">贡献者</p>
<p class="text-sm font-medium text-slate-800 dark:text-slate-200">
{{ app.contributor }}
{{ displayApp.contributor }}
</p>
</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"
>
<p class="text-xs uppercase tracking-wide text-slate-400">大小</p>
<p class="text-sm font-medium text-slate-800 dark:text-slate-200">
{{ app.size }}
{{ displayApp.size }}
</p>
</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"
>
<p class="text-xs uppercase tracking-wide text-slate-400">
更新时间
</p>
<p class="text-sm font-medium text-slate-800 dark:text-slate-200">
{{ app.update }}
{{ displayApp.update }}
</p>
</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"
>
<p class="text-xs uppercase tracking-wide text-slate-400">网站</p>
<a
:href="app.website"
:href="displayApp.website"
target="_blank"
class="text-sm font-medium text-brand hover:underline"
>{{ app.website }}</a
>{{ displayApp.website }}</a
>
</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"
>
<p class="text-xs uppercase tracking-wide text-slate-400">版本</p>
<p class="text-sm font-medium text-slate-800 dark:text-slate-200">
{{ app.version }}
{{ displayApp.version }}
</p>
</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"
>
<p class="text-xs uppercase tracking-wide text-slate-400">标签</p>
<p class="text-sm font-medium text-slate-800 dark:text-slate-200">
{{ app.tags }}
{{ displayApp.tags }}
</p>
</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>
<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"
v-html="app.more.replace(/\n/g, '<br>')"
v-html="displayApp.more.replace(/\n/g, '<br>')"
></div>
</div>
</div>
@@ -235,8 +255,8 @@ const props = defineProps<{
const emit = defineEmits<{
(e: "close"): void;
(e: "install"): void;
(e: "remove"): void;
(e: "install", app: App): void;
(e: "remove", app: App): void;
(e: "open-preview", index: number): void;
(e: "open-app", pkgname: string): void;
}>();
@@ -245,15 +265,33 @@ const appPkgname = computed(() => props.app?.pkgname);
const isIconLoaded = ref(false);
const viewingOrigin = ref<"spark" | "apm">("spark");
watch(
() => props.app,
() => {
(newApp) => {
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(() => {
return downloads.value.find((d) => d.pkgname === props.app?.pkgname);
return downloads.value.find((d) => d.pkgname === displayApp.value?.pkgname);
});
const { installFeedback } = useInstallFeedback(appPkgname);
@@ -278,20 +316,20 @@ const installBtnText = computed(() => {
return "安装";
});
const iconPath = computed(() => {
if (!props.app) return "";
if (!displayApp.value) return "";
const arch = window.apm_store.arch || "amd64-apm";
const finalArch =
props.app.origin === "spark"
displayApp.value.origin === "spark"
? arch.replace("-apm", "-store")
: 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>("");
// 监听 app 变化获取新app的下载量
watch(
() => props.app,
() => displayApp.value,
async (newApp) => {
if (newApp) {
downloadCount.value = "";
@@ -322,11 +360,15 @@ const closeModal = () => {
};
const handleInstall = () => {
emit("install");
if (displayApp.value) {
emit("install", displayApp.value);
}
};
const handleRemove = () => {
emit("remove");
if (displayApp.value) {
emit("remove", displayApp.value);
}
};
const openPreview = (index: number) => {

View File

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

View File

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

View File

@@ -107,6 +107,10 @@ export interface App {
flags?: string; // Tags in apm packages manager, e.g. "automatic" for dependencies
arch?: string; // Architecture, e.g. "amd64", "arm64"
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 {

View File

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