Files
yunke/yunkes/src/components/MessageBubble.vue
T
浅念 8fde1d8597 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>
2026-08-05 10:23:01 +08:00

228 lines
8.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed } from 'vue'
import { Microphone, VideoPlay, Document, Picture, Paperclip } from '@element-plus/icons-vue'
import type { Message } from '@/api/types'
import { formatDuration, formatTime, wechatTypeLabel } from '@/utils/format'
const props = defineProps<{ message: Message }>()
/**
* 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, 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 }>()
const isSend = computed(() => !!props.message.is_send)
// Centered (system/divider) rows ignore direction.
const isCentered = computed(
() => props.message.type === 'divider' || props.message.type === 'system',
)
const senderLabel = computed(
() => props.message.sender_name || (isSend.value ? '我方' : '对方'),
)
const timeLabel = computed(() => formatTime(props.message.timestamp))
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>
<div
class="yk-msg"
:class="{
'yk-msg--send': isSend && !isCentered,
'yk-msg--center': isCentered,
}"
>
<!-- Centered: divider / system — not clickable. -->
<div v-if="isCentered" class="yk-msg__divider">
{{ message.text || (message.type === 'divider' ? '—— 分割线 ——' : '系统消息') }}
</div>
<!-- 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>
<!-- 1a. image (http URL with image extension) -->
<div v-if="mediaKind === 'image'" @click.stop>
<el-image
class="yk-msg__image"
:src="bestMediaUrl"
:preview-src-list="imageFullUrl ? [imageFullUrl] : []"
:preview-teleported="true"
fit="cover"
hide-on-click-modal
/>
</div>
<!-- 1b. video (http URL with video extension) -->
<div v-else-if="mediaKind === 'video'" @click.stop>
<video
class="yk-msg__video"
:src="bestMediaUrl"
controls
preload="metadata"
/>
</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>
<!-- 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>
语音 ({{ formatDuration(message.duration) }})
</span>
<div v-if="message.transcription_text" class="yk-msg__transcript">
{{ message.transcription_text }}
</div>
</div>
<!-- 3. transcription text only -->
<div v-else-if="message.transcription_text" class="yk-msg__bubble">
{{ message.transcription_text }}
</div>
<!-- 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><Picture /></el-icon>
[{{ typeTagLabel }}]
</div>
</div>
</div>
</template>