huminmin
9 天以前 9ffa576f26402a87abfdd5a341e70057067742e4
src/views/im/home/components/friend/friend-add-dialog.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,289 @@
<script lang="ts" setup>
import type { SystemUserApi } from '#/api/system/user'
import { computed, ref } from 'vue'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { useUserStore } from '#/packages/stores/src'
import { Button, Input, message, Modal, Spin } from 'ant-design-vue'
import { getSimpleUserListByNickname } from '#/api/system/user'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { ImFriendAddSource } from '../../../utils/constants'
import { getGenderColor, getGenderIcon } from '../../../utils/user'
import { useFriendStore } from '../../store/friendStore'
import { UserAvatar } from '../user'
defineOptions({ name: 'ImFriendAddDialog' })
const visible = ref(false) // å¼¹çª—是否可见
const presetUser = ref<null | SystemUserApi.UserSimple>(null) // é¢„填目标用户:非 null æ—¶è·³è¿‡æœç´¢æ­¥éª¤ï¼Œç›´æŽ¥è¿›å…¥ç”³è¯·è¡¨å•(群成员加好友 / åç‰‡åŠ å¥½å‹ç­‰åœºæ™¯ï¼‰
const addSource = ref<number>(ImFriendAddSource.SEARCH) // æ·»åŠ æ¥æºï¼›å‚è§ ImFriendAddSourceEnum,默认 SEARCH
const addSourceExtra = ref<string>('') // æ¥æºé™„带信息:addSource=ImFriendAddSource.GROUP æ—¶ä¼ ç¾¤åï¼Œè¯æœ¯æ‹¼ä¸ºã€Œæˆ‘是 XX ç¾¤çš„ YY」
defineExpose({
  /** æ‰“开加好友弹窗:reset â†’ çŒå‚ â†’ visible=true;不传 opts èµ°æœç´¢æ¨¡å¼ */
  open(opts?: { addSource?: number; addSourceExtra?: string; presetUser?: null | SystemUserApi.UserSimple; }) {
    presetUser.value = opts?.presetUser ?? null
    addSource.value = opts?.addSource ?? ImFriendAddSource.SEARCH
    addSourceExtra.value = opts?.addSourceExtra ?? ''
    resetAll()
    visible.value = true
  }
})
const friendStore = useFriendStore()
const userStore = useUserStore()
/** å½“前登录用户编号;用 computed åŒ…一层,切账号后随 wsCache é‡å–,避免顶层求值在 keep-alive å®žä¾‹é‡ŒæŒæœ‰æ—§ id */
const currentUserId = computed(() => getCurrentUserId())
/** æœç´¢ç»“果过滤掉自己;用 v-if è€Œéž v-show,避免 DOM å ä½ + å¤´åƒæ— æ•ˆè¯·æ±‚ */
const visibleUsers = computed(() =>
  users.value.filter((user) => user.id !== currentUserId.value)
)
const keyword = ref('') // æœç´¢å…³é”®å­—
const users = ref<SystemUserApi.UserSimple[]>([]) // æœç´¢ç»“æžœ
const searched = ref(false) // æ˜¯å¦å·²æœç´¢
const loading = ref(false) // æœç´¢åŠ è½½ä¸­
const step = ref<'apply' | 'search'>('search') // å½“前步骤:search=搜索列表;apply=申请表单
const targetUser = ref<null | SystemUserApi.UserSimple>(null) // ç”³è¯·ç›®æ ‡ç”¨æˆ·
const applyContent = ref('') // ç”³è¯·ç†ç”±ï¼ˆé»˜è®¤å¡«ã€Œæˆ‘是 ${当前昵称}」,对齐微信交互)
const displayName = ref('') // å¯¹æŽ¥æ”¶æ–¹çš„备注(仅自己可见)
const submitting = ref(false) // æäº¤ä¸­
/** å¼¹çª—标题随步骤切换 */
const dialogTitle = computed(() => (step.value === 'apply' ? '申请添加朋友' : '添加好友'))
/** æ˜¯å¦é¢„填模式(presetUser ä¸ä¸ºç©º â†’ è·³è¿‡æœç´¢ï¼Œå…³é—­å³é”€æ¯ï¼Œæ— ã€Œå–消返回搜索」按钮) */
const presetMode = computed(() => !!presetUser.value)
function resetAll() {
  keyword.value = ''
  users.value = []
  searched.value = false
  // é¢„填模式:直接进申请表单,targetUser å–自 presetUser;申请理由按 addSource åŒºåˆ†è¯æœ¯
  if (presetUser.value) {
    targetUser.value = presetUser.value
    applyContent.value = buildPresetApplyContent()
    displayName.value = ''
    step.value = 'apply'
    return
  }
  // éžé¢„填模式:默认进搜索步骤
  step.value = 'search'
  targetUser.value = null
  applyContent.value = ''
  displayName.value = ''
}
/** é¢„填模式下的申请理由话术:群聊「我是"XX ç¾¤"的 YY」;其它「我是 YY」 */
function buildPresetApplyContent(): string {
  const myNickname = userStore.userInfo?.nickname || ''
  if (!myNickname) {
    return ''
  }
  // ç¾¤èŠåœºæ™¯æ‹¼å¸¦ç¾¤åçš„话术;其它场景默认「我是 YY」
  const groupExtra = addSource.value === ImFriendAddSource.GROUP ? addSourceExtra.value : ''
  return groupExtra ? `我是"${groupExtra}"的${myNickname}` : `我是${myNickname}`
}
/** æŒ‰æ˜µç§°æœç´¢ç”¨æˆ·ï¼šç©ºå…³é”®å­—直接清空结果 */
async function handleSearch() {
  searched.value = true
  if (!keyword.value.trim()) {
    users.value = []
    return
  }
  loading.value = true
  try {
    users.value = (await getSimpleUserListByNickname(keyword.value.trim())) || []
  } finally {
    loading.value = false
  }
}
/** è¿›å…¥ç”³è¯·æ­¥éª¤ï¼šé¢„填申请理由「我是 ${当前用户昵称}」(对齐微信交互) */
function enterApply(user: SystemUserApi.UserSimple) {
  targetUser.value = user
  const myNickname = userStore.userInfo?.nickname || ''
  applyContent.value = myNickname ? `我是${myNickname}` : ''
  displayName.value = ''
  step.value = 'apply'
}
/** å–消申请,回到搜索步骤 */
function backToSearch() {
  step.value = 'search'
  targetUser.value = null
}
/** æäº¤å¥½å‹ç”³è¯·ï¼šè¿”回 requestId èµ°ã€Œç­‰å¾…验证」;返回 null è¡¨ç¤ºåŽç«¯å‘½ä¸­ã€Œå•向好友静默重启」分支,已直接成为好友 */
async function handleSubmitApply() {
  if (!targetUser.value) {
    return
  }
  // é¢„校验:不能加自己(搜索列表已过滤,这里兜底 presetUser / åç‰‡å…¥å£ç­‰åœºæ™¯ï¼‰
  if (targetUser.value.id === currentUserId.value) {
    message.warning('不能添加自己为好友')
    return
  }
  submitting.value = true
  try {
    const requestId = await friendStore.applyFriendRequest({
      toUserId: targetUser.value.id,
      applyContent: applyContent.value.trim() || undefined,
      displayName: displayName.value.trim() || undefined,
      addSource: addSource.value
    })
    // silent åˆ†æ”¯ï¼ˆå·²æ˜¯å•向好友被静默重启):主动 fetchFriendInfo å…¥åº“,不依赖 WS FRIEND_ADD æŽ¨é€ï¼Œé¿å…ä¸¢æŽ¨æ—¶åˆ—表看不到
    if (requestId === null) {
      await friendStore.fetchFriendInfo(targetUser.value.id)
    }
    message.success(requestId ? '申请已发送,等待对方验证' : '已添加为好友')
    visible.value = false
  } catch {
    // ä¸šåŠ¡é”™è¯¯ï¼ˆå·²æ˜¯å¥½å‹ / è¢«å¯¹æ–¹æ‹‰é»‘ / ç”¨æˆ·è¢«ç¦ç”¨ ç­‰ï¼‰ï¼šå…¨å±€æ‹¦æˆªå™¨å·²å¼¹é”™è¯¯æç¤ºï¼Œæœ¬åœ°å…³å¼¹çª—避免脏状态停留
    visible.value = false
  } finally {
    submitting.value = false
  }
}
</script>
<template>
  <!--
    æ·»åŠ å¥½å‹å¯¹è¯æ¡†ï¼ˆåŒå±‚æµç¨‹ï¼‰
    - ç¬¬ä¸€å±‚ search:按昵称搜索用户列表
    - ç¬¬äºŒå±‚ apply:选中用户后展开「申请添加朋友」表单(申请理由 + å¤‡æ³¨ï¼‰
  -->
  <Modal
    v-model:open="visible"
    :title="dialogTitle"
    width="480px"
    :mask-closable="false"
    :footer="step === 'apply' ? undefined : null"
  >
    <!-- ç¬¬ä¸€å±‚:搜索 + ç”¨æˆ·åˆ—表 -->
    <template v-if="step === 'search'">
      <Input
        v-model:value="keyword"
        placeholder="输入昵称回车搜索(最多展示 20 æ¡ï¼‰"
        allow-clear
        @keyup.enter="handleSearch"
      >
        <template #suffix>
          <Icon icon="ant-design:search-outlined" class="cursor-pointer" @click="handleSearch" />
        </template>
      </Input>
      <Spin :spinning="loading" wrapper-class-name="w-full">
        <div class="h-[400px] mt-2.5">
          <div
            v-if="visibleUsers.length === 0"
            class="py-10 text-13px text-center text-[var(--ant-color-text-disabled)]"
          >
            {{ searched ? '没有搜到用户' : '输入关键字后回车开始搜索' }}
          </div>
          <div
            v-for="user in visibleUsers"
            :key="user.id"
            class="flex gap-3 items-center px-2 py-2.5 border-b border-b-solid border-[var(--ant-color-border-secondary)]"
          >
            <UserAvatar
              :id="user.id"
              :url="user.avatar"
              :name="user.nickname"
              :size="42"
              :clickable="false"
            />
            <div class="flex-1 min-w-0 overflow-hidden">
              <!-- æ˜µç§° + æ€§åˆ«å›¾æ ‡ -->
              <div
                class="flex items-center gap-1 text-sm font-semibold text-[var(--ant-color-text)]"
              >
                <span class="truncate">{{ user.nickname }}</span>
                <Icon
                  v-if="getGenderIcon(user.sex)"
                  :icon="getGenderIcon(user.sex)"
                  :size="14"
                  :color="getGenderColor(user.sex)"
                  class="flex-shrink-0"
                />
              </div>
              <!-- éƒ¨é—¨ -->
              <div
                v-if="user.deptName"
                class="mt-0.5 text-xs truncate text-[var(--ant-color-text-secondary)]"
              >
                {{ user.deptName }}
              </div>
            </div>
            <!-- å·²æ˜¯å¥½å‹æ˜¾ç¤ºã€Œå·²æ·»åŠ ã€ï¼›å¦åˆ™æ˜¾ç¤ºã€Œæ·»åŠ ã€ï¼ˆç‚¹å‡»è¿›å…¥ apply æ­¥éª¤ï¼‰ -->
            <Button
              v-if="!friendStore.isActiveFriend(user.id)"
              type="primary"
              size="small"
              @click="enterApply(user)"
            >
              æ·»åŠ 
            </Button>
            <Button v-else size="small" disabled>已添加</Button>
          </div>
        </div>
      </Spin>
    </template>
    <!-- ç¬¬äºŒå±‚:申请表单(对齐微信「申请添加朋友」) -->
    <template v-if="step === 'apply' && targetUser">
      <!-- é€‰ä¸­çš„用户卡片 -->
      <div
        class="flex gap-3 items-center px-2 py-3 mb-4 rounded-md bg-[var(--ant-color-fill-secondary)]"
      >
        <UserAvatar
          :id="targetUser.id"
          :url="targetUser.avatar"
          :name="targetUser.nickname"
          :size="42"
          :clickable="false"
        />
        <div class="flex-1 min-w-0 overflow-hidden">
          <div class="text-sm font-semibold text-[var(--ant-color-text)] truncate">
            {{ targetUser.nickname }}
          </div>
          <div
            v-if="targetUser.deptName"
            class="mt-0.5 text-xs truncate text-[var(--ant-color-text-secondary)]"
          >
            {{ targetUser.deptName }}
          </div>
        </div>
      </div>
      <div class="text-13px text-[var(--ant-color-text-secondary)] mb-1.5">发送添加朋友申请</div>
      <Input.TextArea
        v-model:value="applyContent"
        :rows="3"
        :maxlength="255"
        show-count
        placeholder="请填写申请理由"
      />
      <div class="text-13px text-[var(--ant-color-text-secondary)] mt-3 mb-1.5">备注</div>
      <Input
        v-model:value="displayName"
        :maxlength="16"
        placeholder="给对方起个备注(仅自己可见,可不填)"
      />
    </template>
    <!-- ä»…在 apply æ­¥éª¤æ˜¾ç¤º footer æ“ä½œæŒ‰é’®ï¼ˆslot å¿…须是 el-dialog ç›´æŽ¥å­èŠ‚ç‚¹ï¼‰ -->
    <template v-if="step === 'apply'" #footer>
      <!-- é¢„填模式无搜索步骤,「取消」直接关闭弹窗 -->
      <Button @click="presetMode ? (visible = false) : backToSearch()">取消</Button>
      <Button type="primary" :loading="submitting" @click="handleSubmitApply"> ç¡®å®š </Button>
    </template>
  </Modal>
</template>