2026-08-01 16:27:22 +08:00
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { computed } from 'vue'
|
|
|
|
|
|
import { Microphone, VideoPlay, Document, Picture } from '@element-plus/icons-vue'
|
|
|
|
|
|
import type { Message } from '@/api/types'
|
|
|
|
|
|
import { formatDuration, formatTime } 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).
|
2026-08-01 17:47:50 +08:00
|
|
|
|
*
|
|
|
|
|
|
* 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.
|
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))
|
|
|
|
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
|
)
|
|
|
|
|
|
</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-01 17:47:50 +08:00
|
|
|
|
<!-- 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. -->
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- text -->
|
|
|
|
|
|
<div v-if="message.type === 'text'" class="yk-msg__bubble">
|
|
|
|
|
|
{{ message.text || '(空消息)' }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- image -->
|
2026-08-01 17:47:50 +08:00
|
|
|
|
<div v-else-if="message.type === 'image'" @click.stop>
|
2026-08-01 16:27:22 +08:00
|
|
|
|
<el-image
|
|
|
|
|
|
v-if="imageThumbUrl"
|
|
|
|
|
|
class="yk-msg__image"
|
|
|
|
|
|
:src="imageThumbUrl"
|
|
|
|
|
|
: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'">
|
|
|
|
|
|
<video
|
|
|
|
|
|
v-if="message.file_url"
|
|
|
|
|
|
class="yk-msg__video"
|
|
|
|
|
|
:src="message.file_url"
|
|
|
|
|
|
controls
|
|
|
|
|
|
preload="metadata"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span v-else class="yk-msg__bubble">
|
|
|
|
|
|
<el-icon><VideoPlay /></el-icon> [视频]
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- voice -->
|
|
|
|
|
|
<div v-else-if="message.type === 'voice'" class="yk-msg__voice">
|
|
|
|
|
|
<span class="yk-msg__chip">
|
|
|
|
|
|
<el-icon><Microphone /></el-icon>
|
|
|
|
|
|
语音{{ message.duration ? ` (${formatDuration(message.duration)})` : '' }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<div v-if="message.transcription_text" class="yk-msg__transcript">
|
|
|
|
|
|
{{ message.transcription_text }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- file -->
|
2026-08-01 17:47:50 +08:00
|
|
|
|
<div v-else-if="message.type === 'file'" @click.stop>
|
2026-08-01 16:27:22 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- unknown / fallback -->
|
|
|
|
|
|
<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})` : ''}]` }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|