refactor: 基于 upstream/Erotica 重新整合本地功能(来源选择/合并详情/计数/窗口/首页)

将本地在 upstream !408(已安装应用合并+统计)之上的后续改动重新整合到最新 upstream/Erotica:
- 已安装应用:卸载/打开按 APM/Spark 来源选择;计数总数=APM+Spark 包数(双来源计为独立包)
- 查看详情:已安装页复用所有应用页合并详情视图(单来源优先已装类型,双来源自动模式)
- 窗口:恢复阈值改为 1600x900,过大回退默认尺寸并居中,不自动最大化
- 首页推荐:致谢卡片移至其他内容下方,加大标题/卡片/致谢间距
- 隐藏排行榜/荣耀榜侧栏入口(代码保留,v-if=false)
- 新增 scripts/test-build.sh 测试打包脚本(版本末段+1 加 -test)并更新 AGENTS.md

冲突文件(index.ts/App.vue/InstalledAppsModal.vue)均取本地完整实现(为上游版本的超集)。
This commit is contained in:
xiyidaiwa
2026-08-03 11:06:28 +08:00
parent 77b4244f1d
commit b89c001401
25 changed files with 1750 additions and 299 deletions
+56 -5
View File
@@ -1,10 +1,24 @@
<template>
<div
@click="openDetail"
class="group flex cursor-pointer gap-3 rounded-xl border border-slate-200/70 bg-white/90 p-4 shadow-sm transition hover:-translate-y-0.5 hover:border-brand/50 hover:shadow-md dark:border-slate-800/60 dark:bg-slate-900/60"
class="group relative flex cursor-pointer transition hover:border-brand/50"
:class="
compact
? 'gap-2 rounded-lg border border-slate-200/70 bg-white/90 p-2 shadow-none hover:shadow-sm dark:border-slate-800/60 dark:bg-slate-900/60'
: 'gap-3 rounded-xl border border-slate-200/70 bg-white/90 p-4 shadow-sm hover:-translate-y-0.5 hover:shadow-md dark:border-slate-800/60 dark:bg-slate-900/60'
"
>
<!-- 排名徽标排行榜/荣耀榜使用 3 名金// -->
<div
class="flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-gradient-to-b from-slate-100 to-slate-200 shadow-inner dark:from-slate-800 dark:to-slate-700"
v-if="rank"
class="pointer-events-none absolute -left-1.5 -top-1.5 z-10 flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold shadow-md ring-2 ring-white transition-transform duration-200 group-hover:scale-110 dark:ring-slate-900"
:class="rankClass"
>
{{ rank }}
</div>
<div
class="flex shrink-0 items-center justify-center overflow-hidden bg-gradient-to-b from-slate-100 to-slate-200 shadow-inner dark:from-slate-800 dark:to-slate-700"
:class="compact ? 'h-9 w-9 rounded-md' : 'h-14 w-14 rounded-xl'"
>
<img
ref="iconImg"
@@ -17,7 +31,8 @@
<div class="flex flex-1 flex-col gap-1 overflow-hidden">
<div class="flex items-center gap-2">
<div
class="truncate text-base font-semibold text-slate-900 dark:text-white"
class="truncate font-semibold text-slate-900 dark:text-white"
:class="compact ? 'text-xs' : 'text-base'"
>
{{ app.name || "" }}
</div>
@@ -43,12 +58,21 @@
</span>
</template>
</div>
<span
v-if="downloadCount"
class="ml-auto shrink-0 text-[10px] text-slate-400 dark:text-slate-500"
>{{ formatDownloads(downloadCount) }}</span
>
</div>
<div class="text-sm text-slate-500 dark:text-slate-400 leading-tight">
<div
class="leading-tight text-slate-500 dark:text-slate-400"
:class="compact ? 'text-xs' : 'text-sm'"
>
{{ app.pkgname || "" }} · {{ app.version || "" }}
</div>
<div
class="truncate text-xs text-slate-500 dark:text-slate-400 leading-tight"
class="truncate leading-tight text-slate-500 dark:text-slate-400"
:class="compact ? 'text-[11px]' : 'text-xs'"
>
{{ description || "\u00A0" }}
</div>
@@ -65,6 +89,12 @@ const props = defineProps<{
app: App;
// 是否显示来源标识(仅在混合模式下显示)
showOrigin?: boolean;
// 紧凑模式(用于首页精选板块等空间受限区域)
compact?: boolean;
// 下载量(用于下载排行;>=0 时在标题行右侧显示)
downloadCount?: number;
// 排名(用于排行榜/荣耀榜,左上角显示徽标)
rank?: number;
}>();
const emit = defineEmits<{
@@ -111,6 +141,27 @@ const description = computed(() => {
return more.substring(0, 80) + (more.length > 80 ? "..." : "");
});
// 下载量格式化:>=1万 显示 "x.x万"
const formatDownloads = (n?: number): string => {
if (!n || n <= 0) return "0";
if (n >= 10000) {
const wan = n / 10000;
return `${wan.toFixed(1).replace(/\.0$/, "")}`;
}
return n.toLocaleString();
};
// 排名徽标配色(前 3 名金/银/铜,其余灰色)
const rankClass = computed(() => {
if (props.rank === 1)
return "bg-amber-400 text-white shadow-amber-400/40";
if (props.rank === 2)
return "bg-slate-300 text-slate-700 shadow-slate-400/40";
if (props.rank === 3)
return "bg-amber-600 text-white shadow-amber-600/40";
return "bg-slate-500/90 text-white shadow-slate-500/30";
});
const openDetail = () => {
emit("open-detail", props.app);
};
+87 -7
View File
@@ -161,13 +161,7 @@
<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="
emit(
'open-app',
displayApp?.pkgname || '',
displayApp?.origin,
)
"
@click="onOpenClick"
>
<i class="fas fa-external-link-alt text-xs"></i>
<span>打开</span>
@@ -392,6 +386,7 @@
</div>
</Transition>
<!-- 元数据详情弹窗 -->
<Transition
enter-active-class="duration-200 ease-out"
@@ -507,6 +502,69 @@
</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>
@@ -730,6 +788,28 @@ const handleRemove = () => {
}
};
// 双来源安装时,"打开"需先让用户选择打开 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;
+3 -1
View File
@@ -19,7 +19,9 @@
<!-- 应用数量较少时使用普通网格带滚动 -->
<div v-else-if="!loading && apps.length <= 50" class="non-virtual-scroller">
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<div
class="grid gap-4 px-4 py-6 sm:grid-cols-2 lg:px-10 xl:grid-cols-3 2xl:grid-cols-4"
>
<AppCard
v-for="app in apps"
:key="app.pkgname"
-108
View File
@@ -1,108 +0,0 @@
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center">
<button
type="button"
class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-slate-200/70 bg-white text-slate-500 shadow-sm transition hover:bg-slate-50 lg:hidden dark:border-slate-800/70 dark:bg-slate-900 dark:text-slate-400 dark:hover:bg-slate-800"
@click="$emit('toggle-sidebar')"
title="切换侧边栏"
>
<i class="fas fa-bars"></i>
</button>
<div class="flex w-full flex-1 items-center gap-3">
<div class="relative flex-1">
<label for="searchBox" class="sr-only">搜索应用</label>
<i
class="fas fa-search pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-slate-400"
></i>
<input
id="searchBox"
v-model="localSearchQuery"
class="w-full rounded-2xl border border-slate-200/70 bg-white/80 py-3 pl-12 pr-20 text-sm text-slate-700 shadow-sm outline-none transition placeholder:text-slate-400 focus:border-brand/50 focus:ring-4 focus:ring-brand/10 dark:border-slate-800/70 dark:bg-slate-900/60 dark:text-slate-200"
placeholder="搜索应用名 / 包名 / 标签 / 粘贴 SPK 分享链接"
@focus="handleSearchFocus"
@input="handleInput"
/>
<button
v-if="localSearchQuery"
type="button"
class="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300 transition-colors"
@click="clearSearch"
title="清除搜索"
>
<i class="fas fa-times-circle"></i>
</button>
</div>
<button
type="button"
class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-slate-200/70 bg-white text-slate-500 shadow-sm transition hover:bg-slate-50 dark:border-slate-800/70 dark:bg-slate-900 dark:text-slate-400 dark:hover:bg-slate-800"
@click="$emit('open-install-settings')"
title="安装设置"
>
<i class="fas fa-cog"></i>
</button>
<button
type="button"
class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-slate-200/70 bg-white text-slate-500 shadow-sm transition hover:bg-slate-50 dark:border-slate-800/70 dark:bg-slate-900 dark:text-slate-400 dark:hover:bg-slate-800"
@click="$emit('open-about')"
title="关于"
>
<i class="fas fa-info-circle"></i>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
const props = defineProps<{
searchQuery: string;
activeTab: string;
appsCount: number;
}>();
const emit = defineEmits<{
(e: "update-search", query: string): void;
(e: "search-focus"): void;
(e: "open-install-settings"): void;
(e: "open-about"): void;
(e: "toggle-sidebar"): void;
(e: "spk-link", pkgname: string): void;
}>();
const localSearchQuery = ref(props.searchQuery || "");
const handleSearchFocus = () => {
emit("search-focus");
};
const handleInput = () => {
const value = localSearchQuery.value.trim();
// 检测 SPK 分享链接: spk://store/{category}/{pkgname}
const spkMatch =
value.match(/^spk:\/\/search\/(.+)$/i) ||
value.match(/^spk:\/\/store\/[^/]+\/(.+)$/i);
if (spkMatch) {
const pkgname = spkMatch[1].trim();
if (pkgname) {
localSearchQuery.value = "";
emit("spk-link", pkgname);
return;
}
}
emit("update-search", localSearchQuery.value);
};
const clearSearch = () => {
localSearchQuery.value = "";
emit("update-search", "");
};
watch(
() => props.searchQuery,
(newVal) => {
localSearchQuery.value = newVal || "";
},
);
</script>
+23
View File
@@ -71,6 +71,29 @@
>
</button>
<!-- 排行榜 / 荣耀榜暂时隐藏入口代码保留后续按需恢复 -->
<button
v-if="false"
type="button"
class="sidebar-tab"
:class="{ 'sidebar-tab-active': activeTab === 'ranking' }"
@click="selectTab('ranking')"
>
<span class="sidebar-tab-icon"><i class="fas fa-trophy"></i></span>
<span class="sidebar-tab-label">排行榜</span>
</button>
<button
v-if="false"
type="button"
class="sidebar-tab"
:class="{ 'sidebar-tab-active': activeTab === 'honor' }"
@click="selectTab('honor')"
>
<span class="sidebar-tab-icon"><i class="fas fa-medal"></i></span>
<span class="sidebar-tab-label">荣耀榜</span>
</button>
<div
v-if="sidebarEntries.length > 0"
class="my-3 border-t border-slate-100 dark:border-slate-800"
+24 -10
View File
@@ -1,5 +1,18 @@
<template>
<!-- 空队列仅显示右下角小按钮 -->
<button
v-if="downloads.length === 0"
type="button"
class="fixed bottom-4 right-4 z-40 inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200/70 bg-white text-slate-500 shadow-lg transition hover:-translate-y-0.5 hover:text-brand dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400 dark:hover:text-white sm:bottom-6 sm:right-6"
title="暂无下载任务"
aria-label="下载队列"
>
<i class="fas fa-download"></i>
</button>
<!-- 有任务展开为完整面板默认展开可点 chevron 折叠列表 -->
<div
v-else
class="fixed inset-x-4 bottom-4 z-40 rounded-3xl border border-slate-200/70 bg-white shadow-2xl transition-all duration-200 dark:border-slate-800/70 dark:bg-slate-900 sm:left-auto sm:right-6 sm:w-96"
>
<div
@@ -53,14 +66,7 @@
v-show="isExpanded"
class="max-h-96 overflow-y-auto overscroll-contain px-3 pb-4"
>
<div
v-if="downloads.length === 0"
class="flex flex-col items-center justify-center rounded-2xl border border-dashed border-slate-200/80 px-4 py-12 text-slate-500 dark:border-slate-800/80 dark:text-slate-400"
>
<i class="fas fa-inbox text-3xl"></i>
<p class="mt-3 text-sm">暂无下载任务</p>
</div>
<div v-else class="space-y-2">
<div class="space-y-2">
<div
v-for="download in downloads"
:key="download.id"
@@ -130,7 +136,7 @@
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { computed, ref, watch } from "vue";
import type { DownloadItem } from "../global/typedefinition";
const props = defineProps<{
@@ -146,7 +152,7 @@ const emit = defineEmits<{
(e: "show-detail", download: DownloadItem): void;
}>();
const isExpanded = ref(false);
const isExpanded = ref(true);
const completedDownloads = computed(() => {
return props.downloads.filter((d) => d.status === "completed").length;
@@ -167,4 +173,12 @@ const clearCompleted = () => {
const showDownloadDetail = (download: DownloadItem) => {
emit("show-detail", download);
};
// 从无任务到有任务时,自动展开面板("有任务时再向左边伸出")
watch(
() => props.downloads.length > 0,
(hasTasks) => {
if (hasTasks) isExpanded.value = true;
},
);
</script>
+93 -69
View File
@@ -1,9 +1,15 @@
<template>
<div class="space-y-6">
<!--
首页视图仅渲染"区域1 精选(links"
历史版本曾渲染"区域2 板块(homelist.json"作为第二个 section
若回滚/合并导致该 section 复活板块应用会与侧边栏入口"装机必备/社区精品/APM 扩展"内容重复展示双来源同数据
现行设计板块已迁移至侧边栏入口App.vue `loadHomeListEntries`首页不再渲染板块参见 commit 438b9baa
-->
<div class="flex h-full min-h-0 flex-col gap-2 overflow-hidden">
<!-- 初始加载状态 - 只有在完全没有数据时显示 -->
<div
v-if="loading && links.length === 0"
class="flex flex-col items-center justify-center py-12 text-slate-500 dark:text-slate-400"
class="flex flex-1 flex-col items-center justify-center text-slate-500 dark:text-slate-400"
>
<i class="fas fa-spinner fa-spin text-2xl mb-3"></i>
<span class="text-sm">正在加载首页内容</span>
@@ -17,7 +23,7 @@
<!-- 无数据时显示欢迎信息 -->
<div
v-else-if="links.length === 0"
class="flex flex-col items-center justify-center py-20 text-center"
class="flex flex-1 flex-col items-center justify-center text-center"
>
<img
v-if="storeFilter === 'apm'"
@@ -47,83 +53,100 @@
</p>
</div>
<!-- 有数据就立即展示图片逐步加载 -->
<div v-else>
<!-- 左上角欢迎语 -->
<div class="mb-4">
<h1 class="text-xl font-bold text-slate-800 dark:text-slate-200">
{{
storeFilter === "apm"
? "欢迎来到星火应用商店 (Amber PM)"
: "欢迎来到星火应用商店"
}}
</h1>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">
探索丰富的应用发现更多精彩内容
</p>
</div>
<!-- Links 区域 -->
<div v-if="links.length > 0" class="grid gap-5 auto-fit-grid">
<a
v-for="link in links"
:key="link.url + link.name"
:href="link.type === '_blank' ? undefined : link.url"
@click.prevent="onLinkClick(link)"
class="group block overflow-hidden rounded-xl transition-transform duration-300 hover:scale-[1.02]"
:title="link.more as string"
<template v-else>
<!-- ============ 区域1 · 精选两行网格 4×2静态不滚动 ============ -->
<section class="shrink-0">
<div class="mb-4">
<h1 class="text-sm font-bold text-slate-800 dark:text-slate-200">
{{
storeFilter === "apm" ? "星火应用商店 (Amber PM)" : "星火应用商店"
}}
</h1>
<p class="text-[10px] text-slate-500 dark:text-slate-400 mt-0.5">
探索丰富的应用发现更多精彩内容
</p>
</div>
<div
v-if="links.length > 0"
class="grid grid-cols-2 gap-4 sm:grid-cols-4"
>
<div
class="relative w-full aspect-[850/400] overflow-hidden rounded-xl bg-slate-100 dark:bg-slate-800"
<a
v-for="link in links"
:key="link.url + link.name"
:href="link.type === '_blank' ? undefined : link.url"
@click.prevent="onLinkClick(link)"
class="group block overflow-hidden rounded-lg transition-transform duration-300 hover:scale-[1.02]"
:title="link.more as string"
>
<img
:src="computedImgUrl(link)"
class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
loading="lazy"
@load="onImageLoad(link.url + link.name)"
@error="onImageError(link.url + link.name)"
:class="{
'opacity-0': !imageLoaded[link.url + link.name],
'opacity-100 transition-opacity duration-300':
imageLoaded[link.url + link.name],
}"
/>
<div
v-if="!imageLoaded[link.url + link.name]"
class="absolute inset-0 flex items-center justify-center"
class="relative w-full overflow-hidden rounded-lg bg-slate-100 dark:bg-slate-800"
>
<img
:src="computedImgUrl(link)"
class="block w-full transition-transform duration-500 group-hover:scale-105"
loading="lazy"
decoding="async"
@load="onImageLoad(link.url + link.name)"
@error="onImageError(link.url + link.name)"
:class="{
'aspect-[850/260] object-cover':
!imageLoaded[link.url + link.name],
'h-auto': imageLoaded[link.url + link.name],
'opacity-0': !imageLoaded[link.url + link.name],
'opacity-100 transition-opacity duration-300':
imageLoaded[link.url + link.name],
}"
/>
<div
class="h-10 w-10 animate-pulse rounded-full bg-slate-200 dark:bg-slate-700"
></div>
v-if="!imageLoaded[link.url + link.name]"
class="absolute inset-0 flex items-center justify-center"
>
<div
class="h-10 w-10 animate-pulse rounded-full bg-slate-200 dark:bg-slate-700"
></div>
</div>
</div>
</div>
<div class="mt-3 px-1">
<div
class="text-base font-semibold text-slate-900 dark:text-white group-hover:text-brand dark:group-hover:text-brand transition-colors"
>
{{ link.name }}
<div class="mt-1.5 px-1">
<div
class="text-[13px] font-semibold text-slate-900 dark:text-white group-hover:text-brand dark:group-hover:text-brand transition-colors"
>
{{ link.name }}
</div>
<div
class="text-[11px] text-slate-500 dark:text-slate-400 mt-0.5 line-clamp-1"
>
{{ link.more }}
</div>
</div>
<div
class="text-sm text-slate-500 dark:text-slate-400 mt-0.5 line-clamp-1"
>
{{ link.more }}
</div>
</div>
</a>
</div>
</div>
</a>
</div>
<!-- 致谢说明F2.3 复用置于区域1 其他内容下方 -->
<ThanksCard class="mt-4" />
</section>
</template>
</div>
</template>
<script setup lang="ts">
import { APM_STORE_BASE_URL } from "../global/storeConfig";
import { reactive } from "vue";
import type { HomeLink } from "../global/typedefinition";
import { APM_STORE_BASE_URL } from "../global/storeConfig";
import type { HomeLink, App } from "../global/typedefinition";
import ThanksCard from "./ThanksCard.vue";
defineProps<{
links: HomeLink[];
loading: boolean;
error: string;
storeFilter?: "spark" | "apm" | "both";
withDefaults(
defineProps<{
links: HomeLink[];
loading: boolean;
error: string;
storeFilter?: "spark" | "apm" | "both";
}>(),
{
storeFilter: "both",
},
);
const emit = defineEmits<{
(e: "open-detail", app: App): void;
}>();
// 图片加载状态跟踪
@@ -152,9 +175,10 @@ const onLinkClick = (link: HomeLink) => {
window.location.href = link.url;
}
};
</script>
<style scoped></style>
// 下载量格式化:>= 1万 显示为 "x.x万"
// (download count now formatted inside AppCard via its downloadCount prop)
</script>
<style scoped>
/* Link 卡片网格 - 固定最小宽度 180px */
+148
View File
@@ -0,0 +1,148 @@
<template>
<div class="flex h-full min-h-0 flex-col gap-3">
<!-- 两个榜单并排每个榜单内容在容器内滚动 -->
<div class="grid min-h-0 flex-1 gap-6 lg:grid-cols-2">
<!-- 贡献荣耀榜 -->
<section class="flex min-h-0 flex-col">
<h2
class="mb-2 shrink-0 text-sm font-bold text-slate-800 dark:text-slate-200"
>
贡献荣耀榜
</h2>
<div
class="grid grid-rows-1 min-h-0 flex-1 gap-4"
:class="gridCols"
>
<div v-if="showSpark" class="flex min-h-0 flex-col">
<SourceLabel origin="spark" />
<div
class="scrollbar-muted min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1"
>
<RankingContributorRow
v-for="(item, i) in contributeSpark"
:key="item.name"
:rank="i + 1"
:name="item.name"
:count="item.count"
label="上榜应用"
/>
<EmptyState
v-if="contributeSpark.length === 0"
:loading="appsLoading"
/>
</div>
</div>
<div v-if="showApm" class="flex min-h-0 flex-col">
<SourceLabel origin="apm" />
<div
class="scrollbar-muted min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1"
>
<RankingContributorRow
v-for="(item, i) in contributeApm"
:key="item.name"
:rank="i + 1"
:name="item.name"
:count="item.count"
label="上榜应用"
/>
<EmptyState
v-if="contributeApm.length === 0"
:loading="appsLoading"
/>
</div>
</div>
</div>
</section>
<!-- 更新荣耀榜 -->
<section class="flex min-h-0 flex-col">
<h2
class="mb-2 shrink-0 text-sm font-bold text-slate-800 dark:text-slate-200"
>
更新荣耀榜
</h2>
<div
class="grid grid-rows-1 min-h-0 flex-1 gap-4"
:class="gridCols"
>
<div v-if="showSpark" class="flex min-h-0 flex-col">
<SourceLabel origin="spark" />
<div
class="scrollbar-muted min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1"
>
<RankingContributorRow
v-for="(item, i) in updateContributeSpark"
:key="item.name"
:rank="i + 1"
:name="item.name"
:count="item.count"
label="近期更新"
/>
<EmptyState
v-if="updateContributeSpark.length === 0"
:loading="appsLoading"
/>
</div>
</div>
<div v-if="showApm" class="flex min-h-0 flex-col">
<SourceLabel origin="apm" />
<div
class="scrollbar-muted min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1"
>
<RankingContributorRow
v-for="(item, i) in updateContributeApm"
:key="item.name"
:rank="i + 1"
:name="item.name"
:count="item.count"
label="近期更新"
/>
<EmptyState
v-if="updateContributeApm.length === 0"
:loading="appsLoading"
/>
</div>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { App } from "../global/typedefinition";
import { aggregateContributors, topUpdatedContributors } from "../modules/ranking";
import SourceLabel from "./SourceLabel.vue";
import RankingContributorRow from "./RankingContributorRow.vue";
import EmptyState from "./RankingEmptyState.vue";
const props = defineProps<{
apps: App[];
storeFilter?: "spark" | "apm" | "both";
}>();
const TOP = 10;
const showSpark = computed(() => props.storeFilter !== "apm");
const showApm = computed(() => props.storeFilter !== "spark");
const gridCols = computed(() =>
showSpark.value && showApm.value
? "grid-cols-1 sm:grid-cols-2"
: "grid-cols-1",
);
const appsLoading = computed(() => props.apps.length === 0);
const contributeSpark = computed(() =>
aggregateContributors(props.apps, "spark", TOP),
);
const contributeApm = computed(() =>
aggregateContributors(props.apps, "apm", TOP),
);
const updateContributeSpark = computed(() =>
topUpdatedContributors(props.apps, "spark"),
);
const updateContributeApm = computed(() =>
topUpdatedContributors(props.apps, "apm"),
);
</script>
+120 -23
View File
@@ -249,20 +249,20 @@
{{ app.name }}
</p>
<span
v-if="app.origin === 'apm'"
data-testid="origin-tag-apm"
class="rounded-md bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-700 dark:bg-amber-500/20 dark:text-amber-400"
title="APM 软件"
v-for="o in (app.origins && app.origins.length
? app.origins
: [app.origin ?? 'spark'])"
:key="o"
:data-testid="o === 'apm' ? 'origin-tag-apm' : 'origin-tag-spark'"
class="rounded-md px-2 py-0.5 text-[11px] font-semibold"
:class="
o === 'apm'
? 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400'
: 'bg-sky-100 text-sky-700 dark:bg-sky-500/20 dark:text-sky-400'
"
:title="o === 'apm' ? 'APM 软件' : 'Spark 软件'"
>
APM
</span>
<span
v-else-if="app.origin === 'spark'"
data-testid="origin-tag-spark"
class="rounded-md bg-sky-100 px-2 py-0.5 text-[11px] font-semibold text-sky-700 dark:bg-sky-500/20 dark:text-sky-400"
title="Spark 软件"
>
Spark
{{ o === "apm" ? "APM" : "Spark" }}
</span>
<span
v-if="app.isDependency"
@@ -288,7 +288,7 @@
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-slate-300/70 px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
@click="$emit('open-app', app)"
@click="onOpenClick(app)"
>
<i class="fas fa-play"></i>
打开
@@ -316,6 +316,75 @@
</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="openChoiceApp"
class="fixed inset-0 z-[85] flex items-center justify-center bg-slate-900/70 p-4"
@click.self="openChoiceApp = null"
>
<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="openChoiceApp = null"
>
取消
</button>
</div>
</div>
</div>
</Transition>
</div>
</Transition>
</template>
@@ -374,13 +443,19 @@ const props = defineProps<{
syncMessage: string;
}>();
const apmCount = computed(
() => props.apps.filter((a) => a.origin === "apm").length,
// 判断某应用是否以指定来源安装:优先看 origins 集合,回退到单 origin 字段
const hasOrigin = (app: App, origin: "spark" | "apm"): boolean =>
app.origins?.includes(origin) ?? app.origin === origin;
// APM / Spark 分别统计实际安装的包数量(同一 pkgname 同时装两种来源时各计一次)
const apmCount = computed(() =>
props.apps.filter((a) => hasOrigin(a, "apm")).length,
);
const sparkCount = computed(
() => props.apps.filter((a) => a.origin === "spark").length,
const sparkCount = computed(() =>
props.apps.filter((a) => hasOrigin(a, "spark")).length,
);
const totalCount = computed(() => props.apps.length);
// 总数 = APM 包数 + Spark 包数(不同来源视为不同包,单独计数)
const totalCount = computed(() => apmCount.value + sparkCount.value);
// 来源筛选:默认全部;点击统计徽章可在 all/apm/spark 间切换
const filterOrigin = ref<"all" | "apm" | "spark">("all");
@@ -401,16 +476,16 @@ const filteredApps = computed(() => {
// 2. 再按来源筛选(默认 all = 不过滤)
if (filterOrigin.value === "apm") {
list = list.filter((a) => a.origin === "apm");
list = list.filter((a) => hasOrigin(a, "apm"));
} else if (filterOrigin.value === "spark") {
list = list.filter((a) => a.origin === "spark");
list = list.filter((a) => hasOrigin(a, "spark"));
}
// 3. 排序:APM 应用始终排在前面(默认全部视图也遵守此规则)
// 返回新数组,避免修改原始 props.apps
return [...list].sort((a, b) => {
const aApm = a.origin === "apm" ? 0 : 1;
const bApm = b.origin === "apm" ? 0 : 1;
const aApm = hasOrigin(a, "apm") ? 0 : 1;
const bApm = hasOrigin(b, "apm") ? 0 : 1;
if (aApm !== bApm) return aApm - bApm;
// 同类内保持原有的字母序,体验更一致
return a.pkgname.localeCompare(b.pkgname);
@@ -429,6 +504,28 @@ const emit = defineEmits<{
(e: "request-login"): void;
}>();
// 双来源安装时,"打开"需先让用户选择打开 APM 还是 Spark 版
const openChoiceApp = ref<App | null>(null);
const openOrigins = (app: App): Array<"spark" | "apm"> =>
app.origins && app.origins.length > 0 ? app.origins : [app.origin ?? "spark"];
const onOpenClick = (app: App) => {
const origins = openOrigins(app);
if (origins.length > 1) {
openChoiceApp.value = app;
} else {
// 单来源直接打开(携带正确 origin 供 launch-app 分支)
emit("open-app", { ...app, origin: origins[0] });
}
};
const confirmOpen = (origin: "spark" | "apm") => {
if (openChoiceApp.value) {
emit("open-app", { ...openChoiceApp.value, origin });
}
openChoiceApp.value = null;
};
// 云端同步功能暂时关闭
// const handleSyncClick = () => {
// if (props.loggedIn) {
+101
View File
@@ -0,0 +1,101 @@
<template>
<div
class="group flex cursor-pointer items-center gap-2 rounded-lg border border-slate-200/70 bg-white/90 p-2 transition hover:border-brand/50 dark:border-slate-800/60 dark:bg-slate-900/60"
@click="openDetail"
>
<!-- 排名序号 3 名金// -->
<span
class="w-5 shrink-0 text-center text-sm font-bold"
:class="rankClass"
>{{ rank }}</span
>
<!-- 应用图标 -->
<div
class="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-md bg-gradient-to-b from-slate-100 to-slate-200 shadow-inner dark:from-slate-800 dark:to-slate-700"
>
<img
v-if="!iconFailed"
:src="iconPath"
:alt="app.name"
class="h-full w-full object-cover"
@error="iconFailed = true"
/>
<i v-else class="fas fa-cube text-slate-400"></i>
</div>
<!-- 应用信息 -->
<div class="flex min-w-0 flex-1 flex-col gap-1 overflow-hidden">
<div class="flex items-center gap-2">
<div
class="truncate text-xs font-semibold text-slate-900 dark:text-white"
>
{{ app.name || "" }}
</div>
<span
v-if="app.origin === 'spark'"
class="rounded-md bg-orange-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-orange-600 dark:bg-orange-900/30 dark:text-orange-400"
>Spark</span
>
<span
v-else
class="rounded-md bg-blue-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-blue-600 dark:bg-blue-900/30 dark:text-blue-400"
>APM</span
>
<span
v-if="showDownload && downloadCount"
class="ml-auto shrink-0 text-[10px] text-slate-400 dark:text-slate-500"
>{{ formatDownloads(downloadCount) }}</span
>
</div>
<div
class="truncate text-xs leading-tight text-slate-500 dark:text-slate-400"
>
{{ app.pkgname || "" }} · {{ app.version || ""
}}<template v-if="updateTime"> · {{ updateTime }}</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { APM_STORE_BASE_URL } from "../global/storeConfig";
import type { App } from "../global/typedefinition";
const props = defineProps<{
app: App;
rank: number;
showDownload?: boolean;
downloadCount?: number;
updateTime?: string;
}>();
const emit = defineEmits<{ (e: "open-detail", app: App): void }>();
const iconFailed = ref(false);
const iconPath = computed(() => {
const arch = window.apm_store.arch || "amd64";
const finalArch = props.app.origin === "spark" ? `${arch}-store` : `${arch}-apm`;
return `${APM_STORE_BASE_URL}/${finalArch}/${props.app.category}/${props.app.pkgname}/icon.png`;
});
const rankClass = computed(() => {
if (props.rank === 1) return "text-amber-400";
if (props.rank === 2) return "text-slate-400";
if (props.rank === 3) return "text-amber-600";
return "text-slate-400 dark:text-slate-500";
});
const formatDownloads = (n?: number): string => {
if (!n || n <= 0) return "0";
if (n >= 10000) {
const wan = n / 10000;
return `${wan.toFixed(1).replace(/\.0$/, "")}`;
}
return n.toLocaleString();
};
const openDetail = () => emit("open-detail", props.app);
</script>
+55
View File
@@ -0,0 +1,55 @@
<template>
<div
class="group flex items-center gap-2 rounded-lg border border-slate-200/70 bg-white/90 p-2 transition hover:border-brand/50 dark:border-slate-800/60 dark:bg-slate-900/60"
>
<!-- 排名序号 3 名金// -->
<span
class="w-5 shrink-0 text-center text-sm font-bold"
:class="rankClass"
>{{ rank }}</span
>
<!-- 贡献者头像 -->
<div
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-gradient-to-b from-amber-100 to-amber-200 text-amber-600 shadow-inner dark:from-amber-900/40 dark:to-amber-800/40"
>
<i class="fas fa-user"></i>
</div>
<!-- 贡献者信息 -->
<div class="flex min-w-0 flex-1 flex-col gap-1 overflow-hidden">
<div class="truncate text-xs font-semibold text-slate-900 dark:text-white">
{{ name }}
</div>
<div
class="truncate text-xs leading-tight text-slate-500 dark:text-slate-400"
>
{{ label }}
</div>
</div>
<!-- 数量徽标 -->
<span
class="shrink-0 rounded-full bg-slate-100 px-2 py-0.5 text-xs font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-300"
>{{ count }}</span
>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
const props = defineProps<{
rank: number;
name: string;
count: number;
label?: string;
}>();
const rankClass = computed(() => {
if (props.rank === 1) return "text-amber-400";
if (props.rank === 2) return "text-slate-400";
if (props.rank === 3) return "text-amber-600";
return "text-slate-400 dark:text-slate-500";
});
</script>
+18
View File
@@ -0,0 +1,18 @@
<template>
<div
class="flex items-center justify-center gap-2 py-4 text-xs text-slate-400"
>
<i v-if="loading" class="fas fa-spinner fa-spin"></i>
<span>{{ loading ? (loadingText || "加载中…") : "暂无数据" }}</span>
</div>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
loading?: boolean;
loadingText?: string;
}>(),
{ loading: false, loadingText: "加载中…" },
);
</script>
+145
View File
@@ -0,0 +1,145 @@
<template>
<div class="flex h-full min-h-0 flex-col gap-3">
<!-- 两个榜单并排每个榜单内容在容器内滚动 -->
<div class="grid min-h-0 flex-1 gap-6 lg:grid-cols-2">
<!-- 应用下载排行 -->
<section class="flex min-h-0 flex-col">
<h2
class="mb-2 shrink-0 text-sm font-bold text-slate-800 dark:text-slate-200"
>
应用下载排行
</h2>
<div
class="grid grid-rows-1 min-h-0 flex-1 gap-4"
:class="gridCols"
>
<div v-if="showSpark" class="flex min-h-0 flex-col">
<SourceLabel origin="spark" />
<div
class="scrollbar-muted min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1"
>
<RankingAppRow
v-for="(app, i) in downloadSpark"
:key="app.pkgname"
:app="app"
:rank="i + 1"
show-download
:download-count="app.downloadCount"
@open-detail="onOpen"
/>
<EmptyState
v-if="downloadSpark.length === 0"
:loading="rankingLoading"
loading-text="加载排行中"
/>
</div>
</div>
<div v-if="showApm" class="flex min-h-0 flex-col">
<SourceLabel origin="apm" />
<div
class="scrollbar-muted min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1"
>
<RankingAppRow
v-for="(app, i) in downloadApm"
:key="app.pkgname"
:app="app"
:rank="i + 1"
show-download
:download-count="app.downloadCount"
@open-detail="onOpen"
/>
<EmptyState
v-if="downloadApm.length === 0"
:loading="rankingLoading"
loading-text="加载排行中"
/>
</div>
</div>
</div>
</section>
<!-- 应用更新排行 -->
<section class="flex min-h-0 flex-col">
<h2
class="mb-2 shrink-0 text-sm font-bold text-slate-800 dark:text-slate-200"
>
应用更新排行
</h2>
<div
class="grid grid-rows-1 min-h-0 flex-1 gap-4"
:class="gridCols"
>
<div v-if="showSpark" class="flex min-h-0 flex-col">
<SourceLabel origin="spark" />
<div
class="scrollbar-muted min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1"
>
<RankingAppRow
v-for="(app, i) in updateSpark"
:key="app.pkgname"
:app="app"
:rank="i + 1"
:update-time="app.update"
@open-detail="onOpen"
/>
<EmptyState v-if="updateSpark.length === 0" :loading="appsLoading" />
</div>
</div>
<div v-if="showApm" class="flex min-h-0 flex-col">
<SourceLabel origin="apm" />
<div
class="scrollbar-muted min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1"
>
<RankingAppRow
v-for="(app, i) in updateApm"
:key="app.pkgname"
:app="app"
:rank="i + 1"
:update-time="app.update"
@open-detail="onOpen"
/>
<EmptyState v-if="updateApm.length === 0" :loading="appsLoading" />
</div>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { App } from "../global/typedefinition";
import { topByUpdate } from "../modules/ranking";
import RankingAppRow from "./RankingAppRow.vue";
import SourceLabel from "./SourceLabel.vue";
import EmptyState from "./RankingEmptyState.vue";
const props = defineProps<{
apps: App[];
apmRanking: App[];
sparkRanking: App[];
rankingLoading: boolean;
storeFilter?: "spark" | "apm" | "both";
}>();
const emit = defineEmits<{ (e: "open-detail", app: App): void }>();
const TOP = 10;
const showSpark = computed(() => props.storeFilter !== "apm");
const showApm = computed(() => props.storeFilter !== "spark");
const gridCols = computed(() =>
showSpark.value && showApm.value
? "grid-cols-1 sm:grid-cols-2"
: "grid-cols-1",
);
const appsLoading = computed(() => props.apps.length === 0);
const downloadSpark = computed(() => props.sparkRanking.slice(0, TOP));
const downloadApm = computed(() => props.apmRanking.slice(0, TOP));
const updateSpark = computed(() => topByUpdate(props.apps, "spark", TOP));
const updateApm = computed(() => topByUpdate(props.apps, "apm", TOP));
const onOpen = (app: App) => emit("open-detail", app);
</script>
+24
View File
@@ -0,0 +1,24 @@
<template>
<div
class="mb-1 flex items-center gap-1 text-xs font-semibold"
:class="
origin === 'spark'
? 'text-orange-600 dark:text-orange-400'
: 'text-blue-600 dark:text-blue-400'
"
>
<span
class="rounded px-1.5 py-0.5 text-[10px] font-bold uppercase"
:class="
origin === 'spark'
? 'bg-orange-100 dark:bg-orange-900/30'
: 'bg-blue-100 dark:bg-blue-900/30'
"
>{{ origin === "spark" ? "Spark" : "APM" }}</span
>
</div>
</template>
<script setup lang="ts">
defineProps<{ origin: "spark" | "apm" }>();
</script>
+44
View File
@@ -0,0 +1,44 @@
<template>
<div
class="relative mb-6 overflow-hidden rounded-2xl border border-amber-200/60 bg-gradient-to-r from-amber-50 to-orange-50 p-5 shadow-sm dark:border-amber-500/20 dark:from-amber-500/10 dark:to-orange-500/10"
>
<div
class="absolute -right-4 -top-4 text-7xl text-amber-200/50 dark:text-amber-400/10"
>
<i class="fas fa-heart"></i>
</div>
<div class="flex items-start gap-3">
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow"
>
<i class="fas fa-hand-holding-heart"></i>
</div>
<div>
<p
class="text-sm font-bold text-amber-700 dark:text-amber-300"
>
{{ title }}
</p>
<p
class="mt-1 text-sm leading-relaxed text-slate-600 dark:text-slate-300"
>
{{ message }}
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
title?: string;
message?: string;
}>(),
{
title: "致每一位星火贡献者",
message:
"星火应用商店的每一次更新、每一个应用,都离不开开发者的默默耕耘与热心用户的悉心维护。感谢所有为星火生态付出时间与精力的贡献者——是你们,让开源之光生生不息。🎉",
},
);
</script>
+43 -5
View File
@@ -58,6 +58,34 @@
{{ error }}
</div>
<!-- 双来源先让用户选择要卸载的来源 -->
<div
v-if="installOrigins.length > 1 && !uninstalling && !completed"
class="mb-1 flex w-full flex-col items-end gap-2"
>
<p class="text-sm text-slate-500 dark:text-slate-400">
该应用同时通过 APM Spark 安装请选择要卸载的来源
</p>
<div class="flex gap-2">
<button
type="button"
class="inline-flex items-center gap-2 rounded-xl border border-amber-300/70 px-4 py-2 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="confirmUninstall('apm')"
>
<i class="fas fa-box-open"></i>
卸载 APM
</button>
<button
type="button"
class="inline-flex items-center gap-2 rounded-xl border border-sky-300/70 px-4 py-2 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="confirmUninstall('spark')"
>
<i class="fas fa-bolt"></i>
卸载 Spark
</button>
</div>
</div>
<div class="flex items-center justify-end gap-3">
<button
v-if="!uninstalling"
@@ -69,10 +97,10 @@
</button>
<button
v-if="!uninstalling && !completed"
v-if="installOrigins.length <= 1 && !uninstalling && !completed"
type="button"
class="inline-flex items-center gap-2 rounded-xl bg-rose-500 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-rose-500/30 transition hover:bg-rose-600 hover:-translate-y-0.5"
@click="confirmUninstall"
@click="confirmUninstall(installOrigins[0])"
>
<i class="fas fa-trash"></i>
确认卸载
@@ -115,6 +143,14 @@ const logEnd = ref<HTMLElement | null>(null);
const appName = computed(() => props.app?.name || "未知应用");
const appPkg = computed(() => props.app?.pkgname || "");
// 该应用实际安装的来源集合:优先取自合并后的 origins,回退到单 origin 字段
const installOrigins = computed<Array<"spark" | "apm">>(() => {
if (props.app?.origins && props.app.origins.length > 0) {
return props.app.origins;
}
return [props.app?.origin ?? "spark"];
});
const handleClose = () => {
if (uninstalling.value && !completed.value) return; // Prevent closing while uninstalling
reset();
@@ -134,18 +170,20 @@ const reset = () => {
error.value = "";
};
const confirmUninstall = () => {
const confirmUninstall = (origin: "spark" | "apm") => {
if (!appPkg.value) {
error.value = "无效的包名";
return;
}
uninstalling.value = true;
logs.value = ["正在请求卸载: " + appPkg.value + "..."];
const originLabel = origin === "apm" ? "APM" : "Spark";
logs.value = [`正在请求卸载(${originLabel}): ${appPkg.value}...`];
// 按用户选择的来源走对应的卸载方式(spark -> aptss removeapm -> apm autoremove
window.ipcRenderer.send("remove-installed", {
pkgname: appPkg.value,
origin: props.app?.origin || "spark",
origin,
});
};
+113 -15
View File
@@ -1,20 +1,76 @@
<template>
<header
class="window-titlebar sticky top-0 z-20 flex h-10 shrink-0 items-center justify-between border-b border-slate-200/70 bg-white px-3 text-slate-700 dark:border-slate-800/70 dark:bg-slate-950 dark:text-slate-200"
class="window-titlebar sticky top-0 z-20 flex h-12 shrink-0 items-center gap-3 border-b border-slate-200/70 bg-slate-50 px-4 text-slate-700 dark:border-slate-800/70 dark:bg-slate-950 dark:text-slate-200"
>
<div class="flex min-w-0 items-center gap-2">
<img
src="../assets/imgs/spark-store.svg"
class="h-5 w-5 flex-shrink-0"
alt="星火应用商店"
/>
<span class="truncate text-sm font-semibold">星火应用商店</span>
</div>
<div class="window-titlebar-controls flex items-center gap-1">
<!-- 移动端侧栏切换 + logo + 应用名 -->
<div class="window-titlebar-left flex min-w-0 items-center gap-2">
<button
type="button"
class="rounded-md px-3 py-1 text-sm transition hover:bg-slate-200/80 dark:hover:bg-slate-800"
class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-slate-500 transition hover:bg-slate-200/80 lg:hidden dark:text-slate-400 dark:hover:bg-slate-800"
aria-label="切换侧边栏"
title="切换侧边栏"
@click="$emit('toggle-sidebar')"
>
<i class="fas fa-bars text-base"></i>
</button>
<img
src="../assets/imgs/spark-store.svg"
class="h-8 w-8 flex-shrink-0"
alt="星火应用商店"
/>
<span class="truncate text-base font-semibold">星火应用商店</span>
</div>
<!-- 搜索框取代原 AppHeader 里的搜索 -->
<div class="window-titlebar-center min-w-0 flex-1">
<div class="relative mx-auto w-full max-w-xl">
<i
class="fas fa-search pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-slate-400"
></i>
<input
id="searchBox"
:value="searchQuery"
class="w-full rounded-full border border-slate-200/70 bg-white/70 py-2 pl-9 pr-9 text-sm text-slate-700 shadow-sm outline-none transition placeholder:text-slate-400 focus:border-brand/50 focus:ring-2 focus:ring-brand/10 dark:border-slate-800/70 dark:bg-slate-900/60 dark:text-slate-200"
placeholder="搜索应用名 / 包名 / 标签 / 粘贴 SPK 分享链接"
@focus="handleFocus"
@input="handleInput"
/>
<button
v-if="searchQuery"
type="button"
class="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300"
title="清除搜索"
@click="clearSearch"
>
<i class="fas fa-times-circle text-sm"></i>
</button>
</div>
</div>
<!-- 设置 + 关于 + 窗口控制 -->
<div class="window-titlebar-controls flex shrink-0 items-center gap-1">
<button
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-md text-slate-500 transition hover:bg-slate-200/80 dark:text-slate-400 dark:hover:bg-slate-800"
aria-label="安装设置"
title="安装设置"
@click="$emit('open-install-settings')"
>
<i class="fas fa-cog text-base"></i>
</button>
<button
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-md text-slate-500 transition hover:bg-slate-200/80 dark:text-slate-400 dark:hover:bg-slate-800"
aria-label="关于"
title="关于"
@click="$emit('open-about')"
>
<i class="fas fa-info-circle text-base"></i>
</button>
<div class="mx-1 h-6 w-px bg-slate-300/70 dark:bg-slate-700/70"></div>
<button
type="button"
class="rounded-md px-3 py-1.5 text-base transition hover:bg-slate-200/80 dark:hover:bg-slate-800"
aria-label="最小化"
title="最小化"
@click="minimize"
@@ -23,7 +79,7 @@
</button>
<button
type="button"
class="rounded-md px-3 py-1 text-sm transition hover:bg-slate-200/80 dark:hover:bg-slate-800"
class="rounded-md px-3 py-1.5 text-base transition hover:bg-slate-200/80 dark:hover:bg-slate-800"
aria-label="最大化或还原"
title="最大化或还原"
@click="toggleMaximize"
@@ -44,6 +100,47 @@
</template>
<script setup lang="ts">
defineProps<{
searchQuery: string;
}>();
const emit = defineEmits<{
(e: "update:searchQuery", value: string): void;
(e: "search-focus"): void;
(e: "spk-link", pkgname: string): void;
(e: "open-install-settings"): void;
(e: "open-about"): void;
(e: "toggle-sidebar"): void;
}>();
const handleFocus = () => {
emit("search-focus");
};
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement | null;
if (!target) return;
const value = target.value.trim();
// 检测 SPK 分享链接: spk://search/{pkgname} 或 spk://store/{category}/{pkgname}
const spkMatch =
value.match(/^spk:\/\/search\/(.+)$/i) ||
value.match(/^spk:\/\/store\/[^/]+\/(.+)$/i);
if (spkMatch) {
const pkgname = spkMatch[1].trim();
if (pkgname) {
target.value = "";
emit("update:searchQuery", "");
emit("spk-link", pkgname);
return;
}
}
emit("update:searchQuery", target.value);
};
const clearSearch = () => {
emit("update:searchQuery", "");
};
const minimize = () => {
window.windowControls.minimize();
};
@@ -62,8 +159,9 @@ const close = () => {
-webkit-app-region: drag;
}
.window-titlebar-controls,
.window-titlebar-controls button {
/* 所有可交互元素(输入框、按钮)禁用窗口拖动 */
.window-titlebar input,
.window-titlebar button {
-webkit-app-region: no-drag;
}
</style>