gaoluyang
2026-06-29 27cd042df9aca0383a49f3514bc21958dd890912
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
<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>