@@ -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 = "imageThumb Url"
:src = "bestMedia Url"
: 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'" >
<!-- 1 b . 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 = "m essage.file_u rl"
:src = "b estMediaU rl"
controls
preload = "metadata"
/ >
< span v-else class = "yk-msg__bubble" >
< el-icon > < VideoPlay / > < / el-icon > [ 视频 ]
< / div >
<!-- 1 c . 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" >
<!-- 1 d . 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 >