gaoluyang
2026-06-29 27cd042df9aca0383a49f3514bc21958dd890912
src/views/im/home/pages/conversation/components/conversation/conversation-private-side.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,285 @@
<script lang="ts" setup>
import type { Conversation, Friend } from '../../../../types'
import { computed, ref, watch } from 'vue'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { Button, Drawer, Input, message, Popover, Spin, Switch } from 'ant-design-vue'
import { useConversationStore } from '#/views/im/home/store/conversationStore'
import { useFriendStore } from '#/views/im/home/store/friendStore'
import { useGroupStore } from '#/views/im/home/store/groupStore'
import { ImConversationType } from '#/views/im/utils/constants'
import { getFriendDisplayName, getGroupDisplayName } from '#/views/im/utils/user'
import { GroupCreateDialog } from '../../../../components/group'
import { UserAvatar } from '../../../../components/user'
defineOptions({ name: 'ImConversationPrivateSide' })
const props = withDefaults(
  defineProps<{
    conversation?: Conversation | null // å½“前会话(取置顶 / å…æ‰“扰态)
    friend?: Friend // å¯¹æ–¹å¥½å‹ä¿¡æ¯ï¼ˆå–头像 / æ˜µç§°ï¼‰
    modelValue?: boolean // æŠ½å±‰å¼€å…³ï¼ˆv-model)
  }>(),
  {
    conversation: null,
    friend: undefined,
    modelValue: false
  }
)
const emit = defineEmits<{
  openHistory: [] // ç‚¹å‡» "查找聊天内容" è¡Œ â†’ çˆ¶ç»„件打开 MessageHistory å¼¹çª—
  'update:modelValue': [value: boolean]
}>()
const visible = computed({
  get: () => props.modelValue,
  set: (value) => emit('update:modelValue', value)
})
const conversationStore = useConversationStore()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
/** tile æ ‡ç­¾ / åŽç»­èŠå¤©ç•Œé¢ç”¨çš„展示名:备注优先 */
const displayName = computed(() => (props.friend ? getFriendDisplayName(props.friend) : ''))
const createGroupDialogRef = ref<InstanceType<typeof GroupCreateDialog>>() // å‘起群聊弹窗 ref:handleOpenCreateGroup è°ƒ open({ lockedIds }) é”å®šå¯¹æ–¹
/** æ‰“开发起群聊弹窗:把对方默认勾上且不可取消,对应微信"基于私聊发起群聊" */
function handleOpenCreateGroup() {
  const lockedIds = props.friend ? [props.friend.friendUserId] : []
  createGroupDialogRef.value?.open({ lockedIds })
}
const displayNamePopoverVisible = ref(false)
const editDisplayName = ref('')
// popover å¼¹å‡ºæ—¶æŠŠå½“前备注灌进编辑态,避免上次未保存的脏值
watch(displayNamePopoverVisible, (open) => {
  if (open) {
    editDisplayName.value = props.friend?.displayName || ''
  }
})
// æŠ½å±‰å…³é—­æ—¶æŠŠè¿˜æ²¡æ”¶æŽ‰çš„ popover ä¸€å¹¶æ¸…掉,避免下次打开闪一下
watch(visible, (open) => {
  if (!open) {
    displayNamePopoverVisible.value = false
  }
})
/** å¤‡æ³¨ popover ç‚¹å‡»ä¿å­˜ */
async function handleSaveDisplayName() {
  if (!props.friend) {
    return
  }
  await friendStore.setFriendDisplayName(props.friend.friendUserId, editDisplayName.value)
  displayNamePopoverVisible.value = false
  message.success('保存成功')
}
/**
 * åˆ‡å…æ‰“扰:乐观双写 conversationStore + friendStore;后端失败回滚 conversation çŠ¶æ€ï¼Œä¿æŒä¸Ž ConversationItem.handleMuted ä¸€è‡´
 */
function handleMutedChange(value: boolean | number | string) {
  if (!props.conversation) {
    return
  }
  const next = !!value
  const { type, targetId } = props.conversation
  conversationStore.setConversationSilent(type, targetId, next)
  if (type !== ImConversationType.PRIVATE) {
    return
  }
  friendStore.setFriendSilent(targetId, next).catch((error) => {
    console.error('[IM ConversationPrivateSide] åˆ‡æ¢å…æ‰“扰失败', { targetId }, error)
    conversationStore.setConversationSilent(type, targetId, !next)
  })
}
/** åˆ‡ç½®é¡¶ï¼šçº¯æœ¬åœ° conversationStore æŽ’序态(无后端字段) */
function handleTopChange(value: boolean | number | string) {
  if (!props.conversation) {
    return
  }
  conversationStore.setConversationTop(props.conversation.type, props.conversation.targetId, !!value)
}
/** ç¾¤åˆ›å»ºæˆåŠŸï¼šè·³åˆ°æ–°ç¾¤ä¼šè¯ + å…³æŽ‰æœ¬ä¾§æŠ½å±‰ï¼Œè®©ç”¨æˆ·ä¸“注新群 */
function handleGroupCreated(groupId: number) {
  const group = groupStore.getGroup(groupId)
  if (!group) {
    return
  }
  conversationStore.openConversation(
    groupId,
    ImConversationType.GROUP,
    getGroupDisplayName(group),
    group.avatar || '',
    { silent: !!group.silent }
  )
  visible.value = false
}
</script>
<template>
  <!--
    ç§èŠä¾§è¾¹æŠ½å±‰
    - æ•´ä½“结构对齐 ConversationGroupSide:宫格 + ä¿¡æ¯è¡Œ + å¼€å…³
    - é¡¶éƒ¨å¥½å‹å®«æ ¼ + "+" tile:点 + è°ƒèµ· GroupCreateDialog å¹¶é”å®šå¯¹æ–¹ï¼Œå¯¹é½å¾®ä¿¡"基于私聊发起群聊"
    - "清空聊天记录"按钮在 WeChat é‡Œæœ‰ï¼Œä½†ç›®å‰åŽç«¯æ²¡å»ºæ¶ˆæ¯æ¸…空能力,先不加避免做半吊子
  -->
  <Drawer
    v-model:open="visible"
    :closable="false"
    placement="right"
    width="340px"
    root-class-name="im-conversation-private-side__modal"
  >
    <!-- friend ç¼ºå¤±åœºæ™¯ï¼šé™Œç”Ÿäººä¼šè¯åˆšæ‰“å¼€ / å¥½å‹æ•°æ®è¿˜æ²¡è¡¥æ‹‰åˆ°ï¼›ç©ºç™½ä¼šè®©ç”¨æˆ·ä»¥ä¸ºæŠ½å±‰åäº†ï¼Œç»™ä¸ªåŠ è½½å ä½ -->
    <div
      v-if="!friend"
      class="flex flex-col items-center justify-center h-full text-13px text-[var(--ant-color-text-placeholder)] bg-[var(--ant-color-bg-container)]"
    >
      <Spin tip="加载中..." />
    </div>
    <div v-else class="flex flex-col h-full bg-[var(--ant-color-bg-container)]">
      <div class="flex-1 overflow-y-auto bg-[var(--ant-color-fill-secondary)]">
        <!-- å¥½å‹å®«æ ¼ï¼šåŽŸ tile + "+" tile,对齐 GroupSide è§†è§‰ï¼Œè®©ä¸¤ç§æŠ½å±‰çœ‹èµ·æ¥æ˜¯ä¸€å®¶çš„ -->
        <div class="flex flex-wrap gap-1 px-4 pt-4 pb-[14px] bg-[var(--ant-color-bg-container)]">
          <div class="flex flex-col items-center w-[66px]">
            <UserAvatar
              :id="friend.friendUserId"
              :url="friend.avatar"
              :name="friend.nickname"
              :size="50"
            />
            <div class="w-full mt-1.5 overflow-hidden text-12px leading-[1.5] text-[var(--ant-color-text)] text-center truncate">
              {{ displayName }}
            </div>
          </div>
          <!-- + tile:点击调起 GroupCreateDialog,把对方 id ä½œä¸º lockedIds ä¼ å…¥ -->
          <div
            class="im-conversation-private-side__tile-wrap-clickable flex flex-col items-center w-[66px] cursor-pointer"
            title="发起群聊"
            @click="handleOpenCreateGroup"
          >
            <div class="im-conversation-private-side__icon-tile flex items-center justify-center w-[50px] h-[50px] text-20px text-[var(--ant-color-text)] bg-[var(--ant-color-fill-tertiary)] border border-dashed border-[var(--ant-color-border)] rounded-md transition-colors duration-200">
              <Icon icon="ant-design:plus-outlined" />
            </div>
            <div class="w-full mt-1.5 overflow-hidden text-12px leading-[1.5] text-[var(--ant-color-text)] text-center truncate">添加</div>
          </div>
        </div>
        <div class="flex-shrink-0 h-[10px]"></div>
        <!-- å¤‡æ³¨ï¼ˆä»…自己可见):点击弹 popover ç¼–辑,保存后立即刷新本抽屉 + ä¼šè¯åˆ—表展示名 -->
        <div class="bg-[var(--ant-color-bg-container)]">
          <Popover
            v-model:open="displayNamePopoverVisible"
            trigger="click"
            placement="leftTop"
            :overlay-style="{ width: '280px' }"
          >
            <div
              class="im-conversation-private-side__row flex flex-col items-stretch gap-1.5 px-4 py-[14px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--ant-color-fill-tertiary)]"
            >
              <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">备注</span>
              <span
                v-if="friend.displayName"
                class="text-13px leading-[1.6] text-[var(--ant-color-text)] break-all line-clamp-2"
              >
                {{ friend.displayName }}
              </span>
              <span v-else class="text-13px leading-[1.6] text-[var(--ant-color-text-placeholder)]">
                å¥½å‹å¤‡æ³¨ä»…自己可见
              </span>
            </div>
            <template #content>
              <div class="flex flex-col gap-2">
                <Input
                  v-model:value="editDisplayName"
                  :maxlength="16"
                  show-count
                  placeholder="请输入备注名"
                />
                <div class="flex justify-end gap-2">
                  <Button size="small" @click="displayNamePopoverVisible = false">取消</Button>
                  <Button size="small" type="primary" @click="handleSaveDisplayName">
                    ä¿å­˜
                  </Button>
                </div>
              </div>
            </template>
          </Popover>
        </div>
        <div class="flex-shrink-0 h-[10px]"></div>
        <!-- æŸ¥æ‰¾èŠå¤©å†…容 -->
        <div class="bg-[var(--ant-color-bg-container)]">
          <div
            class="im-conversation-private-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--ant-color-fill-tertiary)]"
            @click="emit('openHistory')"
          >
            <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">查找聊天内容</span>
            <Icon
              icon="ant-design:right-outlined"
              :size="11"
              class="text-[var(--ant-color-text-placeholder)]"
            />
          </div>
        </div>
        <div class="flex-shrink-0 h-[10px]"></div>
        <!-- å¼€å…³é¡¹ -->
        <div class="bg-[var(--ant-color-bg-container)]">
          <div class="im-conversation-private-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 transition-colors duration-150">
            <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">消息免打扰</span>
            <Switch :checked="!!conversation?.silent" @change="handleMutedChange" />
          </div>
          <div class="im-conversation-private-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 transition-colors duration-150">
            <span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">置顶聊天</span>
            <Switch :checked="!!conversation?.top" @change="handleTopChange" />
          </div>
        </div>
      </div>
    </div>
    <!-- å­å¯¹è¯æ¡†ï¼šå‘起群聊(锁定对方为已选) -->
    <GroupCreateDialog ref="createGroupDialogRef" @created="handleGroupCreated" />
  </Drawer>
</template>
<style scoped>
/* ã€Œ+」 tile: hover æ—¶è”动内部 icon-tile èµ°ä¸»è‰²ï¼› è·¨å­å…ƒç´ çš„ hover è”动无法用单元素工具类表达 */
.im-conversation-private-side__tile-wrap-clickable:hover .im-conversation-private-side__icon-tile {
  color: var(--ant-color-primary);
  border-color: var(--ant-color-primary);
  background-color: var(--ant-color-primary-bg);
}
/* :deep ç©¿é€ Icon å†…部 svg; el-icon å…¨å±€ color åœ¨æš—色模式下被主题盖过,锁 fill åˆ°å½“前色 */
.im-conversation-private-side__icon-tile :deep(svg) {
  fill: currentColor !important;
}
/* ç›¸é‚»ä¿¡æ¯è¡ŒåŠ åˆ†éš”çº¿ï¼› ç›¸é‚»å…„弟选择器无法用工具类表达 */
.im-conversation-private-side__row + .im-conversation-private-side__row {
  border-top: 1px solid var(--im-border-color-lighter);
}
</style>
<!-- åŒ GroupSide:antd Drawer ä¼ é€åˆ° body åŽ scoped data-v è½ä¸åˆ° body ä¸Šï¼Œé  root-class-name ä½œç¥–先选择器写全局规则压掉默认 padding -->
<style>
.im-conversation-private-side__modal .ant-drawer-body {
  padding: 0;
}
</style>