feat(应用详情): 增强应用详情页功能并优化代码格式

重构应用详情页逻辑,支持从首页和深度链接直接打开应用时自动获取完整信息
优化应用卡片来源标识显示,支持同时显示多个来源
统一代码格式,修复多行字符串和模板字符串的换行问题
This commit is contained in:
2026-03-29 17:21:17 +08:00
parent 94f4307783
commit e7fb8e689a
7 changed files with 360 additions and 121 deletions

View File

@@ -480,9 +480,7 @@ async function processNextInQueue() {
) {
clearInterval(timeoutChecker);
child.kill();
reject(
new Error(`下载卡在0%超过 ${currentTimeout / 1000}`),
);
reject(new Error(`下载卡在0%超过 ${currentTimeout / 1000}`));
}
}, progressCheckInterval);
@@ -527,7 +525,9 @@ async function processNextInQueue() {
} catch (err) {
retryCount++;
if (retryCount >= timeoutList.length) {
throw new Error(`下载失败,已重试 ${timeoutList.length} 次: ${err}`);
throw new Error(
`下载失败,已重试 ${timeoutList.length} 次: ${err}`,
);
}
sendLog(`下载失败,准备重试 (${retryCount}/${timeoutList.length})`);
// 等待2秒后重试
@@ -860,7 +860,9 @@ ipcMain.handle("list-installed", async () => {
if (hasEntries) {
try {
const desktopFiles = fs.readdirSync(entriesPath);
logger.debug(`Found desktop files for ${pkgname}: ${desktopFiles.join(", ")}`);
logger.debug(
`Found desktop files for ${pkgname}: ${desktopFiles.join(", ")}`,
);
for (const file of desktopFiles) {
if (file.endsWith(".desktop")) {
const desktopPath = path.join(entriesPath, file);
@@ -870,7 +872,9 @@ ipcMain.handle("list-installed", async () => {
const iconMatch = content.match(/^Icon=(.+)$/m);
if (nameMatch) appName = nameMatch[1].trim();
if (iconMatch) icon = iconMatch[1].trim();
logger.debug(`Parsed desktop file for ${pkgname}: name=${appName}, icon=${icon}`);
logger.debug(
`Parsed desktop file for ${pkgname}: name=${appName}, icon=${icon}`,
);
break;
}
}

View File

@@ -389,84 +389,191 @@ const selectCategory = (category: string) => {
}
};
// 从仓库获取应用详细信息的辅助函数
const fetchAppFromStore = async (
pkgname: string,
category: string,
origin: "spark" | "apm",
): Promise<App | null> => {
try {
const arch = window.apm_store.arch || "amd64";
const finalArch = origin === "spark" ? `${arch}-store` : `${arch}-apm`;
const appJsonUrl = `${APM_STORE_BASE_URL}/${finalArch}/${category}/${pkgname}/app.json`;
const response = await fetch(cacheBuster(appJsonUrl));
if (!response.ok) return null;
const appJson = await response.json();
return {
name: appJson.Name || "",
pkgname: appJson.Pkgname || pkgname,
version: appJson.Version || "",
filename: appJson.Filename || "",
torrent_address: appJson.Torrent_address || "",
author: appJson.Author || "",
contributor: appJson.Contributor || "",
website: appJson.Website || "",
update: appJson.Update || "",
size: appJson.Size || "",
more: appJson.More || "",
tags: appJson.Tags || "",
img_urls:
typeof appJson.img_urls === "string"
? (JSON.parse(appJson.img_urls) as string[])
: appJson.img_urls || [],
icons: appJson.icons || "",
category: category,
origin: origin,
currentStatus: "not-installed",
};
} catch (e) {
console.warn(`Failed to fetch ${origin} app info for ${pkgname}`, e);
return null;
}
};
const openDetail = async (app: App | Record<string, unknown>) => {
// 提取 pkgname必须存在
// 提取 pkgname 和 category(必须存在)
const pkgname = (app as Record<string, unknown>).pkgname as string;
const category =
((app as Record<string, unknown>).category as string) || "unknown";
// 检查是否来自 HomeView 或 DeepLink需要重新获取完整信息
const fromHomeView = (app as Record<string, unknown>)._fromHomeView === true;
const fromDeepLink = (app as Record<string, unknown>)._fromDeepLink === true;
const needFetchFromStore = fromHomeView || fromDeepLink;
if (!pkgname) {
console.warn("openDetail: 缺少 pkgname", app);
return;
}
// 首先尝试从当前已经处理好(合并/筛选)的 filteredApps 中查找,以便获取 isMerged 状态等
// 首先尝试从当前已经处理好(合并/筛选)的 filteredApps 中查找
let fullApp = filteredApps.value.find((a) => a.pkgname === pkgname);
// 如果没找到(可能是从已安装列表之类的其他入口打开的),回退到全局 apps 中查找完整 App
// 如果没找到,回退到全局 apps 中查找
if (!fullApp) {
fullApp = apps.value.find((a) => a.pkgname === pkgname);
}
if (!fullApp) {
// 构造一个最小可用的 App 对象
fullApp = {
name: ((app as Record<string, unknown>).name as string) || "",
pkgname: pkgname,
version: ((app as Record<string, unknown>).version as string) || "",
filename: ((app as Record<string, unknown>).filename as string) || "",
category:
((app as Record<string, unknown>).category as string) || "unknown",
torrent_address: "",
author: "",
contributor: "",
website: "",
update: "",
size: "",
more: ((app as Record<string, unknown>).more as string) || "",
tags: "",
img_urls: [],
icons: "",
origin:
((app as Record<string, unknown>).origin as "spark" | "apm") || "apm",
currentStatus: "not-installed",
} as App;
let finalApp: App;
// 来自 HomeView 或 DeepLink 的应用需要重新从仓库获取完整信息
if (needFetchFromStore) {
// 从 Spark 和 APM 仓库获取完整的应用信息
const [sparkApp, apmApp] = await Promise.all([
storeFilter.value !== "apm"
? fetchAppFromStore(pkgname, category, "spark")
: Promise.resolve(null),
storeFilter.value !== "spark"
? fetchAppFromStore(pkgname, category, "apm")
: Promise.resolve(null),
]);
// 构建合并的应用对象
if (sparkApp || apmApp) {
// 如果两个仓库都有这个应用,创建合并对象
if (sparkApp && apmApp) {
finalApp = {
...sparkApp, // 默认使用 Spark 的信息作为主显示
isMerged: true,
sparkApp: sparkApp,
apmApp: apmApp,
viewingOrigin: "spark", // 默认查看 Spark 版本
};
} else if (sparkApp) {
finalApp = sparkApp;
} else {
finalApp = apmApp!;
}
} else if (fullApp) {
finalApp = fullApp;
} else {
// 两个仓库都没有找到,且本地也没有,构造一个最小可用的 App 对象
finalApp = {
name: ((app as Record<string, unknown>).name as string) || "",
pkgname: pkgname,
version: ((app as Record<string, unknown>).version as string) || "",
filename: ((app as Record<string, unknown>).filename as string) || "",
category: category,
torrent_address: "",
author: "",
contributor: "",
website: "",
update: "",
size: "",
more: ((app as Record<string, unknown>).more as string) || "",
tags: "",
img_urls: [],
icons: "",
origin:
((app as Record<string, unknown>).origin as "spark" | "apm") || "apm",
currentStatus: "not-installed",
} as App;
}
} else {
// 非 HomeView 来源,使用原来的逻辑
if (fullApp) {
finalApp = fullApp;
} else {
// 构造一个最小可用的 App 对象
finalApp = {
name: ((app as Record<string, unknown>).name as string) || "",
pkgname: pkgname,
version: ((app as Record<string, unknown>).version as string) || "",
filename: ((app as Record<string, unknown>).filename as string) || "",
category: category,
torrent_address: "",
author: "",
contributor: "",
website: "",
update: "",
size: "",
more: ((app as Record<string, unknown>).more as string) || "",
tags: "",
img_urls: [],
icons: "",
origin:
((app as Record<string, unknown>).origin as "spark" | "apm") || "apm",
currentStatus: "not-installed",
} as App;
}
}
// 合并应用:先检查 Spark/APM 安装状态,已安装的版本优先展示
if (fullApp.isMerged && (fullApp.sparkApp || fullApp.apmApp)) {
// 检查 Spark/APM 安装状态,已安装的版本优先展示
if (finalApp.isMerged && (finalApp.sparkApp || finalApp.apmApp)) {
const [sparkInstalled, apmInstalled] = await Promise.all([
fullApp.sparkApp
finalApp.sparkApp
? (window.ipcRenderer.invoke("check-installed", {
pkgname: fullApp.sparkApp.pkgname,
pkgname: finalApp.sparkApp.pkgname,
origin: "spark",
}) as Promise<boolean>)
: Promise.resolve(false),
fullApp.apmApp
finalApp.apmApp
? (window.ipcRenderer.invoke("check-installed", {
pkgname: fullApp.apmApp.pkgname,
pkgname: finalApp.apmApp.pkgname,
origin: "apm",
}) as Promise<boolean>)
: Promise.resolve(false),
]);
if (sparkInstalled && !apmInstalled) {
fullApp.viewingOrigin = "spark";
finalApp.viewingOrigin = "spark";
} else if (apmInstalled && !sparkInstalled) {
fullApp.viewingOrigin = "apm";
finalApp.viewingOrigin = "apm";
}
// 若都安装或都未安装,不设置 viewingOrigin由模态框默认展示 spark
// 若都安装或都未安装,默认展示 spark
}
const displayAppForScreenshots =
fullApp.viewingOrigin !== undefined && fullApp.isMerged
? ((fullApp.viewingOrigin === "spark"
? fullApp.sparkApp
: fullApp.apmApp) ?? fullApp)
: fullApp;
finalApp.viewingOrigin !== undefined && finalApp.isMerged
? ((finalApp.viewingOrigin === "spark"
? finalApp.sparkApp
: finalApp.apmApp) ?? finalApp)
: finalApp;
currentApp.value = fullApp;
currentApp.value = finalApp;
currentScreenIndex.value = 0;
loadScreenshots(displayAppForScreenshots);
showModal.value = true;
currentAppSparkInstalled.value = false;
currentAppApmInstalled.value = false;
checkAppInstalled(fullApp);
checkAppInstalled(finalApp);
nextTick(() => {
const modal = document.querySelector(
@@ -1181,9 +1288,12 @@ onMounted(async () => {
(_event: IpcRendererEvent, data: { pkgname: string }) => {
// 根据包名直接打开应用详情
const tryOpen = () => {
// 先切换到"全部应用"分类
activeCategory.value = "all";
// 使用类似 HomeView 的方式打开应用,从两个仓库获取完整信息
const target = apps.value.find((a) => a.pkgname === data.pkgname);
if (target) {
openDetail(target);
openDetail({ ...target, _fromDeepLink: true });
} else {
// 如果找不到应用,回退到搜索模式
searchQuery.value = data.pkgname;

View File

@@ -21,30 +21,29 @@
>
{{ app.name || "" }}
</div>
<span
:class="[
'shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider shadow-sm',
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.isMerged
? "Spark/APM"
: app.origin === "spark"
? "Spark"
: "APM"
}}
</span>
<!-- 来源标识支持同时显示多个 -->
<div class="flex shrink-0 gap-1">
<span
v-if="showSparkBadge"
class="rounded-md px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider shadow-sm bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400"
>
Spark
</span>
<span
v-if="showApmBadge"
class="rounded-md px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider shadow-sm bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400"
>
APM
</span>
</div>
</div>
<div class="text-sm text-slate-500 dark:text-slate-400 leading-tight">
{{ app.pkgname || "" }} · {{ app.version || "" }}
</div>
<div class="truncate text-xs text-slate-500 dark:text-slate-400 leading-tight">
{{ description || '\u00A0' }}
<div
class="truncate text-xs text-slate-500 dark:text-slate-400 leading-tight"
>
{{ description || "\u00A0" }}
</div>
</div>
</div>
@@ -57,6 +56,9 @@ import type { App } from "../global/typedefinition";
const props = defineProps<{
app: App;
// 从外部传入的 Spark/APM 可用性信息
sparkAvailable?: boolean;
apmAvailable?: boolean;
}>();
const emit = defineEmits<{
@@ -69,6 +71,22 @@ const loadedIcon = ref(
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Crect fill="%23f0f0f0" width="100" height="100"/%3E%3C/svg%3E',
);
// 是否显示 Spark 标识
const showSparkBadge = computed(() => {
// 如果明确指定了 sparkAvailable使用它
if (props.sparkAvailable !== undefined) return props.sparkAvailable;
// 否则根据 app 的 origin 或 isMerged 判断
return props.app.origin === "spark" || props.app.isMerged === true;
});
// 是否显示 APM 标识
const showApmBadge = computed(() => {
// 如果明确指定了 apmAvailable使用它
if (props.apmAvailable !== undefined) return props.apmAvailable;
// 否则根据 app 的 origin 或 isMerged 判断
return props.app.origin === "apm" || props.app.isMerged === true;
});
const iconPath = computed(() => {
const arch = window.apm_store.arch || "amd64";
const finalArch =

View File

@@ -57,14 +57,25 @@
<!-- 版本号和来源切换 -->
<div class="space-y-3">
<!-- 版本号 -->
<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>
<span class="text-sm font-semibold text-slate-800 dark:text-slate-200">{{ displayApp?.version || "-" }}</span>
<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
>
<span
class="text-sm font-semibold text-slate-800 dark:text-slate-200"
>{{ displayApp?.version || "-" }}</span
>
</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
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"
@@ -110,9 +121,17 @@
</div>
<!-- 下载量 -->
<div v-if="downloadCount" 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>
<span class="text-sm font-semibold text-slate-800 dark:text-slate-200">{{ downloadCount }}</span>
<div
v-if="downloadCount"
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
>
<span
class="text-sm font-semibold text-slate-800 dark:text-slate-200"
>{{ downloadCount }}</span
>
</div>
</div>
@@ -163,55 +182,79 @@
</div>
<!-- 其他元信息 -->
<div class="space-y-2 pt-2 border-t border-slate-200/60 dark:border-slate-800/60" @click="showAllMetaData">
<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>
<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>
<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>
<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>
<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>
<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"
>
<span class="text-xs text-slate-400">网站</span>
<span class="text-xs font-medium text-brand truncate max-w-[140px]">{{ displayApp.website }}</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>
<span
class="text-xs font-medium text-slate-700 dark:text-slate-300 truncate max-w-[140px]"
>{{ displayApp.tags }}</span
>
</div>
</div>
</div>
@@ -223,7 +266,9 @@
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">
<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>
@@ -241,7 +286,9 @@
<!-- 截图展示 -->
<div v-if="screenshots.length">
<h3 class="text-base font-semibold text-slate-900 dark:text-white mb-3 flex items-center gap-2">
<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>
@@ -295,53 +342,92 @@
>
<i class="fas fa-xmark"></i>
</button>
<h3 class="text-lg font-semibold text-slate-900 dark:text-white mb-4 pr-8">
<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 rounded-xl bg-slate-50 p-4 dark:bg-slate-900/50 space-y-3">
<div
class="max-h-80 overflow-y-auto 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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<span
class="text-sm font-medium text-slate-800 dark:text-slate-200"
>{{ displayApp.origin === "spark" ? "Spark" : "APM" }}</span
>
</div>
</div>
</div>
@@ -525,5 +611,3 @@ const hideImage = (e: Event) => {
(e.target as HTMLElement).style.display = "none";
};
</script>

View File

@@ -10,10 +10,7 @@
@open-detail="$emit('open-detail', app)"
/>
</div>
<div
v-else
class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
>
<div v-else class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<div
v-for="n in 8"
:key="n"

View File

@@ -32,10 +32,18 @@
class="h-32 w-32 mb-6 opacity-90"
/>
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-200 mb-2">
{{ storeFilter === 'apm' ? '欢迎来到星火应用商店 (Amber PM)' : '欢迎来到星火应用商店' }}
{{
storeFilter === "apm"
? "欢迎来到星火应用商店 (Amber PM)"
: "欢迎来到星火应用商店"
}}
</h1>
<p class="text-sm text-slate-500 dark:text-slate-400">
{{ storeFilter === 'apm' ? '探索丰富的应用,发现更多精彩内容' : '探索丰富的应用,发现更多精彩内容' }}
{{
storeFilter === "apm"
? "探索丰富的应用,发现更多精彩内容"
: "探索丰富的应用,发现更多精彩内容"
}}
</p>
</div>
<!-- 有数据就立即展示图片逐步加载 -->
@@ -43,7 +51,11 @@
<!-- 左上角欢迎语 -->
<div class="mb-4">
<h1 class="text-xl font-bold text-slate-800 dark:text-slate-200">
{{ storeFilter === 'apm' ? '欢迎来到星火应用商店 (Amber PM)' : '欢迎来到星火应用商店' }}
{{
storeFilter === "apm"
? "欢迎来到星火应用商店 (Amber PM)"
: "欢迎来到星火应用商店"
}}
</h1>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">
探索丰富的应用发现更多精彩内容
@@ -61,29 +73,41 @@
:title="link.more as string"
>
<!-- 图片区域 - 850:400 比例 -->
<div class="relative w-full aspect-[850/400] overflow-hidden rounded-xl bg-slate-100 dark:bg-slate-800">
<div
class="relative w-full aspect-[850/400] overflow-hidden rounded-xl bg-slate-100 dark:bg-slate-800"
>
<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] }"
: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"
>
<div class="h-10 w-10 animate-pulse rounded-full bg-slate-200 dark:bg-slate-700"></div>
<div
class="h-10 w-10 animate-pulse rounded-full bg-slate-200 dark:bg-slate-700"
></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">
<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>
<div class="text-sm text-slate-500 dark:text-slate-400 mt-0.5 line-clamp-1">
<div
class="text-sm text-slate-500 dark:text-slate-400 mt-0.5 line-clamp-1"
>
{{ link.more }}
</div>
</div>
@@ -103,7 +127,7 @@
v-for="app in section.apps"
:key="app.pkgname"
:app="app"
@open-detail="$emit('open-detail', $event)"
@open-detail="handleOpenDetail(app)"
/>
</div>
</section>
@@ -126,10 +150,15 @@ defineProps<{
storeFilter?: "spark" | "apm" | "both";
}>();
defineEmits<{
const emit = defineEmits<{
(e: "open-detail", app: App | Record<string, unknown>): void;
}>();
// 处理应用卡片点击,添加来自首页的标记
const handleOpenDetail = (app: App) => {
emit("open-detail", { ...app, _fromHomeView: true });
};
// 图片加载状态跟踪
const imageLoaded = reactive<Record<string, boolean>>({});

View File

@@ -27,8 +27,7 @@ export const handleInstall = (appObj?: App) => {
if (
downloads.value.find(
(d) =>
d.pkgname === targetApp.pkgname && d.origin === targetApp.origin,
(d) => d.pkgname === targetApp.pkgname && d.origin === targetApp.origin,
)
) {
logger.info(
@@ -107,9 +106,7 @@ export const handleUpgrade = (app: App) => {
(d) => d.pkgname === app.pkgname && d.origin === app.origin,
)
) {
logger.info(
`任务已存在,忽略重复添加: ${app.pkgname} (${app.origin})`,
);
logger.info(`任务已存在,忽略重复添加: ${app.pkgname} (${app.origin})`);
return;
}