gaoluyang
2026-06-29 27cd042df9aca0383a49f3514bc21958dd890912
src/views/im/home/pages/conversation/components/conversation/conversation-item.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,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>