From 72d107c9defb3255abfaa82ee57e1d4c862421d2 Mon Sep 17 00:00:00 2001 From: xiyidaiwa Date: Thu, 13 Aug 2026 22:30:38 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=88=AA=E5=9B=BE=E4=BB=A5=20img=5Furls?= =?UTF-8?q?=20=E4=B8=BA=E5=80=99=E9=80=89=E5=B9=B6=E6=8E=A2=E6=B5=8B?= =?UTF-8?q?=E8=BF=87=E6=BB=A4=20404=EF=BC=88=E8=A7=A3=E5=86=B3=E9=A3=9E?= =?UTF-8?q?=E4=B9=A6=E7=B1=BB=E8=84=8F=E6=95=B0=E6=8D=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实测飞书 bytedance-feishu-stable 的 app.json img_urls 声明 5 张 (screen_1~5),但 screen_4/5 实际 404(后端元数据声明与实际文件不一致) - 上一版(.4)直接采用 img_urls 会把 2 个 404 URL 塞进预览,仍出现空白页 - 改为:以 img_urls 为候选集合,对每个 URL 做 HEAD 存在性探测,仅保留真实可用的 (飞书过滤后只剩 3 张,微信 3 张保持 3 张)——既保证实际有几张预览几张,又兜底 后端脏数据;探测结果按 URL 内存缓存复用,同一应用反复打开不重复请求 - 恢复 abort 机制:打开新详情/关闭时取消旧探测,避免陈旧结果覆盖 - debian/changelog 测试版本号 5.2.1.5-test - 验证:curl 实测飞书 screen_4/5=404、微信 3 张全 200;vue-tsc 0 / eslint 0 / 打包成功 --- debian/changelog | 2 +- src/composables/useAppDetail.ts | 65 ++++++++++++++++++++++++++++----- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/debian/changelog b/debian/changelog index 49d10119..9c31ee1b 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,4 +1,4 @@ -spark-store (5.2.1.4-test) UNRELEASED; urgency=medium +spark-store (5.2.1.5-test) UNRELEASED; urgency=medium * Initial release. (Closes: #nnnn) diff --git a/src/composables/useAppDetail.ts b/src/composables/useAppDetail.ts index adedfdb6..2e38b232 100644 --- a/src/composables/useAppDetail.ts +++ b/src/composables/useAppDetail.ts @@ -34,6 +34,7 @@ import { buildReviewTags, } from "../modules/appIdentity"; import { loadFavoriteMetadataForDetail } from "./useFavorites"; +import { axiosInstance } from "./useHttp"; import type { ReviewTags } from "../global/typedefinition"; import type { Ref } from "vue"; @@ -54,6 +55,9 @@ const currentReviewTags = computed(() => { }); }); +// 截图探测请求的中断控制器:每次打开新详情前取消旧探测,避免结果覆盖当前应用 +let screenshotAbortController: AbortController | null = null; + // 从仓库获取应用详细信息的辅助函数 const fetchAppFromStore = async ( pkgname: string, @@ -329,8 +333,16 @@ const openDetail = async (app: App | OpenDetailInput) => { currentApp.value = finalApp; currentScreenIndex.value = 0; - // 截图直接采用后端 img_urls(已解析),同步赋值,详情页打开即显示正确数量 - loadScreenshots(displayAppForScreenshots); + // 截图以后端 img_urls 为候选并探测真实存在性;异步进行,详情页先打开不阻塞。 + // 每次打开取消上一轮探测,避免陈旧结果覆盖当前应用。 + if (screenshotAbortController) screenshotAbortController.abort(); + screenshotAbortController = new AbortController(); + loadScreenshots( + displayAppForScreenshots, + screenshotAbortController.signal, + ).catch(() => { + // 被取消或异常已在 loadScreenshots 内处理 + }); showModal.value = true; currentAppSparkInstalled.value = false; @@ -390,28 +402,63 @@ const checkAppInstalled = (app: App) => { // 截图来源直接采用后端 app.img_urls(权威真实列表,微信 3 张即返回 3 个 URL)。 // 无需前端再 HEAD 探测 screen_1~5.png —— 既消除每次打开详情的额外请求压力, // 又保证"实际有几张就预览几张",边界由 ScreenPreview 的 length 自动收敛。 -// 兼容 img_urls 可能为字符串形式 JSON(类型声明为 string[],运行时偶发字符串)。 -const loadScreenshots = (app: App) => { - let urls: string[] = []; +// 截图 URL 存在性缓存:按 URL 记忆探测结果(true=存在 / false=404 等无效), +// 避免同一应用反复打开详情时重复发起 HEAD 探测,降低服务器压力。 +const screenshotExistenceCache = new Map(); + +// 探测单张截图是否真实存在:HEAD 请求,超时 3s;命中缓存直接返回。 +const checkScreenshotExists = async ( + url: string, + signal?: AbortSignal, +): Promise => { + const cached = screenshotExistenceCache.get(url); + if (cached !== undefined) return cached; + try { + await axiosInstance.head(url, { timeout: 3000, signal }); + screenshotExistenceCache.set(url, true); + return true; + } catch { + screenshotExistenceCache.set(url, false); + return false; + } +}; + +// 截图来源以后端 img_urls 为候选(权威意图列表),但后端元数据可能"声明 N 张实存 M 张" +// (如飞书声明 5 张仅上传 3 张)。因此对候选逐一探测存在性,仅保留真实可用的 URL, +// 既保证"实际有几张就预览几张",又兜底过滤后端脏数据导致的空白页。 +// 探测结果经内存缓存复用,同一应用多次打开不再重复请求。 +const loadScreenshots = async (app: App, signal?: AbortSignal) => { const raw = app.img_urls as unknown; + let candidates: string[] = []; if (Array.isArray(raw)) { - urls = raw as string[]; + candidates = raw as string[]; } else if (typeof raw === "string" && raw.length > 0) { try { const parsed = JSON.parse(raw); - if (Array.isArray(parsed)) urls = parsed as string[]; + if (Array.isArray(parsed)) candidates = parsed as string[]; } catch { - urls = []; + candidates = []; } } - screenshots.value = urls.filter( + candidates = candidates.filter( (u): u is string => typeof u === "string" && u.length > 0, ); + + // 先按缓存/同步填充已知结果,未知项异步探测;避免详情页打开后长时间空白 + const results = await Promise.all( + candidates.map((url) => checkScreenshotExists(url, signal)), + ); + screenshots.value = candidates.filter((_, index) => results[index]); }; const closeDetail = () => { showModal.value = false; currentApp.value = null; + // 关闭详情时取消正在进行的截图探测,避免结果写入已关闭的旧应用 + if (screenshotAbortController) { + screenshotAbortController.abort(); + screenshotAbortController = null; + } }; const openScreenPreview = (index: number) => {