gaoluyang
2 天以前 b64a0deae5b5d33f9e20671a68936b27f0b9b00b
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
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>