Files
spark-store/src/components/AppDetailModal.vue
T
xiyidaiwa b3eb1d01d9 fix: 标签优先显示策略 auto 模式按服务端优先级配置展示
- auto 模式用合并应用自身(含 sparkApp/apmApp)匹配 priority-config.json,
  不再仅取单一子版,避免漏匹配。
- openDetail 区分「已安装页显式来源强制」与「其他页面按策略」;
  非强制分支清除 forceViewingOrigin 粘性标志,避免跨入口状态泄漏。
- AppDetailModal 按 TagPriorityStrategy 响应式重算默认来源;
  手动切标签仅本次会话预览,不持久。
- storeConfig.loadPriorityConfig 改用 axios(与渲染进程一致),
  修复 Electron 下全局 fetch 失败导致配置为空、auto 全部回退 APM 的问题。
- 首页推荐列表 parseAppList 从 jsonUrl 提取分类,避免 category 误为 unknown。
2026-08-07 18:21:39 +08:00

919 lines
37 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<Transition
enter-active-class="duration-200 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="duration-150 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="show"
v-bind="attrs"
class="fixed inset-0 z-[70] flex items-center justify-center overflow-hidden bg-slate-900/70 p-4"
@click.self="closeModal"
@wheel="onOverlayWheel"
>
<div
class="modal-panel relative flex w-full max-w-6xl max-h-[90vh] overflow-y-auto overscroll-contain scrollbar-nowidth rounded-3xl border border-white/10 bg-white/95 px-6 pb-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900 lg:max-h-[90vh] lg:overflow-hidden lg:pb-0"
>
<!-- 主布局左侧信息 + 右侧内容 -->
<div class="flex w-full flex-col gap-6 lg:min-h-0 lg:flex-row">
<!-- 左侧图标版本来源按钮元信息 -->
<div
data-testid="detail-fixed-sidebar"
class="w-full flex-shrink-0 space-y-5 lg:w-72 lg:self-start lg:py-4"
>
<button
type="button"
class="inline-flex items-center gap-2 rounded-full border border-slate-200/70 bg-white px-4 py-2 text-sm font-medium text-slate-600 shadow-lg transition hover:bg-slate-50 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-200"
@click="closeModal"
aria-label="返回"
>
<i class="fas fa-arrow-left"></i>
<span>返回</span>
</button>
<!-- 应用图标和名称 -->
<div class="text-center">
<div
class="mx-auto flex h-28 w-28 items-center justify-center overflow-hidden rounded-3xl bg-gradient-to-b from-slate-100 to-slate-200 shadow-lg dark:from-slate-800 dark:to-slate-700"
>
<img
v-if="app"
:src="iconPath"
alt="icon"
class="h-full w-full object-cover transition-opacity duration-300"
:class="isIconLoaded ? 'opacity-100' : 'opacity-0'"
loading="lazy"
@load="isIconLoaded = true"
/>
</div>
<h2 class="mt-4 text-xl font-bold text-slate-900 dark:text-white">
{{ displayApp?.name || "" }}
</h2>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
{{ displayApp?.pkgname || "" }}
</p>
<div
v-if="displayApp?.version || downloadCount"
class="mt-1 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 text-sm text-slate-500 dark:text-slate-400"
>
<span v-if="displayApp?.version">{{ displayApp.version }}</span>
<span
v-if="displayApp?.version && downloadCount"
class="text-slate-300 dark:text-slate-600"
>·</span
>
<span v-if="downloadCount" class="flex items-center gap-1">
<svg
class="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
{{ downloadCount }}
</span>
</div>
</div>
<!-- 应用来源切换 -->
<div
class="flex items-center justify-between rounded-2xl border border-slate-200/60 bg-slate-50/50 px-4 py-3 dark:border-slate-800/60 dark:bg-slate-800/50"
>
<span class="text-sm text-slate-500 dark:text-slate-400"
>来源</span
>
<div
v-if="app?.isMerged"
class="flex gap-1 overflow-hidden rounded-lg shadow-sm border border-slate-200 dark:border-slate-700"
>
<button
v-if="app.sparkApp"
type="button"
title="原生安装,高手自定义"
class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors"
:class="
viewingOrigin === 'spark'
? 'bg-orange-500 text-white'
: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-600'
"
@click="selectOrigin('spark')"
>
Spark
</button>
<button
v-if="app.apmApp"
type="button"
title="兼容安装,省心不折腾"
class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors"
:class="
viewingOrigin === 'apm'
? 'bg-blue-500 text-white'
: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-600'
"
@click="selectOrigin('apm')"
>
APM
</button>
</div>
<span
v-else-if="displayApp"
:class="[
'rounded-md px-2 py-1 text-xs font-bold uppercase tracking-wider',
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',
]"
>
{{ displayApp.origin === "spark" ? "Spark" : "APM" }}
</span>
</div>
<!-- 功能按钮 -->
<div class="space-y-2">
<button
v-if="!isinstalled"
type="button"
class="w-full inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium text-white shadow-sm disabled:opacity-40 transition"
:class="
installFeedback
? 'bg-emerald-600 hover:bg-emerald-700'
: 'bg-slate-800 hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600'
"
@click="handleInstall"
:disabled="installFeedback || isOtherVersionInstalled"
>
<i
class="fas text-xs"
:class="installFeedback ? 'fa-check' : 'fa-download'"
></i>
<span>{{ installBtnText }}</span>
</button>
<template v-else>
<div class="flex gap-2">
<button
type="button"
class="flex-1 inline-flex items-center justify-center gap-2 rounded-xl bg-slate-800 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
@click="onOpenClick"
>
<i class="fas fa-external-link-alt text-xs"></i>
<span>打开</span>
</button>
<button
type="button"
class="flex-1 inline-flex items-center justify-center gap-2 rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm font-medium text-slate-600 transition hover:border-rose-400 hover:text-rose-500 hover:bg-rose-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 dark:hover:border-rose-500 dark:hover:text-rose-400 dark:hover:bg-slate-700"
@click="handleRemove"
>
<i class="fas fa-trash text-xs"></i>
<span>卸载</span>
</button>
</div>
</template>
<!-- 收藏功能暂时关闭
<button
type="button"
class="inline-flex w-full items-center justify-center gap-2 rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm font-medium text-slate-600 transition hover:bg-slate-50 hover:text-slate-900 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
@click="handleFavorite"
>
<i class="fas fa-star text-xs"></i>
<span>{{ favoriteButtonText }}</span>
</button>
-->
<button
type="button"
class="inline-flex w-full items-center justify-center gap-2 rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm font-medium text-slate-600 transition hover:bg-slate-50 hover:text-slate-900 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
@click="copySpkLink"
>
<i
class="fas text-xs"
:class="spkCopied ? 'fa-check' : 'fa-share-alt'"
></i>
<span>{{ spkCopied ? "已复制链接" : "SPK 分享" }}</span>
</button>
</div>
<!-- 其他元信息 -->
<div
class="space-y-2 pt-2 border-t border-slate-200/60 dark:border-slate-800/60"
@click="showAllMetaData"
>
<div
v-if="displayApp?.category"
class="flex items-center justify-between px-1 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 rounded px-1 -mx-1 transition-colors"
>
<span class="text-xs text-slate-400">分类</span>
<span
class="text-xs font-medium text-slate-700 dark:text-slate-300 truncate max-w-[140px]"
>{{ displayApp.category }}</span
>
</div>
<div
v-if="displayApp?.author"
class="flex items-center justify-between px-1 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 rounded px-1 -mx-1 transition-colors"
>
<span class="text-xs text-slate-400">作者</span>
<span
class="text-xs font-medium text-slate-700 dark:text-slate-300 truncate max-w-[140px]"
>{{ displayApp.author }}</span
>
</div>
<div
v-if="displayApp?.contributor"
class="flex items-center justify-between px-1 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 rounded px-1 -mx-1 transition-colors"
>
<span class="text-xs text-slate-400">贡献者</span>
<span
class="text-xs font-medium text-slate-700 dark:text-slate-300 truncate max-w-[140px]"
>{{ displayApp.contributor }}</span
>
</div>
<div
v-if="displayApp?.size"
class="flex items-center justify-between px-1 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 rounded px-1 -mx-1 transition-colors"
>
<span class="text-xs text-slate-400">大小</span>
<span
class="text-xs font-medium text-slate-700 dark:text-slate-300"
>{{ displayApp.size }}</span
>
</div>
<div
v-if="displayApp?.update"
class="flex items-center justify-between px-1 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 rounded px-1 -mx-1 transition-colors"
>
<span class="text-xs text-slate-400">更新</span>
<span
class="text-xs font-medium text-slate-700 dark:text-slate-300"
>{{ displayApp.update }}</span
>
</div>
<div
v-if="displayApp?.website"
class="flex items-center justify-between px-1 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 rounded px-1 -mx-1 transition-colors"
@click.stop="openWebsite(displayApp.website)"
>
<span class="text-xs text-slate-400">网站</span>
<span
class="text-xs font-medium text-brand truncate max-w-[140px]"
>{{ displayApp.website }}</span
>
</div>
<div
v-if="displayApp?.tags"
class="flex items-center justify-between px-1 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 rounded px-1 -mx-1 transition-colors"
>
<span class="text-xs text-slate-400">标签</span>
<span
class="text-xs font-medium text-slate-700 dark:text-slate-300 truncate max-w-[140px]"
>{{ displayApp.tags }}</span
>
</div>
</div>
</div>
<!-- 右侧应用详情+ 截图 -->
<div
data-testid="detail-scroll-content"
class="min-w-0 flex-1 space-y-5 lg:max-h-[90vh] lg:overflow-y-auto lg:overscroll-contain lg:py-4 lg:pr-2"
>
<!-- 应用详情 -->
<div
v-if="displayApp?.more && displayApp.more.trim() !== ''"
class="rounded-2xl border border-slate-200/60 bg-slate-50/50 p-5 dark:border-slate-800/60 dark:bg-slate-800/30"
>
<h3
class="text-base font-semibold text-slate-900 dark:text-white mb-3 flex items-center gap-2"
>
<i class="fas fa-info-circle text-slate-400"></i>
应用详情
</h3>
<div
class="text-sm leading-relaxed text-slate-600 dark:text-slate-300 space-y-2"
v-html="displayApp.more.replace(/\n/g, '<br>')"
></div>
</div>
<div
v-else
class="rounded-2xl border border-slate-200/60 bg-slate-50/30 p-8 text-center dark:border-slate-800/60 dark:bg-slate-800/20"
>
<p class="text-sm text-slate-400">暂无应用详情</p>
</div>
<!-- 截图展示 -->
<div v-if="screenshots.length">
<h3
class="text-base font-semibold text-slate-900 dark:text-white mb-3 flex items-center gap-2"
>
<i class="fas fa-images text-slate-400"></i>
应用截图
</h3>
<div class="grid gap-3 sm:grid-cols-2">
<img
v-for="(screen, index) in screenshots"
:key="index"
:src="screen"
alt="screenshot"
class="h-44 w-full cursor-pointer rounded-2xl border border-slate-200/60 object-cover shadow-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-lg dark:border-slate-800/60"
loading="lazy"
@click="openPreview(index)"
@error="hideImage"
/>
</div>
</div>
<div
v-else
class="rounded-2xl border border-slate-200/60 bg-slate-50/30 p-8 text-center dark:border-slate-800/60 dark:bg-slate-800/20"
>
<p class="text-sm text-slate-400">暂无应用截图</p>
</div>
<!-- 评论功能暂时关闭
<ReviewsPanel
v-if="loggedIn && activeReviewAppKey && activeReviewTags"
:app-key="activeReviewAppKey"
:tags="activeReviewTags"
:logged-in="loggedIn"
:can-submit="isinstalled"
@request-login="$emit('request-login', $event)"
@show-user="emit('show-user', $event)"
/>
<section
v-else-if="!loggedIn && reviewAppKey && reviewTags"
class="rounded-2xl border border-slate-200/60 bg-slate-50/50 p-5 dark:border-slate-800/60 dark:bg-slate-800/30"
>
<h3
class="text-base font-semibold text-slate-900 dark:text-white mb-3 flex items-center gap-2"
>
<i class="fas fa-comments text-slate-400"></i>
应用评价
</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">
登录星火账号后可查看评价并发表评论
</p>
<button
type="button"
class="mt-4 inline-flex items-center rounded-xl bg-slate-800 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
@click="emit('request-login', '登录后查看和发表评论。')"
>
登录后查看评价
</button>
</section>
<section
v-else-if="reviewAppKey && reviewTags"
class="rounded-2xl border border-slate-200/60 bg-slate-50/50 p-5 dark:border-slate-800/60 dark:bg-slate-800/30"
>
<h3
class="text-base font-semibold text-slate-900 dark:text-white mb-3 flex items-center gap-2"
>
<i class="fas fa-comments text-slate-400"></i>
应用评价
</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">
安装应用后可发表评论
</p>
</section>
-->
</div>
</div>
</div>
</div>
</Transition>
<!-- 元数据详情弹窗 -->
<Transition
enter-active-class="duration-200 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="duration-150 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="showMetaModal"
class="fixed inset-0 z-[80] flex items-center justify-center overflow-hidden bg-slate-900/60 p-4"
@click.self="closeMetaModal"
>
<div
class="relative w-full max-w-md rounded-2xl border border-white/10 bg-white p-6 shadow-2xl dark:border-slate-700 dark:bg-slate-800"
>
<button
type="button"
class="absolute top-3 right-3 inline-flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-700 dark:hover:text-slate-300"
@click="closeMetaModal"
aria-label="关闭"
>
<i class="fas fa-xmark"></i>
</button>
<h3
class="text-lg font-semibold text-slate-900 dark:text-white mb-4 pr-8"
>
应用信息
</h3>
<div
class="max-h-80 overflow-y-auto overscroll-contain rounded-xl bg-slate-50 p-4 dark:bg-slate-900/50 space-y-3"
>
<div v-if="displayApp?.name" class="flex justify-between">
<span class="text-sm text-slate-500">应用名称</span>
<span
class="text-sm font-medium text-slate-800 dark:text-slate-200 text-right max-w-[60%] break-all"
>{{ displayApp.name }}</span
>
</div>
<div v-if="displayApp?.pkgname" class="flex justify-between">
<span class="text-sm text-slate-500">包名</span>
<span
class="text-sm font-medium text-slate-800 dark:text-slate-200 text-right max-w-[60%] break-all"
>{{ displayApp.pkgname }}</span
>
</div>
<div v-if="displayApp?.version" class="flex justify-between">
<span class="text-sm text-slate-500">版本</span>
<span
class="text-sm font-medium text-slate-800 dark:text-slate-200"
>{{ displayApp.version }}</span
>
</div>
<div v-if="displayApp?.category" class="flex justify-between">
<span class="text-sm text-slate-500">分类</span>
<span
class="text-sm font-medium text-slate-800 dark:text-slate-200"
>{{ displayApp.category }}</span
>
</div>
<div v-if="displayApp?.author" class="flex justify-between">
<span class="text-sm text-slate-500">作者</span>
<span
class="text-sm font-medium text-slate-800 dark:text-slate-200 text-right max-w-[60%] break-all"
>{{ displayApp.author }}</span
>
</div>
<div v-if="displayApp?.contributor" class="flex justify-between">
<span class="text-sm text-slate-500">贡献者</span>
<span
class="text-sm font-medium text-slate-800 dark:text-slate-200 text-right max-w-[60%] break-all"
>{{ displayApp.contributor }}</span
>
</div>
<div v-if="displayApp?.size" class="flex justify-between">
<span class="text-sm text-slate-500">大小</span>
<span
class="text-sm font-medium text-slate-800 dark:text-slate-200"
>{{ displayApp.size }}</span
>
</div>
<div v-if="displayApp?.update" class="flex justify-between">
<span class="text-sm text-slate-500">更新时间</span>
<span
class="text-sm font-medium text-slate-800 dark:text-slate-200"
>{{ displayApp.update }}</span
>
</div>
<div v-if="displayApp?.website" class="flex justify-between">
<span class="text-sm text-slate-500">网站</span>
<a
:href="displayApp.website"
target="_blank"
class="text-sm font-medium text-brand hover:underline text-right max-w-[60%] break-all"
>{{ displayApp.website }}</a
>
</div>
<div v-if="displayApp?.tags" class="flex justify-between">
<span class="text-sm text-slate-500">标签</span>
<span
class="text-sm font-medium text-slate-800 dark:text-slate-200 text-right max-w-[60%] break-all"
>{{ displayApp.tags }}</span
>
</div>
<div v-if="displayApp?.origin" class="flex justify-between">
<span class="text-sm text-slate-500">来源</span>
<span
class="text-sm font-medium text-slate-800 dark:text-slate-200"
>{{ displayApp.origin === "spark" ? "Spark" : "APM" }}</span
>
</div>
</div>
</div>
</div>
<!-- 双来源安装打开时选择 APM 还是 Spark -->
<Transition
enter-active-class="duration-200 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="duration-150 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="openChoiceVisible"
class="fixed inset-0 z-[85] flex items-center justify-center bg-slate-900/70 p-4"
@click.self="openChoiceVisible = false"
>
<div
class="relative w-full max-w-sm overflow-hidden rounded-3xl border border-white/10 bg-white/95 p-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
>
<div class="mb-5 flex items-start gap-3">
<div
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-sky-100 to-indigo-50 shadow-inner dark:from-sky-900/30 dark:to-indigo-800/20"
>
<i class="fas fa-external-link-alt text-xl text-sky-500"></i>
</div>
<div>
<h3 class="text-lg font-bold text-slate-900 dark:text-white">
打开应用
</h3>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
该应用同时通过 APM Spark 安装请选择要打开的来源
</p>
</div>
</div>
<div class="flex gap-3">
<button
type="button"
class="inline-flex flex-1 items-center justify-center gap-2 rounded-xl border border-amber-300/70 px-4 py-2.5 text-sm font-semibold text-amber-700 transition hover:bg-amber-50 dark:border-amber-500/40 dark:text-amber-300 dark:hover:bg-amber-500/10"
@click="confirmOpen('apm')"
>
<i class="fas fa-box-open"></i>
打开 APM
</button>
<button
type="button"
class="inline-flex flex-1 items-center justify-center gap-2 rounded-xl border border-sky-300/70 px-4 py-2.5 text-sm font-semibold text-sky-700 transition hover:bg-sky-50 dark:border-sky-500/40 dark:text-sky-300 dark:hover:bg-sky-500/10"
@click="confirmOpen('spark')"
>
<i class="fas fa-bolt"></i>
打开 Spark
</button>
</div>
<div class="mt-4 flex justify-end">
<button
type="button"
class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
@click="openChoiceVisible = false"
>
取消
</button>
</div>
</div>
</div>
</Transition>
</Transition>
</template>
<script setup lang="ts">
import { computed, useAttrs, ref, watch } from "vue";
import axios from "axios";
// import ReviewsPanel from "@/components/ReviewsPanel.vue";
import { useInstallFeedback, downloads } from "../global/downloadStatus";
import {
APM_STORE_BASE_URL,
getHybridDefaultOrigin,
} from "../global/storeConfig";
import {
tagPriorityStrategyRef,
initTagPriorityStrategy,
type TagPriorityStrategy,
} from "../global/tagPriority";
// 评论功能暂时关闭
// import { buildReviewAppKey, buildReviewTags } from "../modules/appIdentity";
import type { App, AppReview, ReviewTags } from "../global/typedefinition";
const attrs = useAttrs();
const props = defineProps<{
show: boolean;
app: App | null;
screenshots: string[];
sparkInstalled: boolean;
apmInstalled: boolean;
loggedIn: boolean;
reviewAppKey: string;
reviewTags: ReviewTags | null;
favorited?: boolean;
favoriteFolderName?: string;
}>();
const emit = defineEmits<{
(e: "close"): void;
(e: "install", app: App): void;
(e: "remove", app: App): void;
(e: "favorite", app: App): void;
(e: "request-login", message: string): void;
(e: "open-preview", index: number): void;
(e: "open-app", pkgname: string, origin?: "spark" | "apm"): void;
(e: "check-install", app: App): void;
(e: "select-origin", origin: "spark" | "apm"): void;
(e: "show-user", review: AppReview): void;
}>();
const appPkgname = computed(() => props.app?.pkgname);
const isIconLoaded = ref(false);
const viewingOrigin = ref<"spark" | "apm">("spark");
// 元数据弹窗相关
const showMetaModal = ref(false);
const showAllMetaData = () => {
showMetaModal.value = true;
};
const closeMetaModal = () => {
showMetaModal.value = false;
};
const openWebsite = (url: string) => {
if (url) {
window.open(url, "_blank");
}
};
// 根据「标签优先显示策略」与应用的可用来源标签,计算默认展示的来源
const computeDefaultViewingOrigin = (
app: App,
strategy: TagPriorityStrategy,
): "spark" | "apm" => {
// 父组件显式指定展示来源(如从已安装应用页按特定来源打开)时优先级最高,
// 此时强制覆盖用户标签策略;否则交由下方策略逻辑决定。
if (app.forceViewingOrigin && app.viewingOrigin) return app.viewingOrigin;
// 非合并应用只有一个来源标签,直接展示该标签
if (!app.isMerged) return app.origin;
// 合并应用:available 为实际存在的来源标签
const available: Array<"spark" | "apm"> = [];
if (app.sparkApp) available.push("spark");
if (app.apmApp) available.push("apm");
if (available.length === 0) return app.origin; // 兜底
if (available.length === 1) return available[0]; // 仅有一个标签时默认展示该标签
// 按策略优先选择对应标签
const preferred: "spark" | "apm" | null =
strategy === "spark" ? "spark" : strategy === "apm" ? "apm" : null;
if (preferred && available.includes(preferred)) {
return preferred;
}
// 回退到应用配置的优先级策略(getHybridDefaultOrigin),使用合并应用自身的
// 规范标识(pkgname/category/tags)匹配服务器 priority-config.json,并约束在可用标签范围内
const auto = getHybridDefaultOrigin(app);
console.log(
`[PriorityConfig] auto 决策 pkgname=${app.pkgname} category=${app.category} sparkTags=${app.sparkApp?.tags ?? ""} apmTags=${app.apmApp?.tags ?? ""} -> ${auto}`,
);
return available.includes(auto) ? auto : available[0];
};
// 进入详情页时按「标签优先显示策略」计算默认来源标签。
// 同时监听 props.app 与共享策略 ref
// - props.app 变化(重开/切换应用)→ 始终按当前策略重算(手动切标签仅本次会话预览,不持久)
// - 策略变化(在设置页实时切换)→ 已打开且未手动切标签的详情页立即更新默认标签
// 手动点标签页(selectOrigin)只是临时预览:置 manualOverride 标记,本次会话内保持,
// 既不被策略变化强行拉回,也不跨重开持久(重开后会按设置重算)。
initTagPriorityStrategy();
const manualOverride = ref(false); // 用户是否手动切换过标签(本次会话)
let lastPkgname: string | null = null; // 上一次计算对应的应用 pkgname,用于识别"新打开/重开"
watch(
[() => props.app, tagPriorityStrategyRef],
([newApp]: [App | null, TagPriorityStrategy]) => {
isIconLoaded.value = false;
if (!newApp) {
// 详情页关闭:清空会话状态,确保下次打开按最新策略重算
lastPkgname = null;
manualOverride.value = false;
return;
}
if (newApp.pkgname !== lastPkgname) {
// 新应用(含重开后再次打开同一应用):重置手动标记,按当前策略计算
lastPkgname = newApp.pkgname;
manualOverride.value = false;
viewingOrigin.value = computeDefaultViewingOrigin(
newApp,
tagPriorityStrategyRef.value,
);
} else if (manualOverride.value || newApp.forceViewingOrigin) {
// 同一应用且用户正在手动预览 / 父组件强制来源:保留当前标签,不重算
} else {
// 同一应用、无手动预览、策略变化 → 实时按新策略更新默认标签
viewingOrigin.value = computeDefaultViewingOrigin(
newApp,
tagPriorityStrategyRef.value,
);
}
},
{ 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;
});
watch(
() => displayApp.value,
(newApp) => {
if (newApp) {
emit("check-install", newApp);
}
},
{ immediate: false },
);
const activeDownload = computed(() => {
return downloads.value.find((d) => d.pkgname === displayApp.value?.pkgname);
});
const isinstalled = computed(() => {
return viewingOrigin.value === "spark"
? props.sparkInstalled
: props.apmInstalled;
});
const isOtherVersionInstalled = computed(() => {
return viewingOrigin.value === "spark"
? props.apmInstalled
: props.sparkInstalled;
});
const { installFeedback } = useInstallFeedback(appPkgname);
const installBtnText = computed(() => {
if (isinstalled.value) {
return "已安装";
}
if (isOtherVersionInstalled.value) {
return viewingOrigin.value === "spark"
? "已安装 APM 版"
: "已安装 Spark 版";
}
if (installFeedback.value) {
const status = activeDownload.value?.status;
if (status === "downloading") {
return `下载中 ${Math.floor((activeDownload.value?.progress || 0) * 100)}%`;
}
if (status === "installing") {
return "安装中...";
}
return "已加入队列";
}
return "安装";
});
const iconPath = computed(() => {
if (!displayApp.value) return "";
const arch = window.apm_store.arch || "amd64";
const finalArch =
displayApp.value.origin === "spark" ? `${arch}-store` : `${arch}-apm`;
return `${APM_STORE_BASE_URL}/${finalArch}/${displayApp.value.category}/${displayApp.value.pkgname}/icon.png`;
});
// 评论与收藏功能暂时关闭
// const activeReviewAppKey = computed(() => {
// if (!displayApp.value) return "";
// return buildReviewAppKey(
// displayApp.value,
// props.reviewTags?.clientArch ?? "amd64",
// );
// });
// const activeReviewTags = computed<ReviewTags | null>(() => {
// if (!displayApp.value || !props.reviewTags) return null;
// return buildReviewTags(displayApp.value, {
// clientArch: props.reviewTags.clientArch,
// distro: props.reviewTags.distro,
// });
// });
// const favoriteButtonText = computed(() => {
// if (!props.favorited) return "收藏";
// return props.favoriteFolderName
// ? `已收藏 · ${props.favoriteFolderName}`
// : "已收藏";
// });
const downloadCount = ref<string>("");
// 监听 app 变化,获取新app的下载量
watch(
() => displayApp.value,
async (newApp) => {
if (newApp) {
downloadCount.value = "";
try {
const arch = window.apm_store.arch || "amd64";
const finalArch =
newApp.origin === "spark" ? `${arch}-store` : `${arch}-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();
} else {
downloadCount.value = "N/A";
throw new Error(`Unexpected response status: ${resp.status}`);
}
} catch (e) {
console.error("Failed to fetch download count", e);
}
}
},
{ immediate: true },
);
const closeModal = () => {
emit("close");
};
const handleInstall = () => {
if (displayApp.value) {
emit("install", displayApp.value);
}
};
const handleRemove = () => {
if (displayApp.value) {
emit("remove", displayApp.value);
}
};
// 双来源安装时,"打开"需先让用户选择打开 APM 还是 Spark 版
const openChoiceVisible = ref(false);
const openOrigins = (app: App | null): Array<"spark" | "apm"> =>
app?.origins && app.origins.length > 0 ? app.origins : [app?.origin ?? "spark"];
const onOpenClick = () => {
const app = displayApp.value;
if (!app) return;
const origins = openOrigins(app);
if (origins.length > 1) {
openChoiceVisible.value = true;
} else {
emit("open-app", app.pkgname, origins[0]);
}
};
const confirmOpen = (origin: "spark" | "apm") => {
const app = displayApp.value;
if (app) emit("open-app", app.pkgname, origin);
openChoiceVisible.value = false;
};
// 收藏功能暂时关闭
// const handleFavorite = () => {
// if (!displayApp.value) return;
// if (!props.loggedIn) {
// emit("request-login", "收藏应用需要登录星火账号。");
// return;
// }
// emit("favorite", displayApp.value);
// };
const selectOrigin = (origin: "spark" | "apm") => {
// 手动切换标签:仅本次会话预览,不写回策略、不跨重开持久
manualOverride.value = true;
viewingOrigin.value = origin;
emit("select-origin", origin);
};
const openPreview = (index: number) => {
emit("open-preview", index);
};
const hideImage = (e: Event) => {
(e.target as HTMLElement).style.display = "none";
};
const spkCopied = ref(false);
let spkCopiedTimer: ReturnType<typeof setTimeout> | null = null;
const copySpkLink = async () => {
if (!displayApp.value?.pkgname) return;
const spkLink = `spk://store/${displayApp.value.category || "unknown"}/${displayApp.value.pkgname}`;
try {
await navigator.clipboard.writeText(spkLink);
spkCopied.value = true;
if (spkCopiedTimer) clearTimeout(spkCopiedTimer);
spkCopiedTimer = setTimeout(() => {
spkCopied.value = false;
}, 2000);
} catch {
// 降级方案:使用 prompt
prompt("请手动复制 SPK 分享链接:", spkLink);
}
};
const onOverlayWheel = (e: WheelEvent) => {
const target = e.target as HTMLElement;
if (target.closest(".overflow-y-auto, .overflow-auto")) return;
e.preventDefault();
};
</script>