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
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
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
<script lang="ts" setup>
import type { User } from '../../types'
 
import { computed, h, nextTick, ref, watch } from 'vue'
 
import { confirm } from '#/packages/effects/common-ui/src'
import { DICT_TYPE } from '#/packages/constants/src'
import { getDictLabel } from '#/packages/effects/hooks/src'
import { IconifyIcon as Icon } from '#/packages/icons/src'
 
import { Button, Checkbox, Dropdown, Input, Menu, message } from 'ant-design-vue'
 
import { getSimpleUser, type SystemUserApi } from '#/api/system/user'
import { formatDate } from '#/views/im/utils/time'
 
import { ImFriendAddSource } from '../../../utils/constants'
import { toUserCardTarget } from '../../../utils/message'
import { getGenderColor, getGenderIcon } from '../../../utils/user'
import { useFriendStore } from '../../store/friendStore'
import { FriendAddDialog } from '../friend'
import RecommendCardDialog from './recommend-card-dialog.vue'
import UserAvatar from './user-avatar.vue'
 
defineOptions({ name: 'ImUserInfo' })
 
const props = withDefaults(
  defineProps<{
    /** 加好友来源;默认 SEARCH;参见 ImFriendAddSource */
    addSource?: number
    /** 来源附带信息:addSource=GROUP(群聊)时传群名,用于「我是 XX 群的 YY」预填话术 */
    addSourceExtra?: string
    /** 备注:仅 relation=friend 时有效;空串 → 显示"添加备注"占位 */
    displayName?: string
    /** UserAvatar 预览层 z-index;放在高 z-index 浮层(如 UserInfoCard)里需手动抬高 */
    previewZIndex?: number
    relation?: UserInfoRelation
    /** 起手用户:至少要有 id;性别 / 部门由组件按 id 懒拉合并补齐 */
    user: null | User
  }>(),
  {
    addSource: ImFriendAddSource.SEARCH,
    addSourceExtra: '',
    displayName: '',
    previewZIndex: 2000,
    relation: 'readonly'
  }
)
 
const emit = defineEmits<{
  /** 用户点"发消息":导航 / 关浮层等场景相关动作由父级承担(比如 UserInfoCard 要 close) */
  chat: [user: User]
  /** 删除联系人成功后通知父级(confirm + 调 store 都在本组件内做完):父级关浮层 / 清选中等 */
  deleted: [user: User]
  /** 备注落库成功后通知父级,用于同步父级持有的本地 FriendLite / Friend 副本(如 contact 页的 selection) */
  saved: [value: string]
}>()
 
/**
 * 关系态:决定备注行 / 右上 "..." 菜单 / 底部动作区的内容
 * - friend: 备注(可编辑)+ "..." 删除菜单 + 3 图标动作
 * - stranger: 单按钮"加为好友"
 * - self: 单按钮"不能和自己聊天" disabled
 * - readonly: 不渲染备注 / 菜单 / 动作区,仅展示头部信息
 */
export type UserInfoRelation = 'friend' | 'readonly' | 'self' | 'stranger'
 
const friendStore = useFriendStore()
 
const full = ref<null | User>(props.user) // 起手 user + getSimpleUser 合并后的完整对象(性别 / 部门补齐用)
 
/** 主标题:备注优先(好友场景),其次原昵称 */
const headerName = computed(() => props.displayName || full.value?.nickname || '')
 
const deptText = computed(() => full.value?.deptName || '-')
 
const genderIcon = computed(() => getGenderIcon(full.value?.sex))
const genderColor = computed(() => getGenderColor(full.value?.sex))
 
/** 好友关系记录:来源 / 添加时间 / 是否拉黑从这里取(仅 friend 态下才有意义) */
const friendInfo = computed(() =>
  props.user?.id ? friendStore.getFriend(props.user.id) : undefined
)
 
/** 是否已拉黑:菜单项「加入黑名单 / 移出黑名单」按这个切换 */
const isBlocked = computed(() => !!friendInfo.value?.blocked)
 
const editingRemark = ref(false) // 备注内联编辑:editingRemark 控制输入态;user 切换时由下面的 watch 复位避免脏态泄漏
const remarkInput = ref('')
const remarkInputRef = ref<null | { focus: () => void; select?: () => void }>(null)
 
/**
 * user.id 变化的统一处理:
 * 1. 起手用 prop 兜底首屏(full = props.user),再 getSimpleUser 命中后合并替换
 * 2. 顺便复位备注编辑态,避免上一个用户的脏输入泄漏到下一个
 * 3. 竞态用 id 比对丢弃陈旧响应
 */
watch(
  () => props.user?.id,
  async (id) => {
    full.value = props.user
    editingRemark.value = false
    if (!id) {
      return
    }
    const data = (await getSimpleUser(id)) as User
    if (props.user?.id !== id) {
      return
    }
    full.value = { ...props.user, ...data }
  },
  { immediate: true }
)
 
/** 备注行点击:进编辑态 + 把当前备注灌进输入框,下一帧把焦点 / 全选交给 el-input */
async function handleRowClick() {
  if (editingRemark.value) {
    return
  }
  remarkInput.value = props.displayName || ''
  editingRemark.value = true
  await nextTick()
  remarkInputRef.value?.focus()
  remarkInputRef.value?.select?.()
}
 
/**
 * 保存备注:
 * 1. 重入保护——blur + Enter 同时触发只走一次(编辑态先复位)
 * 2. 无变化跳过后端调用 + 不抛 saved,避免父级误同步
 * 3. 落库成功 / 失败都在组件内自闭:成功抛 saved 让父级同步本地副本,失败留编辑态外的展示值不动
 */
async function saveRemark() {
  if (!editingRemark.value) {
    return
  }
  const userId = props.user?.id
  if (!userId) {
    editingRemark.value = false
    return
  }
  const next = remarkInput.value.trim()
  editingRemark.value = false
  if (next === (props.displayName || '')) {
    return
  }
  await friendStore.setFriendDisplayName(userId, next)
  message.success('已更新备注')
  emit('saved', next)
}
 
function cancelEditRemark() {
  editingRemark.value = false
}
 
/** 发消息:导航 / 关浮层这些"业务侧"动作交给父级,本组件只负责通知 */
function handleChat() {
  if (!props.user) {
    return
  }
  emit('chat', props.user)
}
 
/** 占位提示:语音 / 视频聊天能力尚未接入,先以"开发中"友好提示 */
function handleComingSoon(featureName: string) {
  message.info(`${featureName} 功能开发中`)
}
 
// ==================== 添加好友 / 删除好友 ====================
 
const friendAddDialogRef = ref<InstanceType<typeof FriendAddDialog>>() // 加好友弹窗 ref:handleAddFriend 调 open({ presetUser, addSource, addSourceExtra }) 触发
 
const recommendDialogRef = ref<InstanceType<typeof RecommendCardDialog>>() // 推荐名片弹窗 ref:handleRecommend 调用 open({ target }) 打开
/** 把他推荐给朋友:弹 RecommendCardDialog 选目标会话 */
function handleRecommend() {
  if (!props.user?.id) {
    return
  }
  const target = toUserCardTarget(full.value)
  if (!target) {
    return
  }
  recommendDialogRef.value?.open({ target })
}
 
/** 加为好友:弹 FriendAddDialog(带预填用户),让用户填申请理由 + 备注后再发申请 */
function handleAddFriend() {
  if (!props.user?.id) {
    return
  }
  const presetUser: SystemUserApi.UserSimple = {
    id: props.user.id,
    nickname: props.user.nickname,
    avatar: props.user.avatar,
    sex: props.user.sex,
    deptId: props.user.deptId,
    deptName: props.user.deptName
  } as SystemUserApi.UserSimple
  friendAddDialogRef.value?.open({
    presetUser,
    addSource: props.addSource,
    addSourceExtra: props.addSourceExtra
  })
}
 
/** 加入黑名单:confirm → friendStore.blockFriend;后端 FRIEND_BLOCK 推到时由 dispatcher 同步多端 */
async function handleBlock() {
  if (!props.user?.id) {
    return
  }
  const target = props.user
  try {
    await confirm(`确定将「${target.nickname || ''}」加入黑名单吗?`, '加入黑名单')
  } catch {
    return
  }
  await friendStore.blockFriend(target.id)
  message.success('已加入黑名单')
}
 
/** 移出黑名单:操作温和不弹 confirm;后端 FRIEND_UNBLOCK 推到时由 dispatcher 同步多端 */
async function handleUnblock() {
  if (!props.user?.id) {
    return
  }
  await friendStore.unblockFriend(props.user.id)
  message.success('已移出黑名单')
}
 
/** 删除联系人:弹自定义确认(含「同时清空聊天记录」选项)→ friendStore.deleteFriend → 通知父级关浮层 / 清选中 */
async function handleDeleteFriend() {
  if (!props.user?.id) {
    return
  }
  const target = props.user
  const clearConversation = ref(true)
  try {
    await confirm({
      cancelText: '取消',
      confirmText: '删除',
      content: h('div', { class: 'flex flex-col gap-3 text-sm' }, [
        h('div', `确定删除好友「${target.nickname || ''}」?`),
        h(
          Checkbox,
          {
            checked: clearConversation.value,
            'onUpdate:checked': (value: boolean) => {
              clearConversation.value = value
            }
          },
          () => '同时清空聊天记录'
        )
      ]),
      icon: 'warning',
      title: '删除联系人'
    })
  } catch {
    return
  }
  await friendStore.deleteFriend(target.id, clearConversation.value)
  message.success('已删除好友')
  emit('deleted', target)
}
</script>
 
<template>
  <!--
    用户名片自包含组件(浮层 / 行内通用)
    - 由 UserInfoCard(浮层)和 contact/index.vue(行内)共用:UserInfoCard 把它放进 teleport 浮层,contact 直接 mount 到右栏
    - 关系态由 relation prop 决定(friend / stranger / self / readonly),对应右上 "..." 菜单 + 底部动作区两块都内化在本组件
    - 备注 / 删除联系人 / 加为好友等 store 操作都在本组件内闭环,父级仅监听 chat / deleted / saved 等通知做导航 / 关浮层 / 同步副本
  -->
  <div>
    <!-- 顶部:头像(左) + 名字 + 性别图标 + 昵称 / 部门(右) + 右上 "..." 菜单(仅 friend 态) -->
    <div class="flex gap-3 items-start">
      <UserAvatar
        :id="full?.id"
        :url="full?.avatar"
        :name="full?.nickname"
        :size="56"
        :clickable="false"
        previewable
        :preview-z-index="previewZIndex"
      />
      <div class="flex-1 min-w-0">
        <div class="flex items-center gap-1.5">
          <span
            class="text-lg font-semibold leading-snug truncate text-[var(--ant-color-text)]"
          >
            {{ headerName }}
          </span>
          <!-- 性别小图标:男蓝、女粉,未知 / 0 不展示,对齐微信留白做法 -->
          <Icon
            v-if="genderIcon"
            :icon="genderIcon"
            :size="16"
            :color="genderColor"
            class="flex-shrink-0"
          />
        </div>
        <div class="mt-2 space-y-1 text-13px text-[var(--ant-color-text-secondary)]">
          <!-- 仅当备注已设时展示昵称副行;未设置时主标题就是 nickname,避免重复 -->
          <div v-if="displayName" class="truncate">昵称:{{ full?.nickname }}</div>
          <div class="truncate">部门:{{ deptText }}</div>
        </div>
      </div>
      <!-- 右上 "..." 菜单:仅 friend 态展示;含「加入/移出黑名单」 + 「删除联系人」 -->
      <div v-if="relation === 'friend'" class="flex-shrink-0">
        <Dropdown :trigger="['click']" placement="bottomRight" overlay-class-name="im-user-info__more-menu">
          <div
            class="flex items-center justify-center w-7 h-7 rounded cursor-pointer hover:bg-[var(--ant-color-fill-secondary)]"
          >
            <Icon icon="ep:more-filled" :size="18" class="text-[var(--ant-color-text-secondary)]" />
          </div>
          <template #overlay>
            <Menu>
              <!-- 把他推荐给朋友:以个人名片消息形式发到选中的会话 -->
              <Menu.Item @click="handleRecommend">把他推荐给朋友</Menu.Item>
              <!-- 拉黑 / 移出黑名单:按 friendInfo.blocked 切换文案 -->
              <Menu.Item v-if="!isBlocked" divided @click="handleBlock">
加入黑名单
</Menu.Item>
              <Menu.Item v-else divided @click="handleUnblock">移出黑名单</Menu.Item>
              <Menu.Item divided @click="handleDeleteFriend">
                <span class="text-[var(--ant-color-error)]">删除联系人</span>
              </Menu.Item>
            </Menu>
          </template>
        </Dropdown>
      </div>
    </div>
 
    <!-- 备注行:仅 friend 态展示,未编辑时整行可点;编辑态用 el-input 行内替换 value 列 -->
    <template v-if="relation === 'friend'">
      <div class="my-4 h-px bg-[var(--ant-color-border-secondary)]"></div>
      <div
        class="flex gap-5 items-center px-1.5 py-1.5 text-sm"
        :class="
          !editingRemark
            ? 'group cursor-pointer rounded transition-colors hover:bg-[var(--ant-color-fill-tertiary)]'
            : ''
        "
        @click="handleRowClick"
      >
        <span class="flex-shrink-0 w-16 whitespace-nowrap text-[var(--ant-color-text-secondary)]">备注</span>
        <Input
          v-if="editingRemark"
          ref="remarkInputRef"
          v-model:value="remarkInput"
          size="small"
          :maxlength="20"
          placeholder="添加备注"
          class="flex-1"
          @click.stop
          @blur="saveRemark"
          @keyup.enter="saveRemark"
          @keyup.esc="cancelEditRemark"
        />
        <template v-else>
          <span
            class="flex-1 min-w-0 truncate"
            :class="
              displayName
                ? 'text-[var(--ant-color-text)]'
                : 'text-[var(--ant-color-text-placeholder)]'
            "
          >
            {{ displayName || '添加备注' }}
          </span>
          <!-- 默认 opacity:0 占位避免 hover 时其它列位移;仅父行 hover 时浮现 -->
          <Icon
            icon="ant-design:edit-outlined"
            :size="14"
            class="flex-shrink-0 text-[var(--ant-color-text-secondary)] opacity-0 group-hover:opacity-100 transition-opacity"
          />
        </template>
      </div>
 
      <!-- ==================== 更多信息:来源 / 添加时间,对齐微信「朋友资料」分块 ==================== -->
      <template v-if="friendInfo?.addSource || friendInfo?.addTime">
        <div class="my-4 h-px bg-[var(--ant-color-border-secondary)]"></div>
        <div v-if="friendInfo?.addSource" class="flex gap-5 items-center px-1.5 py-1.5 text-sm">
          <span class="flex-shrink-0 w-16 whitespace-nowrap text-[var(--ant-color-text-secondary)]">来源</span>
          <span class="flex-1 min-w-0 truncate text-[var(--ant-color-text)]">
            {{ getDictLabel(DICT_TYPE.IM_FRIEND_ADD_SOURCE, friendInfo.addSource) }}
          </span>
        </div>
        <div v-if="friendInfo?.addTime" class="flex gap-5 items-center px-1.5 py-1.5 text-sm">
          <span class="flex-shrink-0 w-16 whitespace-nowrap text-[var(--ant-color-text-secondary)]">添加时间</span>
          <span class="flex-1 min-w-0 truncate text-[var(--ant-color-text)]">
            {{ formatDate(new Date(friendInfo.addTime), 'YYYY-MM-DD') }}
          </span>
        </div>
      </template>
    </template>
 
    <!-- 动作区:好友 = 3 图标;陌生人 = 加为好友按钮;自己 = disabled;readonly 不渲染 -->
    <template v-if="relation !== 'readonly'">
      <div class="my-4 h-px bg-[var(--ant-color-border-secondary)]"></div>
      <div v-if="relation === 'friend'" class="flex justify-around">
        <!-- 三连图标按钮:图上字下、主色作为可点暗示,hover 降不透明度做反馈 -->
        <div
          class="flex flex-col gap-1.5 items-center cursor-pointer text-13px text-[var(--ant-color-primary)] transition-opacity hover:opacity-75"
          @click="handleChat"
        >
          <Icon icon="ant-design:message-outlined" :size="22" />
          <span>发消息</span>
        </div>
        <div
          class="flex flex-col gap-1.5 items-center cursor-pointer text-13px text-[var(--ant-color-primary)] transition-opacity hover:opacity-75"
          @click="handleComingSoon('语音聊天')"
        >
          <Icon icon="ant-design:phone-outlined" :size="22" />
          <span>语音聊天</span>
        </div>
        <div
          class="flex flex-col gap-1.5 items-center cursor-pointer text-13px text-[var(--ant-color-primary)] transition-opacity hover:opacity-75"
          @click="handleComingSoon('视频聊天')"
        >
          <Icon icon="ant-design:video-camera-outlined" :size="22" />
          <span>视频聊天</span>
        </div>
      </div>
      <div v-else-if="relation === 'self'" class="flex justify-center">
        <Button type="primary" disabled>不能和自己聊天</Button>
      </div>
      <div v-else class="flex justify-center">
        <Button type="primary" @click="handleAddFriend">加为好友</Button>
      </div>
    </template>
 
    <!-- 加好友弹窗:携带预填用户跳过搜索步骤,直接进申请表单(理由按 addSource 区分话术) -->
    <FriendAddDialog ref="friendAddDialogRef" />
 
    <!-- 把他推荐给朋友弹窗:仅 friend 态下出现入口 -->
    <RecommendCardDialog ref="recommendDialogRef" />
  </div>
</template>
 
<style>
/*
  非 scoped:el-dropdown 的下拉菜单走 teleport 到 body,scoped 选不到。
  UserInfoCard 浮层用 z-9998,要把这块抬到更高(默认 --ant-z-index-popup-base ~2050 会被遮罩压住)。
*/
.im-user-info__more-menu {
  z-index: 10000 !important;
}
</style>