gaoluyang
2026-06-29 27cd042df9aca0383a49f3514bc21958dd890912
src/views/im/home/pages/conversation/components/message/material-bubble.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,170 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { openSafeUrl } from '#/packages/utils/src'
import { Modal, Spin } from 'ant-design-vue'
import { getChannelMaterial } from '#/api/im/channel/material'
import { useChannelStore } from '#/views/im/home/store/channelStore'
import { useConversationStore } from '#/views/im/home/store/conversationStore'
import { ImConversationType } from '#/views/im/utils/constants'
import { type MaterialMessage, parseMessage } from '#/views/im/utils/message'
const props = defineProps<{
  content: string
}>()
const conversationStore = useConversationStore()
const channelStore = useChannelStore()
/** å½“前是否在公众号 / é¢‘道会话里:决定走大卡片还是紧凑转发卡片 */
const isChannelView = computed(
  () => conversationStore.activeConversation?.type === ImConversationType.CHANNEL
)
/** ååºåˆ—化 content JSON ä¸º payload å¯¹è±¡ */
const payload = computed<MaterialMessage>(
  () => parseMessage<MaterialMessage>(props.content) ?? {}
)
/** æ¥æºé¢‘道;紧凑卡底部渲染头像 + åç§° */
const sourceChannel = computed(() =>
  payload.value.channelId ? channelStore.getChannel(payload.value.channelId) : undefined
)
const detailVisible = ref(false)
const detailLoading = ref(false)
const detailHtml = ref('')
// ç‚¹å‡»è¡Œä¸ºï¼šurl éžç©ºè·³å¤–链;为空则按 payload.materialId æ‹‰å¯Œæ–‡æœ¬æ­£æ–‡ï¼Œå…¨å± dialog æ¸²æŸ“
const onClick = async () => {
  if (payload.value.url) {
    openSafeUrl(payload.value.url)
    return
  }
  if (!payload.value.materialId) {
    return
  }
  detailVisible.value = true
  detailLoading.value = true
  detailHtml.value = ''
  try {
    const material = await getChannelMaterial(payload.value.materialId)
    detailHtml.value = material?.content ?? ''
  } catch (error) {
    console.error('[Material] æ‹‰å–正文失败', error)
  } finally {
    detailLoading.value = false
  }
}
</script>
<template>
  <!-- å…¬ä¼—号会话内大卡片:对齐微信公众号单图文卡(封面 9:5 + ä¸‹æ–¹ç™½åº•加粗标题条) -->
  <div
    v-if="isChannelView"
    class="material-card cursor-pointer w-full overflow-hidden rounded-lg bg-[var(--ant-color-bg-container)] border border-solid border-[var(--im-border-color-lighter)]"
    @click="onClick"
  >
    <img v-if="payload.coverUrl" class="block w-full h-[200px] object-cover" :src="payload.coverUrl" />
    <div class="px-3.5 py-3 text-15px font-600 leading-[1.4] text-[var(--ant-color-text)] line-clamp-2">
      {{ payload.title || '(无标题)' }}
    </div>
  </div>
  <!-- ç§èŠ / ç¾¤èŠé‡Œè¢«è½¬å‘的素材紧凑卡片:标题 + æ‘˜è¦åœ¨å·¦ã€å°å°é¢åœ¨å³ã€åº•部频道头像 + åç§°ï¼ˆå¯¹é½å¾®ä¿¡å…¬ä¼—号转发卡) -->
  <div
    v-else
    class="material-card cursor-pointer flex flex-col w-[260px] px-3.5 pt-3 pb-2.5 rounded-lg bg-[var(--ant-color-bg-container)] border border-solid border-[var(--im-border-color-lighter)]"
    @click="onClick"
  >
    <div class="flex gap-2.5 items-start">
      <div class="flex flex-1 flex-col gap-1.5 min-w-0">
        <div class="text-15px font-600 leading-[1.4] text-[var(--ant-color-text)] line-clamp-2 break-all">
          {{ payload.title || '(无标题)' }}
        </div>
        <div
          v-if="payload.summary"
          class="text-12px leading-[1.5] text-[var(--ant-color-text-secondary)] line-clamp-2 break-all"
        >
          {{ payload.summary }}
        </div>
      </div>
      <img
        v-if="payload.coverUrl"
        class="flex-shrink-0 w-[60px] h-[60px] object-cover rounded bg-[var(--ant-color-fill-secondary)]"
        :src="payload.coverUrl"
      />
    </div>
    <div class="flex items-center gap-1.5 mt-2.5 pt-2 border-t border-t-solid border-[var(--im-border-color-lighter)] text-12px text-[var(--ant-color-text-secondary)]">
      <img
        v-if="sourceChannel?.avatar"
        class="w-4 h-4 rounded-full object-cover flex-shrink-0"
        :src="sourceChannel.avatar"
      />
      <Icon v-else icon="ep:promotion" :size="14" />
      <span class="truncate">{{ sourceChannel?.name || '频道消息' }}</span>
    </div>
  </div>
  <!-- å¯Œæ–‡æœ¬è¯¦æƒ…:全屏弹窗按需挂载,关闭后释放富文本 DOM -->
  <Modal
    v-model:open="detailVisible"
    :title="payload.title || '详情'"
    :footer="null"
    width="100vw"
    wrap-class-name="im-material-detail-modal"
    destroy-on-close
  >
    <Spin :spinning="detailLoading" wrapper-class-name="w-full">
      <div class="material-detail-body max-w-[720px] mx-auto px-5 pt-6 pb-20 min-h-[60vh]">
        <div class="text-[22px] font-600 leading-[1.4] text-[var(--ant-color-text)] mb-5">
          {{ payload.title || '' }}
        </div>
        <div
          v-if="detailHtml"
          class="article-content text-15px leading-[1.75] text-[var(--ant-color-text)]"
          v-dompurify-html="detailHtml"
        ></div>
        <div
          v-else-if="!detailLoading"
          class="text-center text-[var(--ant-color-text-secondary)] mt-20"
        >
          æš‚无正文
        </div>
      </div>
    </Spin>
  </Modal>
</template>
<style scoped lang="scss">
/* hover é˜´å½± + transition:合写一处,写法更紧凑 */
.material-card {
  transition: box-shadow 0.15s ease;
  &:hover {
    box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
  }
}
/* :deep ç©¿é€ v-dompurify-html æ¸²æŸ“的内嵌 DOM;统一控制富文本里的 img / p / hN æŽ’版 */
.article-content {
  :deep(img) {
    max-width: 100%;
    height: auto;
  }
  :deep(p) {
    margin: 12px 0;
  }
  :deep(h1),
  :deep(h2),
  :deep(h3) {
    margin: 20px 0 12px;
    font-weight: 600;
  }
}
</style>