2026-08-01 16:27:22 +08:00
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { computed } from 'vue'
|
2026-08-05 10:23:01 +08:00
|
|
|
|
import { Microphone, VideoPlay, Document, Picture, Paperclip } from '@element-plus/icons-vue'
|
2026-08-01 16:27:22 +08:00
|
|
|
|
import type { Message } from '@/api/types'
|
2026-08-05 10:23:01 +08:00
|
|
|
|
import { formatDuration, formatTime, wechatTypeLabel } from '@/utils/format'
|
2026-08-01 16:27:22 +08:00
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{ message: Message }>()
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-08-05 10:23:01 +08:00
|
|
|
|
* 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
|
2026-08-13 14:02:42 +08:00
|
|
|
|
* 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.
|
2026-08-05 10:23:01 +08:00
|
|
|
|
*
|
|
|
|
|
|
* Cascade (per task spec):
|
2026-08-13 14:02:42 +08:00
|
|
|
|
* 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).
|
2026-08-05 10:23:01 +08:00
|
|
|
|
* 2. `duration` present (voice w/o URL) → voice chip.
|
|
|
|
|
|
* 3. `transcription_text` → text bubble.
|
|
|
|
|
|
* 4. `text` present → text bubble.
|
2026-08-13 14:02:42 +08:00
|
|
|
|
* 5. structured `type` placeholder (WA image w/o URL, image_legacy w/o URL).
|
2026-08-05 10:23:01 +08:00
|
|
|
|
* 6. else → `[图片/表情 (type_raw)]` style tag using `wechatTypeLabel`.
|
2026-08-01 17:47:50 +08:00
|
|
|
|
*
|
|
|
|
|
|
* Clicking anywhere on the bubble (other than interactive children such as the
|
2026-08-05 10:23:01 +08:00
|
|
|
|
* 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.
|
2026-08-01 16:27:22 +08:00
|
|
|
|
*/
|
2026-08-01 17:47:50 +08:00
|
|
|
|
const emit = defineEmits<{ (e: 'open-detail', message: Message): void }>()
|
|
|
|
|
|
|
2026-08-01 16:27:22 +08:00
|
|
|
|
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))
|
|
|
|
|
|
|
2026-08-05 10:23:01 +08:00
|
|
|
|
const HTTP_RE = /^https?:\/\//i
|
|
|
|
|
|
|
2026-08-13 14:02:42 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 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
|
|
|
|
|
|
|
2026-08-05 10:23:01 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* Best http media URL on the message, honoring the precedence
|
2026-08-13 14:02:42 +08:00
|
|
|
|
* `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>`.
|
2026-08-05 10:23:01 +08:00
|
|
|
|
*/
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-08-13 14:02:42 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-05 10:23:01 +08:00
|
|
|
|
return undefined
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-11 18:24:11 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 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
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-05 10:23:01 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-08-13 14:02:42 +08:00
|
|
|
|
* 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).
|
2026-08-05 10:23:01 +08:00
|
|
|
|
*/
|
2026-08-13 14:02:42 +08:00
|
|
|
|
const mediaKind = computed<'image' | 'video' | 'audio' | 'file' | 'link' | undefined>(() => {
|
2026-08-05 10:23:01 +08:00
|
|
|
|
const u = bestMediaUrl.value
|
|
|
|
|
|
if (!u) return undefined
|
|
|
|
|
|
const ext = urlExt(u)
|
2026-08-13 14:02:42 +08:00
|
|
|
|
if (!ext) return 'link'
|
2026-08-05 10:23:01 +08:00
|
|
|
|
if (IMAGE_EXTS.has(ext)) return 'image'
|
|
|
|
|
|
if (VIDEO_EXTS.has(ext)) return 'video'
|
|
|
|
|
|
if (AUDIO_EXTS.has(ext)) return 'audio'
|
|
|
|
|
|
return 'file'
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-11 18:24:11 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 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)
|
|
|
|
|
|
|
2026-08-05 10:23:01 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 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.
|
2026-08-11 18:24:11 +08:00
|
|
|
|
* 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).
|
2026-08-05 10:23:01 +08:00
|
|
|
|
*/
|
|
|
|
|
|
const imageFullUrl = computed<string | undefined>(() => {
|
2026-08-11 18:24:11 +08:00
|
|
|
|
if (localSrc.value) return localSrc.value
|
2026-08-05 10:23:01 +08:00
|
|
|
|
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})` : ''}`
|
|
|
|
|
|
})
|
2026-08-01 16:27:22 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="yk-msg"
|
|
|
|
|
|
:class="{
|
|
|
|
|
|
'yk-msg--send': isSend && !isCentered,
|
|
|
|
|
|
'yk-msg--center': isCentered,
|
|
|
|
|
|
}"
|
|
|
|
|
|
>
|
2026-08-01 17:47:50 +08:00
|
|
|
|
<!-- Centered: divider / system — not clickable. -->
|
2026-08-01 16:27:22 +08:00
|
|
|
|
<div v-if="isCentered" class="yk-msg__divider">
|
|
|
|
|
|
{{ message.text || (message.type === 'divider' ? '—— 分割线 ——' : '系统消息') }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-05 10:23:01 +08:00
|
|
|
|
<!-- 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. -->
|
2026-08-01 17:47:50 +08:00
|
|
|
|
<div v-else class="yk-msg__row yk-msg__row--clickable" @click="emit('open-detail', message)">
|
2026-08-01 16:27:22 +08:00
|
|
|
|
<div class="yk-msg__meta">
|
|
|
|
|
|
<span>{{ senderLabel }}</span>
|
|
|
|
|
|
<span v-if="timeLabel">{{ timeLabel }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-05 10:23:01 +08:00
|
|
|
|
<!-- 1a. image (http URL with image extension) -->
|
|
|
|
|
|
<div v-if="mediaKind === 'image'" @click.stop>
|
2026-08-01 16:27:22 +08:00
|
|
|
|
<el-image
|
|
|
|
|
|
class="yk-msg__image"
|
2026-08-11 18:24:11 +08:00
|
|
|
|
:src="mediaSrc"
|
2026-08-01 16:27:22 +08:00
|
|
|
|
:preview-src-list="imageFullUrl ? [imageFullUrl] : []"
|
|
|
|
|
|
:preview-teleported="true"
|
|
|
|
|
|
fit="cover"
|
|
|
|
|
|
hide-on-click-modal
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-05 10:23:01 +08:00
|
|
|
|
<!-- 1b. video (http URL with video extension) -->
|
|
|
|
|
|
<div v-else-if="mediaKind === 'video'" @click.stop>
|
2026-08-01 16:27:22 +08:00
|
|
|
|
<video
|
|
|
|
|
|
class="yk-msg__video"
|
2026-08-11 18:24:11 +08:00
|
|
|
|
:src="mediaSrc"
|
2026-08-01 16:27:22 +08:00
|
|
|
|
controls
|
|
|
|
|
|
preload="metadata"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-05 10:23:01 +08:00
|
|
|
|
<!-- 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>
|
2026-08-11 18:24:11 +08:00
|
|
|
|
<audio class="yk-msg__audio" :src="mediaSrc" controls preload="metadata" />
|
2026-08-05 10:23:01 +08:00
|
|
|
|
<span v-if="message.duration" class="yk-msg__chip">
|
2026-08-01 16:27:22 +08:00
|
|
|
|
<el-icon><Microphone /></el-icon>
|
2026-08-05 10:23:01 +08:00
|
|
|
|
{{ formatDuration(message.duration) }}
|
2026-08-01 16:27:22 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-05 10:23:01 +08:00
|
|
|
|
<!-- 1d. generic file (http URL, other extension) — download link -->
|
|
|
|
|
|
<div v-else-if="mediaKind === 'file'" @click.stop>
|
2026-08-01 16:27:22 +08:00
|
|
|
|
<a
|
|
|
|
|
|
class="yk-msg__chip yk-msg__chip--download"
|
2026-08-11 18:24:11 +08:00
|
|
|
|
:href="mediaSrc"
|
2026-08-01 16:27:22 +08:00
|
|
|
|
target="_blank"
|
|
|
|
|
|
rel="noopener"
|
|
|
|
|
|
>
|
2026-08-05 10:23:01 +08:00
|
|
|
|
<el-icon><Paperclip /></el-icon>
|
2026-08-01 16:27:22 +08:00
|
|
|
|
下载文件
|
|
|
|
|
|
</a>
|
2026-08-05 10:23:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-13 14:02:42 +08:00
|
|
|
|
<!-- 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>
|
|
|
|
|
|
|
2026-08-05 10:23:01 +08:00
|
|
|
|
<!-- 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) }})
|
2026-08-01 16:27:22 +08:00
|
|
|
|
</span>
|
2026-08-05 10:23:01 +08:00
|
|
|
|
<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 }}
|
2026-08-01 16:27:22 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-05 10:23:01 +08:00
|
|
|
|
<!-- 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
|
2026-08-13 14:02:42 +08:00
|
|
|
|
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">
|
2026-08-05 10:23:01 +08:00
|
|
|
|
<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. -->
|
2026-08-01 16:27:22 +08:00
|
|
|
|
<div v-else class="yk-msg__bubble">
|
2026-08-05 10:23:01 +08:00
|
|
|
|
<el-icon><Picture /></el-icon>
|
|
|
|
|
|
[{{ typeTagLabel }}]
|
2026-08-01 16:27:22 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|