gaoluyang
63 分钟以前 cb9cd49627b65a4c0e137e08063271a8cefe1826
src/views/im/home/components/user/user-info.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,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>