+
+
+
+ {{ snsMediaStageLabel(snsMediaStageKey(post.id, idx, 'thumb')) || '识别中' }}
+
+
图片失败
@@ -816,6 +854,135 @@ const onMediaError = (postId, idx) => {
mediaErrors.value[mediaErrorKey(postId, idx)] = true
}
+// Hover badge: show which SNS media pipeline stage produced the image.
+// Backend provides `X-SNS-Source` (and optional `X-SNS-Hit-Type`, `X-SNS-X-Enc`) on `/api/sns/media` responses.
+const snsMediaStage = ref({}) // stageKey -> { source, hitType, xEnc }
+const snsMediaStageLoading = ref({}) // stageKey -> boolean
+const snsMediaStageInFlight = new Set()
+
+const isSnsMediaApiUrl = (url) => {
+ const u = String(url || '').trim()
+ return !!u && u.includes('/api/sns/media')
+}
+
+const snsMediaStageKey = (postId, idx, kind = 'thumb') => {
+ const acc = String(selectedAccount.value || '').trim()
+ const pid = String(postId || '').trim()
+ return `sns:${acc}:${pid}:${String(Number(idx) || 0)}:${String(kind || 'thumb')}`
+}
+
+const snsCoverStageKey = (cover) => {
+ const acc = String(selectedAccount.value || '').trim()
+ const cid = String(cover?.id || cover?.tid || cover?.createTime || '').trim()
+ return `sns:${acc}:cover:${cid || '0'}`
+}
+
+const snsMediaStageLabel = (key) => {
+ const k = String(key || '').trim()
+ if (!k) return ''
+ const info = snsMediaStage.value[k]
+ if (!info || typeof info !== 'object') return ''
+
+ const source = String(info?.source || '').trim()
+ const hitType = String(info?.hitType || '').trim()
+
+ if (source === 'remote-cache') return '远程缓存'
+ if (source === 'remote-decrypt') return '远程解密'
+ if (source === 'remote') return '远程直出'
+ if (source === 'deterministic-hash') return hitType ? `本地命中(${hitType})` : '本地命中'
+ if (source === 'manual-pick') return '手动匹配'
+ if (source === 'local-heuristic') return '本地兜底'
+ if (source === 'local-heuristic-next') return '本地兜底(跳过)'
+ if (source === 'bkg-cover') return '封面缓存'
+ if (source === 'proxy') return '远程代理'
+ if (source === 'unknown') return '未知'
+ if (source === 'error') return '获取失败'
+ return source || '未知'
+}
+
+const snsMediaStageBadgeColorClass = (key) => {
+ const k = String(key || '').trim()
+ const source = String(snsMediaStage.value?.[k]?.source || '').trim()
+
+ if (source.startsWith('remote')) return 'bg-emerald-600/85 text-white'
+ if (source === 'deterministic-hash') return 'bg-sky-600/85 text-white'
+ if (source.startsWith('local')) return 'bg-blue-600/85 text-white'
+ if (source === 'manual-pick') return 'bg-amber-600/90 text-white'
+ if (source === 'proxy') return 'bg-fuchsia-600/85 text-white'
+ if (source === 'bkg-cover') return 'bg-indigo-600/85 text-white'
+ if (source === 'error') return 'bg-red-600/85 text-white'
+ return 'bg-black/50 text-white'
+}
+
+const snsMediaStageBadgeTitle = (key) => {
+ const k = String(key || '').trim()
+ const info = snsMediaStage.value?.[k]
+ if (!info || typeof info !== 'object') return ''
+ const source = String(info?.source || '').trim()
+ const hitType = String(info?.hitType || '').trim()
+ const xEnc = String(info?.xEnc || '').trim()
+
+ const parts = []
+ if (source) parts.push(`source=${source}`)
+ if (hitType) parts.push(`hit=${hitType}`)
+ if (xEnc) parts.push(`x-enc=${xEnc}`)
+ return parts.join(' · ')
+}
+
+const ensureSnsMediaStage = async (key, url) => {
+ if (!process.client) return
+ const k = String(key || '').trim()
+ const u = String(url || '').trim()
+ if (!k || !u) return
+ if (!isSnsMediaApiUrl(u)) return
+
+ if (snsMediaStage.value[k]) return
+ if (snsMediaStageLoading.value[k]) return
+ if (snsMediaStageInFlight.has(k)) return
+
+ snsMediaStageInFlight.add(k)
+ snsMediaStageLoading.value[k] = true
+
+ try {
+ const resp = await fetch(u, { method: 'GET', mode: 'cors', cache: 'force-cache' })
+ const source = String(resp.headers.get('X-SNS-Source') || '').trim() || 'unknown'
+ const hitType = String(resp.headers.get('X-SNS-Hit-Type') || '').trim()
+ const xEnc = String(resp.headers.get('X-SNS-X-Enc') || '').trim()
+
+ snsMediaStage.value[k] = { source, hitType, xEnc }
+
+ try {
+ resp.body?.cancel?.()
+ } catch {}
+ } catch {
+ snsMediaStage.value[k] = { source: 'error', hitType: '', xEnc: '' }
+ } finally {
+ snsMediaStageLoading.value[k] = false
+ snsMediaStageInFlight.delete(k)
+ }
+}
+
+const onSnsMediaHover = (post, m, idx = 0) => {
+ const pid = String(post?.id || '').trim()
+ if (!pid) return
+ const key = snsMediaStageKey(pid, idx, 'thumb')
+ const u = getMediaThumbSrc(post, m, idx)
+ ensureSnsMediaStage(key, u)
+}
+
+const onCoverMediaHover = () => {
+ const c = activeCover.value
+ if (!c || !Array.isArray(c.media) || c.media.length <= 0) return
+ const u = getSnsMediaUrl(c, c.media[0], 0, c.media[0].url)
+ ensureSnsMediaStage(snsCoverStageKey(c), u)
+}
+
+watch([selectedAccount, snsUseCache], () => {
+ snsMediaStage.value = {}
+ snsMediaStageLoading.value = {}
+ snsMediaStageInFlight.clear()
+})
+
// Article card thumbnail is best-effort: try SNS media thumb first, then fall back to
// extracting the cover from mp.weixin.qq.com HTML. Track per-post stage so we don't
// keep showing a broken
![]()
.
diff --git a/src/wechat_decrypt_tool/api.py b/src/wechat_decrypt_tool/api.py
index 9fb6729..ef5112f 100644
--- a/src/wechat_decrypt_tool/api.py
+++ b/src/wechat_decrypt_tool/api.py
@@ -47,6 +47,7 @@ app.add_middleware(
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
+ expose_headers=["X-SNS-Source", "X-SNS-Hit-Type", "X-SNS-X-Enc"],
)
app.include_router(_health_router)
diff --git a/src/wechat_decrypt_tool/routers/sns.py b/src/wechat_decrypt_tool/routers/sns.py
index fc93776..f7a2771 100644
--- a/src/wechat_decrypt_tool/routers/sns.py
+++ b/src/wechat_decrypt_tool/routers/sns.py
@@ -2749,7 +2749,7 @@ async def get_sns_media(
print(f"===== Hit Bkg Cover ======= {bkg_path}")
return FileResponse(bkg_path, media_type="image/jpeg",
- headers={"Cache-Control": "public, max-age=31536000"})
+ headers={"Cache-Control": "public, max-age=31536000", "X-SNS-Source": "bkg-cover"})
exact_match_path = None
hit_type = ""
@@ -2807,6 +2807,7 @@ async def get_sns_media(
if payload and str(media_type or "").startswith("image/"):
resp = Response(content=payload, media_type=str(media_type or "image/jpeg"))
resp.headers["Cache-Control"] = "public, max-age=86400"
+ resp.headers["X-SNS-Source"] = "manual-pick"
return resp
except Exception:
pass
@@ -2850,6 +2851,7 @@ async def get_sns_media(
if payload and str(media_type or "").startswith("image/"):
resp = Response(content=payload, media_type=str(media_type or "image/jpeg"))
resp.headers["Cache-Control"] = "public, max-age=86400"
+ resp.headers["X-SNS-Source"] = "local-heuristic"
return resp
except Exception:
pass
@@ -2881,6 +2883,7 @@ async def get_sns_media(
if payload and str(media_type or "").startswith("image/"):
resp = Response(content=payload, media_type=str(media_type or "image/jpeg"))
resp.headers["Cache-Control"] = "public, max-age=86400"
+ resp.headers["X-SNS-Source"] = "local-heuristic-next"
return resp
except Exception:
continue
@@ -2894,7 +2897,12 @@ async def get_sns_media(
from .chat_media import proxy_image # pylint: disable=import-outside-toplevel
try:
- return await proxy_image(u)
+ resp0 = await proxy_image(u)
+ try:
+ resp0.headers["X-SNS-Source"] = "proxy"
+ except Exception:
+ pass
+ return resp0
except HTTPException:
raise
except Exception as e: