gaoluyang
2 天以前 787ccc59ba89bacc075562a161ecf02bc76ebadc
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
<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>