diff --git a/frontend/src/components/PkgLogo.vue b/frontend/src/components/PkgLogo.vue index 29ceb68..839ec77 100644 --- a/frontend/src/components/PkgLogo.vue +++ b/frontend/src/components/PkgLogo.vue @@ -57,11 +57,15 @@ const style = computed(() => { // - 其他主页 → 域名根路径 /favicon.ico // 加载失败一律回退首字母徽标 const imgOk = ref(false) +const actualSrc = ref('') + const faviconUrl = computed(() => { const hp = props.homepage if (!hp) return '' try { const u = new URL(hp) + // 过滤明显无效的 GitHub 子域名(如 epeli.github.com,正确应为 *.github.io) + if (u.hostname.endsWith('.github.com') && u.hostname !== 'www.github.com') return '' if (u.hostname === 'github.com' || u.hostname === 'www.github.com') { const m = hp.match(/^https?:\/\/(?:www\.)?github\.com\/([^/?#]+)\/[^/?#]+/) if (m) return `https://github.com/${m[1]}.png` @@ -71,15 +75,62 @@ const faviconUrl = computed(() => { return '' } }) -watch(faviconUrl, () => { imgOk.value = false }) + +// ===== 共享 URL 探测缓存(跨组件全局)===== +// 策略:用 fetch(no-cors) 预检代替直接 img.src。 +// fetch 的网络层失败(DNS/连接/超时)不会涌入控制台红色报错; +// 同 URL 所有组件实例共用一个 Promise,避免重复并发。 +// 404 等 no-cors 下 opaque resolve,由 img @error 兜底(少量且可控)。 +const _urlCache = new Map() // url -> { ok, expires } +const _urlPending = new Map() // url -> Promise +const TTL = 24 * 3600 * 1000 // 失败缓存 24h,避免反复探测已知的死链 +const PROBE_TIMEOUT = 8000 // 探测超时 8s + +function getUrlCache(url) { + const entry = _urlCache.get(url) + if (!entry) return null + if (entry.expires < Date.now()) { _urlCache.delete(url); return null } + return entry.ok +} + +async function probeUrl(url) { + const cached = getUrlCache(url) + if (cached !== null) return cached + if (_urlPending.has(url)) return _urlPending.get(url) + + const p = (async () => { + try { + const ctrl = new AbortController() + const t = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT) + await fetch(url, { mode: 'no-cors', signal: ctrl.signal, referrerPolicy: 'no-referrer' }) + clearTimeout(t) + _urlCache.set(url, { ok: true, expires: Date.now() + TTL }) + return true + } catch { + _urlCache.set(url, { ok: false, expires: Date.now() + TTL }) + return false + } + })() + + _urlPending.set(url, p) + try { return await p } finally { _urlPending.delete(url) } +} + +watch([() => inView.value, () => faviconUrl.value], async ([v, url]) => { + if (!v || !url) { actualSrc.value = ''; imgOk.value = false; return } + const ok = await probeUrl(url) + if (ok) actualSrc.value = url + else { actualSrc.value = ''; imgOk.value = false } +}, { immediate: true }) + function onImgOk() { imgOk.value = true } function onImgErr() { imgOk.value = false }