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
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
<script lang="ts" setup>
import type { Conversation } from '../../../../types'
 
import { computed } from 'vue'
 
import { confirm } from '#/packages/effects/common-ui/src'
import { IconifyIcon as Icon } from '#/packages/icons/src'
 
import { Tag } from 'ant-design-vue'
 
import { buildRecallTip } from '#/views/im/utils/conversation'
import { formatConversationTime } from '#/views/im/utils/time'
import { getSenderDisplayName } from '#/views/im/utils/user'
 
import { ImContentType, ImConversationType, isNormalMessage } from '../../../../../utils/constants'
import { GroupAvatar } from '../../../../components/group'
import { UserAvatar } from '../../../../components/user'
import { useConversationStore } from '../../../../store/conversationStore'
import { useFriendStore } from '../../../../store/friendStore'
import { useGroupRequestStore } from '../../../../store/groupRequestStore'
import { useGroupStore } from '../../../../store/groupStore'
import { useImUiStore } from '../../../../store/uiStore'
 
defineOptions({ name: 'ImConversationItem' })
 
// 周中文名(dayjs 的 day() 返回 0-6,0=周日);项目没全局装 dayjs/locale/zh-cn,本地映射避免引副作用
const props = defineProps<{
  conversation: Conversation
}>()
 
const conversationStore = useConversationStore()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const groupRequestStore = useGroupRequestStore()
const uiStore = useImUiStore()
 
const isActive = computed(
  () =>
    conversationStore.activeConversation?.targetId === props.conversation.targetId &&
    conversationStore.activeConversation?.type === props.conversation.type
)
 
/**
 * 当前会话的草稿快照:存在时列表显示 [草稿] 前缀 + 文本,盖掉 sender 前缀和 @我 红字
 * 当前打开的会话不展示草稿;输入内容已经在编辑器里可见,列表再显示 [草稿] 反而冗余
 */
const draft = computed(() => {
  if (isActive.value) {
    return undefined
  }
  return conversationStore.getConversationDraft(props.conversation)
})
 
const isGroup = computed(() => props.conversation.type === ImConversationType.GROUP)
 
/** 最后一条消息发送者的展示名:实时算 + 快照 fallback(getSenderDisplayName 算不出时兜底) */
const lastSenderDisplayName = computed(() => {
  const senderId = props.conversation.lastSenderId
  if (!senderId) {
    return ''
  }
  return getSenderDisplayName(
    senderId,
    props.conversation.type,
    props.conversation.targetId,
    props.conversation.lastSenderDisplayName
  )
})
 
/** 群聊 + 有最后发送者 + 最后一条是普通消息时,显示发送者前缀(FRIEND_* / GROUP_* / RECALL / 草稿态不带前缀) */
const showSendName = computed(() => {
  if (draft.value) {
    return false
  }
  if (!isGroup.value) {
    return false
  }
  if (!props.conversation.lastSenderId) {
    return false
  }
  const lastType = props.conversation.lastMessageType
  return lastType != null && isNormalMessage(lastType)
})
 
/** 列表展示文案:草稿优先(对齐微信 PC:有草稿时盖掉最后一条预览)→ 撤回实时算 → lastContent 兜底 */
const lastContentDisplay = computed(() => {
  if (draft.value) {
    return draft.value.plain
  }
  if (
    props.conversation.lastMessageType === ImContentType.RECALL &&
    props.conversation.lastSenderId != null
  ) {
    return buildRecallTip(
      props.conversation.lastSenderId,
      !!props.conversation.lastSelfSend,
      props.conversation.type,
      props.conversation.targetId,
      props.conversation.lastSenderDisplayName
    )
  }
  return props.conversation.lastContent
})
 
/** 会话列表 "[草稿]" / "@ 我" / "@ 全体成员" 红字提示;草稿优先(对齐微信 PC) */
const atText = computed(() => {
  if (draft.value) {
    return '[草稿]'
  }
  if (props.conversation.atMe) {
    return '[有人@我]'
  }
  if (props.conversation.atAll) {
    return '[@全体成员]'
  }
  return ''
})
 
/** 免打扰会话未读条数文案 */
const mutedUnreadText = computed(() => {
  if (!props.conversation.silent || props.conversation.unreadCount <= 0) {
    return ''
  }
  const count = props.conversation.unreadCount > 99 ? '99+' : props.conversation.unreadCount
  return `[${count}条]`
})
 
/** 群聊未处理加群申请红字前缀;store 已经按「我管理的群」过滤过,count > 0 即可显示 */
const requestText = computed(() => {
  if (!isGroup.value) {
    return ''
  }
  const count = groupRequestStore.getUnhandledGroupRequestCountMap.get(props.conversation.targetId) ?? 0
  return count > 0 ? `[${count}条进群申请]` : ''
})
 
/** 点击切会话 */
function handleClick() {
  conversationStore.setActiveConversation(props.conversation)
}
 
/** 切换置顶 */
function handleTop() {
  conversationStore.setConversationTop(
    props.conversation.type,
    props.conversation.targetId,
    !props.conversation.top
  )
}
 
/** 切换免打扰:乐观 UI(先本地切换,菜单立即关;后端失败回滚 conversation 状态) */
function handleMuted() {
  const next = !props.conversation.silent
  const { type, targetId } = props.conversation
  conversationStore.setConversationSilent(type, targetId, next)
  const sync =
    type === ImConversationType.PRIVATE
      ? friendStore.setFriendSilent(targetId, next)
      : groupStore.setGroupSilent(targetId, next)
  sync.catch((error) => {
    console.error('[IM] 切换免打扰失败', error)
    conversationStore.setConversationSilent(type, targetId, !next)
  })
}
 
/** 删除会话:二次确认后软删 */
async function handleDelete() {
  try {
    await confirm(`确定删除与「${props.conversation.name}」的会话吗?`, '删除会话')
    conversationStore.removeConversation(props.conversation.type, props.conversation.targetId)
  } catch {}
}
 
/** 右键菜单:置顶 / 免打扰 / 删除 */
function handleContextMenu(e: MouseEvent) {
  uiStore.openContextMenu(
    { x: e.clientX, y: e.clientY },
    [
      { key: 'TOP', name: props.conversation.top ? '取消置顶' : '置顶' },
      { key: 'MUTED', name: props.conversation.silent ? '允许消息通知' : '消息免打扰' },
      { key: 'DELETE', name: '删除', divided: true, danger: true }
    ],
    (item) => {
      switch (item.key) {
      case 'DELETE': {
        void handleDelete()
 
      break;
      }
      case 'MUTED': {
        handleMuted()
 
      break;
      }
      case 'TOP': {
        handleTop()
 
      break;
      }
      // No default
      }
    }
  )
}
 
</script>
 
<template>
  <div
    class="relative flex items-center gap-2.5 px-4 py-3 cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]"
    :class="{ '!bg-[#d9ecff] dark:!bg-[var(--ant-color-primary-bg-hover)]': isActive }"
    :data-conversation-key="`${conversation.type}-${conversation.targetId}`"
    @click="handleClick"
    @contextmenu.prevent="handleContextMenu"
  >
    <!-- 头像 + 未读提示 -->
    <div class="relative">
      <GroupAvatar
        v-if="isGroup"
        :group-id="conversation.targetId"
        :url="conversation.avatar"
        :name="conversation.name"
        :size="40"
      />
      <UserAvatar
        v-else
        :url="conversation.avatar"
        :name="conversation.name"
        :size="40"
        :clickable="false"
      />
      <!-- 数字徽标:非免打扰且有未读时显示具体条数 -->
      <span
        v-show="!conversation.silent && conversation.unreadCount > 0"
        class="absolute -top-1.5 -right-1.5 min-w-[18px] h-[18px] px-1.5 text-11px leading-[18px] text-white text-center bg-[#f56c6c] border border-solid border-white dark:border-[var(--ant-color-bg-container)] rounded-full box-border whitespace-nowrap"
      >
        {{ conversation.unreadCount > 99 ? '99+' : conversation.unreadCount }}
      </span>
      <!-- 小红点:免打扰且有未读时提示存在新消息 -->
      <span
        v-show="conversation.silent && conversation.unreadCount > 0"
        class="absolute -top-1 -right-1 w-2.5 h-2.5 bg-[#f56c6c] border border-solid border-white dark:border-[var(--ant-color-bg-container)] rounded-full box-border"
      ></span>
    </div>
 
    <div class="flex-1 min-w-0">
      <div class="flex items-center justify-between">
        <span class="flex flex-1 items-center gap-1 min-w-0">
          <span class="overflow-hidden text-sm truncate text-[var(--ant-color-text)]">
            {{ conversation.name }}
          </span>
          <Tag
            v-if="isGroup"
            type="primary"
            size="small"
            class="conversation-item__tag flex-shrink-0 !h-[18px] !px-1 !leading-4"
          >
            群
          </Tag>
        </span>
        <span class="flex-shrink-0 ml-1 text-12px text-[var(--ant-color-text-secondary)]">
          {{ formatConversationTime(conversation.lastSendTime) }}
        </span>
      </div>
      <div class="flex items-center mt-1 leading-5">
        <!-- 进群申请红字前缀:群主 / 管理员看到自己管理的群下还有未处理申请时显示 -->
        <span
          v-if="requestText"
          class="conversation-item__prefix flex-shrink overflow-hidden text-12px text-[#c70b0b] truncate whitespace-nowrap"
        >
          {{ requestText }}
        </span>
        <!-- @红字提示:atMe 优先于 atAll -->
        <span
          v-if="atText"
          class="conversation-item__prefix flex-shrink overflow-hidden text-12px text-[#c70b0b] truncate whitespace-nowrap"
        >
          {{ atText }}
        </span>
        <!-- 免打扰未读条数 -->
        <span
          v-if="mutedUnreadText"
          class="flex-shrink-0 mr-1 text-12px text-[var(--ant-color-text-secondary)] whitespace-nowrap"
        >
          {{ mutedUnreadText }}
        </span>
        <!-- 群聊最后一条发送者前缀:按 lastSenderId + 当前会话上下文实时算名字 -->
        <span
          v-if="showSendName"
          class="conversation-item__sender flex-shrink overflow-hidden text-12px text-[var(--ant-color-text-secondary)] truncate whitespace-nowrap"
        >
          {{ lastSenderDisplayName }}:&nbsp;
        </span>
        <span
          class="flex-1 min-w-0 overflow-hidden text-12px truncate text-[var(--ant-color-text-secondary)]"
        >
          {{ lastContentDisplay }}
        </span>
        <!-- 免打扰图标 -->
        <Icon
          v-if="conversation.silent"
          icon="mdi:bell-off-outline"
          :size="14"
          class="conversation-item__silent flex-shrink-0 ml-1 text-[var(--ant-color-text-disabled)]"
          title="消息免打扰"
        />
      </div>
    </div>
  </div>
</template>
 
<style scoped>
/* 消掉 el-tag 切会话时 active 底色变化的渐变(看起来像闪烁); :deep 穿透 el-tag 自身样式 */
.conversation-item__tag {
  transition: none !important;
}
 
/* el-icon 的全局 color:var(--color) 在暗色模式下会渲染成白色,这里用 :deep + !important 锁定 */
.conversation-item__silent :deep(svg) {
  fill: currentColor !important;
}
 
.conversation-item__prefix {
  max-width: 45%;
}
 
.conversation-item__sender {
  max-width: 50%;
}
</style>