gaoluyang
2026-06-29 27cd042df9aca0383a49f3514bc21958dd890912
src/views/im/home/pages/conversation/components/message/reply-preview.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,234 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { formatFileSize } from '#/packages/utils/src'
import { CardLineLabel } from '#/views/im/home/components/card'
import { ImContentType } from '#/views/im/utils/constants'
import { getClientConversationId } from '#/views/im/utils/db'
import {
  type AudioMessage,
  type CardMessage,
  type FaceMessage,
  type FileMessage,
  getFileIconInfo,
  type ImageMessage,
  type MaterialMessage,
  parseMessage,
  type QuoteMessage,
  type TextMessage,
  type VideoMessage
} from '#/views/im/utils/message'
import { formatSeconds } from '#/views/im/utils/time'
import { getSenderDisplayName } from '#/views/im/utils/user'
import { useConversationStore } from '../../../../store/conversationStore'
import { useMessageStore } from '../../../../store/messageStore'
defineOptions({ name: 'ImReplyPreview' })
const props = withDefaults(
  defineProps<{
    /** æ°”泡内为 true æ”¯æŒç‚¹å‡»è·³è½¬,输入条为 false */
    clickable?: boolean
    /** è¾“入条为 true æ˜¾ç¤º Ã— å…³é—­æŒ‰é’® */
    closable?: boolean
    /** è‡ªå·±å‘送的气泡为 true,把竖线镜像到右侧,与气泡同侧 */
    mirrored?: boolean
    quote: QuoteMessage
  }>(),
  {
    clickable: false,
    closable: false,
    mirrored: false
  }
)
const emit = defineEmits<{
  close: []
  locate: [messageId: number]
}>()
const MAX_TEXT_PREVIEW_LEN = 60 // æ–‡æœ¬æ‘˜è¦åœ¨å¼•用块里展示的最大字符数
const conversationStore = useConversationStore()
const messageStore = useMessageStore()
/** åœ¨å½“前会话消息列表里查找原消息,仅用于实时判断是否已撤回;摘要 / ç¼©ç•¥å›¾éƒ½ä»Ž quote.content ç›´æŽ¥æ´¾ç”Ÿ */
const liveMessage = computed(() => {
  const conversation = conversationStore.activeConversation
  if (!conversation || !props.quote.messageId) {
    return undefined
  }
  return messageStore
    .getMessages(getClientConversationId(conversation.type, conversation.targetId))
    .find((message) => message.id === props.quote.messageId)
})
/** å‘½ä¸­æœ¬åœ°ç¼“存且 type === RECALL æ‰åˆ¤å®šä¸ºå·²æ’¤å›ž;不在缓存的当快照仍有效 */
const isRecalled = computed(() => liveMessage.value?.type === ImContentType.RECALL)
/** æ¸²æŸ“时实时算,与气泡上方显示名走同一套规则,避免备注变更后引用块陈旧 */
const senderName = computed(() => {
  const conversation = conversationStore.activeConversation
  if (!conversation) {
    return ''
  }
  return getSenderDisplayName(props.quote.senderId, conversation.type, conversation.targetId)
})
/** quote.content è§£æžä¸€æ¬¡ç¼“存,让多个 computed å¤ç”¨ï¼Œé•¿ä¼šè¯æ¯æ¡å¼•用气泡少一次 JSON.parse */
type AnyQuotePayload = Partial<
  AudioMessage &
    CardMessage &
    FaceMessage &
    FileMessage &
    ImageMessage &
    MaterialMessage &
    TextMessage &
    VideoMessage
>
const parsedPayload = computed(() => parseMessage<AnyQuotePayload>(props.quote.content))
const isText = computed(() => props.quote.type === ImContentType.TEXT)
const isFile = computed(() => props.quote.type === ImContentType.FILE)
const isVoice = computed(() => props.quote.type === ImContentType.VOICE)
const isCard = computed(() => props.quote.type === ImContentType.CARD)
const isFace = computed(() => props.quote.type === ImContentType.FACE)
const isMaterial = computed(() => props.quote.type === ImContentType.MATERIAL)
/** æ–‡æœ¬è¶…过 MAX_TEXT_PREVIEW_LEN æˆªæ–­ï¼Œé•¿å†…容不撑爆引用块 */
const textPreview = computed(() => {
  const text = parsedPayload.value?.content ?? ''
  return text.length <= MAX_TEXT_PREVIEW_LEN ? text : `${text.slice(0, Math.max(0, MAX_TEXT_PREVIEW_LEN))}…`
})
/** æ–‡ä»¶ icon:按扩展名挑色,跟主气泡渲染同源 */
const fileIcon = computed(() => getFileIconInfo(parsedPayload.value?.name))
/** ç¼©ç•¥å›¾ URL:图片 / è§†é¢‘ / è¡¨æƒ…贴图 / é¢‘道素材封面从 quote.content ç›´æŽ¥å–,不依赖本地缓存 */
const thumbnailUrl = computed<string | undefined>(() => {
  if (isRecalled.value) {
    return undefined
  }
  const { type } = props.quote
  if (type === ImContentType.IMAGE) {
    return parsedPayload.value?.thumbnailUrl || parsedPayload.value?.url
  }
  if (type === ImContentType.VIDEO || type === ImContentType.MATERIAL) {
    return parsedPayload.value?.coverUrl
  }
  if (type === ImContentType.FACE) {
    return parsedPayload.value?.url
  }
  return undefined
})
/** ä»… clickable ä¸”未撤回时触发跳转 */
function onClick() {
  if (!props.clickable || isRecalled.value) {
    return
  }
  emit('locate', props.quote.messageId)
}
</script>
<template>
  <!--
    å¼•用预览块(对齐微信 PC):2px ç«–线作为「引用」视觉标识 + ç´§å‡‘内容预览
    - clickable=true(气泡内):点击触发 locate emit;撤回态禁用跳转
    - closable=true(输入条):尾部 Ã— å…³é—­æŒ‰é’®
    - mirrored=true(自己发送的气泡):竖线镜像到右侧,与气泡同侧
    - å†…容预览(与主气泡同源,不缩写成「[文件]/[语音]」):
      - æ–‡æœ¬ï¼šæˆªæ–­åŽçº¯æ–‡å­—
      - å›¾ç‰‡ / è§†é¢‘:缩略图(IMAGE ç”¨ thumbnailUrl/url,VIDEO ç”¨ coverUrl)
      - æ–‡ä»¶ï¼šfile icon + æ–‡ä»¶å + å¤§å°
      - è¯­éŸ³ï¼šaudio icon + æ—¶é•¿
      - æ’¤å›žï¼ˆå‘½ä¸­æœ¬åœ°ç¼“存且 type === RECALL):「原消息已撤回」斜体灰字
  -->
  <div
    class="flex w-fit gap-1.5 items-center min-w-0 py-0.5 text-12px text-[var(--ant-color-text-secondary)] rounded transition-colors"
    :class="[
      mirrored
        ? 'pl-1 pr-2 border-r-2 border-r-solid border-r-[var(--ant-color-border)]'
        : 'pl-2 pr-1 border-l-2 border-l-solid border-l-[var(--ant-color-border)]',
      {
        'cursor-pointer hover:text-[var(--ant-color-text)]': clickable && !isRecalled,
        'hover:bg-[var(--ant-color-fill-secondary)]': (clickable && !isRecalled) || closable
      }
    ]"
    @click="onClick"
  >
    <span class="flex-shrink-0">{{ senderName }}:</span>
    <!-- æ’¤å›žé™çº§ -->
    <span v-if="isRecalled" class="italic">原消息已撤回</span>
    <!-- æ–‡æœ¬ -->
    <span v-else-if="isText" class="min-w-0 line-clamp-2 break-words">{{ textPreview }}</span>
    <!-- æ–‡ä»¶ï¼šicon + æ–‡ä»¶å + å¤§å° -->
    <template v-else-if="isFile">
      <Icon :icon="fileIcon.icon" :color="fileIcon.color" :size="14" class="flex-shrink-0" />
      <span v-if="parsedPayload?.name" class="min-w-0 line-clamp-2 break-words">
        {{ parsedPayload.name }}
      </span>
      <span
        v-if="parsedPayload?.size"
        class="flex-shrink-0 text-[var(--ant-color-text-placeholder)]"
      >
        {{ formatFileSize(parsedPayload.size) }}
      </span>
    </template>
    <!-- è¯­éŸ³ï¼šaudio icon + æ—¶é•¿ -->
    <template v-else-if="isVoice">
      <Icon icon="ant-design:audio-outlined" :size="14" class="flex-shrink-0" />
      <span v-if="parsedPayload?.duration" class="flex-shrink-0">
        {{ formatSeconds(parsedPayload.duration) }}
      </span>
    </template>
    <!-- åç‰‡ -->
    <CardLineLabel
      v-else-if="isCard"
      :card="parsedPayload"
      class="min-w-0 line-clamp-2 break-words"
    />
    <!-- è¡¨æƒ…贴图:缩略图 + name(无 name ä»…显示 [表情]) -->
    <template v-else-if="isFace">
      <span class="flex-shrink-0">[表情]</span>
      <span v-if="parsedPayload?.name" class="min-w-0 line-clamp-2 break-words">
        {{ parsedPayload.name }}
      </span>
    </template>
    <!-- é¢‘道素材:[频道] + æ ‡é¢˜ + å°é¢ç¼©ç•¥å›¾ -->
    <template v-else-if="isMaterial">
      <span class="flex-shrink-0">[频道]</span>
      <span v-if="parsedPayload?.title" class="min-w-0 line-clamp-2 break-words">
        {{ parsedPayload.title }}
      </span>
    </template>
    <!-- å›¾ç‰‡ / è§†é¢‘ / è¡¨æƒ…贴图 / é¢‘道素材缩略图 -->
    <img
      v-if="thumbnailUrl"
      :src="thumbnailUrl"
      class="flex-shrink-0 object-cover w-6 h-6 rounded"
      alt=""
    />
    <!-- å…³é—­æŒ‰é’® -->
    <button
      v-if="closable"
      type="button"
      class="flex-shrink-0 inline-flex items-center justify-center w-4 h-4 cursor-pointer rounded-full bg-transparent border-none text-[var(--ant-color-text-secondary)] hover:bg-[var(--ant-color-fill)] hover:text-[var(--ant-color-text)]"
      @click.stop="emit('close')"
    >
      <Icon icon="ant-design:close-outlined" :size="10" />
    </button>
  </div>
</template>