fix(media): image_legacy渲染+下载覆盖+WA raw_json URL回退
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -11,16 +11,21 @@ const props = defineProps<{ message: Message }>()
|
||||
* URL-driven, not type-code-driven** — many upstream WeChat messages carry a
|
||||
* numeric `type_raw` (3=image_legacy, 43=图片/表情, ...) that the legacy code
|
||||
* mapped to the `unknown` bucket even though their `file_*_url` points to a
|
||||
* perfectly good OSS asset. We now render by media-URL presence + extension
|
||||
* first, and only fall back to the type code as a last resort.
|
||||
* perfectly good OSS asset. We render by media-URL presence + extension first,
|
||||
* and only fall back to the type code as a last resort. `image_legacy` rows
|
||||
* (smoke.db 26 rows with valid `wx.oss.yunkecn.com` OSS URLs) hit the same
|
||||
* image branch as `image` because the URL cascade is type-agnostic.
|
||||
*
|
||||
* Cascade (per task spec):
|
||||
* 1. http media URL (`file_url` > `file_hd_url` > `file_th_url`) → by ext:
|
||||
* image / video / audio / generic file (📎 link).
|
||||
* 1. http media URL (`file_url` > `file_hd_url` > `file_th_url`, then
|
||||
* `raw_json.{ossFileName,fileUrl,mediaUrl,url}` as a WA fallback) → by ext:
|
||||
* image / video / audio / generic file (📎 download); http URL with NO
|
||||
* recognized media extension → 📎 link (invite / support.weixin.qq.com /
|
||||
* unknown-type rows whose file_url is a share link).
|
||||
* 2. `duration` present (voice w/o URL) → voice chip.
|
||||
* 3. `transcription_text` → text bubble.
|
||||
* 4. `text` present → text bubble.
|
||||
* 5. structured `type` placeholder (WA image w/o URL, etc.).
|
||||
* 5. structured `type` placeholder (WA image w/o URL, image_legacy w/o URL).
|
||||
* 6. else → `[图片/表情 (type_raw)]` style tag using `wechatTypeLabel`.
|
||||
*
|
||||
* Clicking anywhere on the bubble (other than interactive children such as the
|
||||
@@ -45,17 +50,39 @@ const timeLabel = computed(() => formatTime(props.message.timestamp))
|
||||
|
||||
const HTTP_RE = /^https?:\/\//i
|
||||
|
||||
/**
|
||||
* Raw-json keys that may carry a downloadable http URL when the structured
|
||||
* `file_*_url` columns don't. WA messages store their media as a bare
|
||||
* `mediaName` filename (not http), so this fallback rarely resolves WA images
|
||||
* today — but upstream shapes drift, and some rows (e.g. WA media that has
|
||||
* been re-hosted on the OSS bucket) do surface an http URL on these raw_json
|
||||
* fields. Checking them is cheap and future-proofs the resolver.
|
||||
*/
|
||||
const RAW_URL_KEYS = ['ossFileName', 'fileUrl', 'mediaUrl', 'url'] as const
|
||||
|
||||
/**
|
||||
* Best http media URL on the message, honoring the precedence
|
||||
* `file_url` > `file_hd_url` > `file_th_url`. Non-http values (e.g. type-3
|
||||
* opaque ids, WA `mediaName` filenames) are skipped so they fall through to
|
||||
* the text/type fallbacks instead of producing broken `<img src>`.
|
||||
* `file_url` > `file_hd_url` > `file_th_url`, then a `raw_json` fallback that
|
||||
* scans the camelCase media fields upstream sometimes populates. Non-http
|
||||
* values (e.g. type-3 opaque ids, WA `mediaName` filenames) are skipped so
|
||||
* they fall through to the text/type fallbacks instead of producing broken
|
||||
* `<img src>`.
|
||||
*/
|
||||
const bestMediaUrl = computed<string | undefined>(() => {
|
||||
const m = props.message
|
||||
for (const u of [m.file_url, m.file_hd_url, m.file_th_url]) {
|
||||
if (u && HTTP_RE.test(u)) return u
|
||||
}
|
||||
// WA / raw_json fallback: scan known media-bearing raw fields for any http
|
||||
// URL. WA images today carry only a bare filename (mediaName) and won't
|
||||
// resolve here, but rows whose media has been re-hosted upstream will.
|
||||
const raw = m.raw_json
|
||||
if (raw) {
|
||||
for (const key of RAW_URL_KEYS) {
|
||||
const v = raw[key]
|
||||
if (typeof v === 'string' && HTTP_RE.test(v)) return v
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
@@ -85,16 +112,21 @@ function urlExt(url: string): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify `bestMediaUrl` into image/video/audio/file. Returns null when there
|
||||
* is no URL OR the URL has no extension (e.g. invite links like
|
||||
* `support.weixin.qq.com/.../addchatroombyinvite?ticket=...`) — those aren't
|
||||
* media files and should fall through to text rendering.
|
||||
* Classify `bestMediaUrl` into image/video/audio/file/link. Returns:
|
||||
* - `image|video|audio|file` when the URL carries a recognized media ext;
|
||||
* - `link` when there IS an http URL but no recognized media extension
|
||||
* (e.g. invite links `support.weixin.qq.com/.../addchatroombyinvite?...`,
|
||||
* or `unknown`-type messages whose `file_url` is a doc/share link) —
|
||||
* rendered as a clickable anchor so the user can follow it instead of the
|
||||
* media being silently hidden;
|
||||
* - `undefined` when there is no http URL at all (falls through to text /
|
||||
* type-code placeholder).
|
||||
*/
|
||||
const mediaKind = computed<'image' | 'video' | 'audio' | 'file' | undefined>(() => {
|
||||
const mediaKind = computed<'image' | 'video' | 'audio' | 'file' | 'link' | undefined>(() => {
|
||||
const u = bestMediaUrl.value
|
||||
if (!u) return undefined
|
||||
const ext = urlExt(u)
|
||||
if (!ext) return undefined
|
||||
if (!ext) return 'link'
|
||||
if (IMAGE_EXTS.has(ext)) return 'image'
|
||||
if (VIDEO_EXTS.has(ext)) return 'video'
|
||||
if (AUDIO_EXTS.has(ext)) return 'audio'
|
||||
@@ -205,6 +237,23 @@ const typeTagLabel = computed(() => {
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 1e. link (http URL with no recognizable media extension — e.g.
|
||||
invite links, support.weixin.qq.com doc/share URLs on `unknown`
|
||||
messages). Render as a clickable anchor so the user can follow it
|
||||
instead of the URL being silently hidden behind the unknown-tag
|
||||
fallback. -->
|
||||
<div v-else-if="mediaKind === 'link'" @click.stop>
|
||||
<a
|
||||
class="yk-msg__chip yk-msg__chip--download"
|
||||
:href="mediaSrc"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<el-icon><Paperclip /></el-icon>
|
||||
打开链接
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 2. voice with duration but no URL (e.g. type 34 with opaque id) -->
|
||||
<div v-else-if="message.duration" class="yk-msg__voice">
|
||||
<span class="yk-msg__chip">
|
||||
@@ -228,8 +277,10 @@ const typeTagLabel = computed(() => {
|
||||
|
||||
<!-- 5. structured-type placeholder fallback (e.g. WA image whose URL is
|
||||
a mediaName filename, not an http URL — can't render without the
|
||||
download endpoint, so degrade to a labeled placeholder). -->
|
||||
<span v-else-if="message.type === 'image'" class="yk-msg__bubble">
|
||||
download endpoint, so degrade to a labeled placeholder).
|
||||
`image_legacy` is included here so a legacy WeChat type-3 row whose
|
||||
OSS URL somehow didn't resolve still shows the [图片] tag. -->
|
||||
<span v-else-if="message.type === 'image' || message.type === 'image_legacy'" class="yk-msg__bubble">
|
||||
<el-icon><Picture /></el-icon> [图片]
|
||||
</span>
|
||||
<span v-else-if="message.type === 'video'" class="yk-msg__bubble">
|
||||
|
||||
Reference in New Issue
Block a user