Files
yunke/yunkes/src/components/MessageBubble.vue
T
2026-08-13 14:02:42 +08:00

305 lines
12 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 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`, 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, 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
* 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
/**
* 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`, 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
})
/**
* Local-first media src — when the backend has localized the media blob
* (`message.media_hash` set by the `message_images` sync pass), render via
* the local `/api/media/{hash}` endpoint instead of hitting yunke OSS.
* Returns `undefined` when no localized copy exists yet; callers fall back
* to `bestMediaUrl` / `imageFullUrl` (the OSS URLs) so a not-yet-localized
* message still renders (degraded — talks to OSS).
*/
const localSrc = computed<string | undefined>(() => {
const hash = props.message.media_hash
return hash ? `/api/media/${hash}` : 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/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' | 'link' | undefined>(() => {
const u = bestMediaUrl.value
if (!u) return undefined
const ext = urlExt(u)
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'
return 'file'
})
/**
* Source for the rendered image / video / audio / file link. Local-first:
* when `media_hash` is present, serve the localized blob via
* `/api/media/{hash}`; otherwise fall back to the best OSS URL on the row
* (degraded — talks to yunke OSS directly). For images, click-to-zoom also
* uses the local URL when available (the localized blob is what we have).
*/
const mediaSrc = computed<string | undefined>(() => localSrc.value || bestMediaUrl.value)
/**
* 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.
* When a localized copy exists, the preview is the local URL (the localized
* blob is what we have on hand — typically the thumbnail, which is
* sufficient for chat-timeline display).
*/
const imageFullUrl = computed<string | undefined>(() => {
if (localSrc.value) return localSrc.value
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="mediaSrc"
: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="mediaSrc"
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="mediaSrc" 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="mediaSrc"
target="_blank"
rel="noopener"
>
<el-icon><Paperclip /></el-icon>
下载文件
</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">
<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).
`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">
<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>