fix: render chat media by URL (images/video/voice) instead of unknown-type placeholder
MessageBubble.vue was keys off the (incomplete) type code, so many messages with real OSS media URLs (https://wx.oss.yunkecn.com/.../*.webp|*.mp4) showed as '[未知消息]'. Rendering is now URL-driven: pick the best http URL from file_url > file_hd_url > file_th_url, then render by extension (image/video/audio/file). Type code is only used as a last-resort label. - MessageBubble.vue: URL-driven cascade (URL by ext → duration → transcription → text → structured-type placeholder → localized tag). Image/video/audio/file children @click.stop so the all-fields drawer still opens on bubble click but not on media interactions. - format.ts: wechatTypeLabel(type_raw) maps known codes (3→图片, 34→语音, 43→图片/表情, 49→文件/名片, 42→名片, 9→表情, 20→红包/转账). - ChatTimeline.vue drawer type tag uses wechatTypeLabel for WeChat msgs. - style.css: compact .yk-msg__audio player for voice-with-URL messages. Verified against smoke.db: 25 type-3 images + 3 type-43 videos that previously showed '[未知消息]' now render as images/video. Build clean. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,22 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Microphone, VideoPlay, Document, Picture } from '@element-plus/icons-vue'
|
||||
import { Microphone, VideoPlay, Document, Picture, Paperclip } from '@element-plus/icons-vue'
|
||||
import type { Message } from '@/api/types'
|
||||
import { formatDuration, formatTime } from '@/utils/format'
|
||||
import { formatDuration, formatTime, wechatTypeLabel } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{ message: Message }>()
|
||||
|
||||
/**
|
||||
* One chat row. Layout/direction is driven by `is_send`; content by `type`.
|
||||
* Media URLs are rendered straight from the OSS `file_*_url` fields — the
|
||||
* `/api/media/{hash}` streaming endpoint exists for cases where the OSS URL is
|
||||
* missing, but the current MessageOut schema exposes `file_url` only, so we
|
||||
* degrade gracefully via `file_th_url || file_url` (forward-compatible).
|
||||
* One chat row. Layout/direction is driven by `is_send`; **content is
|
||||
* 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.
|
||||
*
|
||||
* Cascade (per task spec):
|
||||
* 1. http media URL (`file_url` > `file_hd_url` > `file_th_url`) → by ext:
|
||||
* image / video / audio / generic file (📎 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.).
|
||||
* 6. else → `[图片/表情 (type_raw)]` style tag using `wechatTypeLabel`.
|
||||
*
|
||||
* Clicking anywhere on the bubble (other than interactive children such as the
|
||||
* image preview or the file download link) emits `open-detail` so the parent
|
||||
* timeline can open the all-fields drawer for this message. The image preview
|
||||
* and download link stop propagation so their own interactions still work.
|
||||
* image preview, video, audio player, or file download link) emits
|
||||
* `open-detail` so the parent timeline can open the all-fields drawer. Those
|
||||
* interactive children `@click.stop` so their own interactions still work.
|
||||
*/
|
||||
const emit = defineEmits<{ (e: 'open-detail', message: Message): void }>()
|
||||
|
||||
@@ -33,11 +43,73 @@ const senderLabel = computed(
|
||||
|
||||
const timeLabel = computed(() => formatTime(props.message.timestamp))
|
||||
|
||||
// Image preview list for el-image (full-size click-to-zoom).
|
||||
const imageFullUrl = computed(() => props.message.file_hd_url || props.message.file_url)
|
||||
const imageThumbUrl = computed(
|
||||
() => props.message.file_th_url || props.message.file_url,
|
||||
)
|
||||
const HTTP_RE = /^https?:\/\//i
|
||||
|
||||
/**
|
||||
* 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>`.
|
||||
*/
|
||||
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
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'webp', 'png', 'gif', 'bmp', 'tif', 'tiff'])
|
||||
const VIDEO_EXTS = new Set(['mp4', 'mov', 'webm', 'm4v', 'avi', 'mkv'])
|
||||
const AUDIO_EXTS = new Set(['mp3', 'amr', 'silk', 'm4a', 'aac', 'wav', 'ogg'])
|
||||
|
||||
/** Lowercase extension of the URL path (before ?/#); null when none. */
|
||||
function urlExt(url: string): string | null {
|
||||
const path = url.split(/[?#]/)[0].replace(/\/+$/, '')
|
||||
const base = path.split('/').pop() || ''
|
||||
const i = base.lastIndexOf('.')
|
||||
return i >= 1 ? base.slice(i + 1).toLowerCase() : 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.
|
||||
*/
|
||||
const mediaKind = computed<'image' | 'video' | 'audio' | 'file' | undefined>(() => {
|
||||
const u = bestMediaUrl.value
|
||||
if (!u) return undefined
|
||||
const ext = urlExt(u)
|
||||
if (!ext) return undefined
|
||||
if (IMAGE_EXTS.has(ext)) return 'image'
|
||||
if (VIDEO_EXTS.has(ext)) return 'video'
|
||||
if (AUDIO_EXTS.has(ext)) return 'audio'
|
||||
return 'file'
|
||||
})
|
||||
|
||||
/**
|
||||
* Image preview src list — prefer the highest-fidelity http URL available so
|
||||
* click-to-zoom shows the best copy. Falls back to the displayed thumb URL.
|
||||
*/
|
||||
const imageFullUrl = computed<string | undefined>(() => {
|
||||
const m = props.message
|
||||
for (const u of [m.file_hd_url, m.file_url, m.file_th_url]) {
|
||||
if (u && HTTP_RE.test(u)) return u
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
/**
|
||||
* Localized type tag for the unknown-fallback bubble and the drawer. WeChat
|
||||
* uses `wechatTypeLabel(type_raw)`; WhatsApp type codes (0/1) are already
|
||||
* covered by the structured `type` field and don't need a label map here.
|
||||
*/
|
||||
const typeTagLabel = computed(() => {
|
||||
const m = props.message
|
||||
if (m.channel === 'wechat') return wechatTypeLabel(m.type_raw)
|
||||
return m.type || `未知${m.type_raw != null ? `(${m.type_raw})` : ''}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -53,80 +125,102 @@ const imageThumbUrl = computed(
|
||||
{{ message.text || (message.type === 'divider' ? '—— 分割线 ——' : '系统消息') }}
|
||||
</div>
|
||||
|
||||
<!-- Normal bubble row — click opens the all-fields drawer. The image
|
||||
preview + file download link stop propagation so their own clicks
|
||||
(zoom / navigate) don't also open the drawer. -->
|
||||
<!-- Normal bubble row — click opens the all-fields drawer. Interactive
|
||||
children (image / video / audio / file link) stop propagation so
|
||||
their own clicks don't also open the drawer. -->
|
||||
<div v-else class="yk-msg__row yk-msg__row--clickable" @click="emit('open-detail', message)">
|
||||
<div class="yk-msg__meta">
|
||||
<span>{{ senderLabel }}</span>
|
||||
<span v-if="timeLabel">{{ timeLabel }}</span>
|
||||
</div>
|
||||
|
||||
<!-- text -->
|
||||
<div v-if="message.type === 'text'" class="yk-msg__bubble">
|
||||
{{ message.text || '(空消息)' }}
|
||||
</div>
|
||||
|
||||
<!-- image -->
|
||||
<div v-else-if="message.type === 'image'" @click.stop>
|
||||
<!-- 1a. image (http URL with image extension) -->
|
||||
<div v-if="mediaKind === 'image'" @click.stop>
|
||||
<el-image
|
||||
v-if="imageThumbUrl"
|
||||
class="yk-msg__image"
|
||||
:src="imageThumbUrl"
|
||||
:src="bestMediaUrl"
|
||||
:preview-src-list="imageFullUrl ? [imageFullUrl] : []"
|
||||
:preview-teleported="true"
|
||||
fit="cover"
|
||||
hide-on-click-modal
|
||||
/>
|
||||
<span v-else class="yk-msg__bubble">[图片]</span>
|
||||
</div>
|
||||
|
||||
<!-- video -->
|
||||
<div v-else-if="message.type === 'video'">
|
||||
<!-- 1b. video (http URL with video extension) -->
|
||||
<div v-else-if="mediaKind === 'video'" @click.stop>
|
||||
<video
|
||||
v-if="message.file_url"
|
||||
class="yk-msg__video"
|
||||
:src="message.file_url"
|
||||
:src="bestMediaUrl"
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
<span v-else class="yk-msg__bubble">
|
||||
<el-icon><VideoPlay /></el-icon> [视频]
|
||||
</div>
|
||||
|
||||
<!-- 1c. audio (http URL with audio extension) — native player + chip
|
||||
showing the duration when the message carries one. -->
|
||||
<div v-else-if="mediaKind === 'audio'" class="yk-msg__voice" @click.stop>
|
||||
<audio class="yk-msg__audio" :src="bestMediaUrl" controls preload="metadata" />
|
||||
<span v-if="message.duration" class="yk-msg__chip">
|
||||
<el-icon><Microphone /></el-icon>
|
||||
{{ formatDuration(message.duration) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- voice -->
|
||||
<div v-else-if="message.type === 'voice'" class="yk-msg__voice">
|
||||
<!-- 1d. generic file (http URL, other extension) — download link -->
|
||||
<div v-else-if="mediaKind === 'file'" @click.stop>
|
||||
<a
|
||||
class="yk-msg__chip yk-msg__chip--download"
|
||||
:href="bestMediaUrl"
|
||||
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">
|
||||
<el-icon><Microphone /></el-icon>
|
||||
语音{{ message.duration ? ` (${formatDuration(message.duration)})` : '' }}
|
||||
语音 ({{ formatDuration(message.duration) }})
|
||||
</span>
|
||||
<div v-if="message.transcription_text" class="yk-msg__transcript">
|
||||
{{ message.transcription_text }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- file -->
|
||||
<div v-else-if="message.type === 'file'" @click.stop>
|
||||
<a
|
||||
v-if="message.file_url"
|
||||
class="yk-msg__chip yk-msg__chip--download"
|
||||
:href="message.file_url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<el-icon><Document /></el-icon>
|
||||
下载文件
|
||||
</a>
|
||||
<span v-else class="yk-msg__bubble">
|
||||
<el-icon><Document /></el-icon> [文件]
|
||||
</span>
|
||||
<!-- 3. transcription text only -->
|
||||
<div v-else-if="message.transcription_text" class="yk-msg__bubble">
|
||||
{{ message.transcription_text }}
|
||||
</div>
|
||||
|
||||
<!-- unknown / fallback -->
|
||||
<!-- 4. plain text -->
|
||||
<div v-else-if="message.text || message.type === 'text'" class="yk-msg__bubble">
|
||||
{{ message.text || '(空消息)' }}
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<el-icon><Picture /></el-icon> [图片]
|
||||
</span>
|
||||
<span v-else-if="message.type === 'video'" class="yk-msg__bubble">
|
||||
<el-icon><VideoPlay /></el-icon> [视频]
|
||||
</span>
|
||||
<span v-else-if="message.type === 'voice'" class="yk-msg__bubble">
|
||||
<el-icon><Microphone /></el-icon> [语音]
|
||||
</span>
|
||||
<span v-else-if="message.type === 'file'" class="yk-msg__bubble">
|
||||
<el-icon><Document /></el-icon> [文件]
|
||||
</span>
|
||||
|
||||
<!-- 6. unknown fallback — show localized type tag instead of bare
|
||||
"[未知消息]" so the user at least sees 图片/语音/名片/etc. -->
|
||||
<div v-else class="yk-msg__bubble">
|
||||
<el-icon v-if="message.type === 'unknown'"><Picture /></el-icon>
|
||||
{{ message.text || `[未知消息${message.type_raw != null ? ` (${message.type_raw})` : ''}]` }}
|
||||
<el-icon><Picture /></el-icon>
|
||||
[{{ typeTagLabel }}]
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user