feat(im): 增加好友申请的逻辑(v1.1:增加各种 code review 注释)

im
YunaiV 2026-05-04 09:47:25 +08:00
parent f86cd30af4
commit 1469d8bb3d
12 changed files with 635 additions and 90 deletions

View File

@ -1,10 +1,12 @@
<template>
<!--
添加好友对话框
- 按昵称搜索用户列表最多 20
- "添加" 直接发好友申请friendStore 落地后按钮自动切到 "已添加"
添加好友对话框双层流程
- 第一层 search按昵称搜索用户列表
- 第二层 apply选中用户后展开申请添加朋友表单申请理由 + 备注
-->
<el-dialog v-model="visible" title="添加好友" width="480px" :close-on-click-modal="false">
<el-dialog v-model="visible" :title="dialogTitle" width="480px" :close-on-click-modal="false">
<!-- 第一层搜索 + 用户列表 -->
<template v-if="step === 'search'">
<el-input
v-model="keyword"
placeholder="输入昵称回车搜索(最多展示 20 条)"
@ -58,18 +60,70 @@
{{ user.deptName }}
</div>
</div>
<!-- 添加操作 -->
<!-- 已是好友显示已添加否则显示添加点击进入 apply 步骤 -->
<el-button
v-if="!friendStore.isFriend(user.id)"
type="primary"
size="small"
@click="handleAdd(user)"
@click="enterApply(user)"
>
添加
</el-button>
<el-button v-else size="small" disabled>已添加</el-button>
</div>
</el-scrollbar>
</template>
<!-- 第二层申请表单对齐微信申请添加朋友 -->
<template v-if="step === 'apply' && targetUser">
<!-- 选中的用户卡片 -->
<div
class="flex gap-3 items-center px-2 py-3 mb-4 rounded-md bg-[var(--el-fill-color-light)]"
>
<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(--el-text-color-primary)] truncate">
{{ targetUser.nickname }}
</div>
<div
v-if="targetUser.deptName"
class="mt-0.5 text-xs truncate text-[var(--el-text-color-secondary)]"
>
{{ targetUser.deptName }}
</div>
</div>
</div>
<div class="text-13px text-[var(--el-text-color-secondary)] mb-1.5">发送添加朋友申请</div>
<el-input
v-model="applyContent"
type="textarea"
:rows="3"
:maxlength="255"
show-word-limit
placeholder="请填写申请理由"
/>
<div class="text-13px text-[var(--el-text-color-secondary)] mt-3 mb-1.5">备注</div>
<el-input
v-model="displayName"
:maxlength="16"
placeholder="给对方起个备注(仅自己可见,可不填)"
/>
</template>
<!-- 仅在 apply 步骤显示 footer 操作按钮slot 必须是 el-dialog 直接子节点 -->
<template v-if="step === 'apply'" #footer>
<!-- 预填模式无搜索步骤取消直接关闭弹窗 -->
<el-button @click="presetMode ? (visible = false) : backToSearch()">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmitApply"> </el-button>
</template>
</el-dialog>
</template>
@ -77,6 +131,7 @@
import { computed, ref, watch } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { useMessage } from '@/hooks/web/useMessage'
import { useUserStore } from '@/store/modules/user'
import UserAvatar from '../user/UserAvatar.vue'
import { useFriendStore } from '../../store/friendStore'
@ -86,9 +141,21 @@ import { getSimpleUserListByNickname, type UserVO } from '@/api/system/user'
defineOptions({ name: 'ImFriendAddDialog' })
const props = defineProps<{
const props = withDefaults(
defineProps<{
modelValue: boolean
}>()
/** 预填目标用户:不为空时跳过搜索步骤,直接进入申请表单(群成员加好友 / 名片加好友等场景) */
presetUser?: UserVO | null
/** 添加来源;参见 ImFriendAddSourceEnum */
addSource?: number
/** 来源附带信息addSource=2群聊时传群名话术拼为「我是 XX 群的 YY」 */
addSourceExtra?: string
}>(),
{
presetUser: null,
addSource: 1
}
)
const emit = defineEmits<{
'update:modelValue': [value: boolean]
@ -101,6 +168,7 @@ const visible = computed({
})
const friendStore = useFriendStore()
const userStore = useUserStore()
const message = useMessage()
const currentUserId = getCurrentUserId() //
@ -109,14 +177,59 @@ const users = ref<UserVO[]>([])
const searched = ref(false)
const loading = ref(false)
// /
/** 当前步骤search=搜索列表apply=申请表单 */
const step = ref<'search' | 'apply'>('search')
/** 申请目标用户 */
const targetUser = ref<UserVO | null>(null)
/** 申请理由(默认填「我是 ${当前昵称}」,对齐微信交互) */
const applyContent = ref('')
/** 对接收方的备注(仅自己可见) */
const displayName = ref('')
const submitting = ref(false)
/** 弹窗标题随步骤切换 */
const dialogTitle = computed(() => (step.value === 'apply' ? '申请添加朋友' : '添加好友'))
/** 是否预填模式presetUser 不为空 → 跳过搜索,关闭即销毁,无「取消返回搜索」按钮) */
const presetMode = computed(() => !!props.presetUser)
/** 每次重新打开都把搜索态 / 申请态清空,避免上次的数据泄漏到下次 */
watch(visible, (open) => {
if (open) {
resetAll()
}
})
function resetAll() {
keyword.value = ''
users.value = []
searched.value = false
// targetUser presetUser addSource
if (props.presetUser) {
targetUser.value = props.presetUser
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.getUser?.nickname || ''
if (!myNickname) {
return ''
}
// YY
// TODO @AI使 addSource
const groupExtra = props.addSource === 2 ? props.addSourceExtra : ''
return groupExtra ? `我是"${groupExtra}"的${myNickname}` : `我是${myNickname}`
}
})
/** 按昵称搜索用户:空关键字直接清空结果 */
async function handleSearch() {
@ -133,12 +246,38 @@ async function handleSearch() {
}
}
/** 发起好友申请:成功后 friendStore 已落地,按钮自动切到 "已添加" */
async function handleAdd(user: UserVO) {
await friendStore.addFriend(user.id, {
nickname: user.nickname,
avatar: user.avatar
/** 进入申请步骤:预填申请理由「我是 ${当前用户昵称}」(对齐微信交互) */
function enterApply(user: UserVO) {
targetUser.value = user
const myNickname = userStore.getUser?.nickname || ''
applyContent.value = myNickname ? `我是${myNickname}` : ''
displayName.value = ''
step.value = 'apply'
}
/** 取消申请,回到搜索步骤 */
function backToSearch() {
step.value = 'search'
targetUser.value = null
}
/** 提交好友申请 */
async function handleSubmitApply() {
if (!targetUser.value) {
return
}
submitting.value = true
try {
await friendStore.applyFriend({
toUserId: targetUser.value.id,
applyContent: applyContent.value.trim() || undefined,
displayName: displayName.value.trim() || undefined,
addSource: props.addSource
})
message.success('已添加好友')
message.success('申请已发送,等待对方验证')
visible.value = false
} finally {
submitting.value = false
}
}
</script>

View File

@ -14,6 +14,8 @@
:url="member.avatar"
:clickable="clickable"
:id="member.userId"
:add-source="2"
:add-source-extra="groupName"
/>
<div
class="flex-1 h-full pl-2.5 overflow-hidden text-sm text-left truncate text-[var(--el-text-color-regular)]"
@ -47,11 +49,13 @@ const props = withDefaults(
height?: number // px
active?: boolean // @
clickable?: boolean // UserInfoCard@
groupName?: string // 'XX ' YY add_source=GROUP
}>(),
{
height: 50,
active: false,
clickable: false
clickable: false,
groupName: ''
}
)

View File

@ -8,12 +8,15 @@
class="relative flex flex-col items-center px-0.5 py-1"
:style="{ width: `${size! + 16}px` }"
>
<!-- TODO @AIadd source 增加枚举 -->
<UserAvatar
:id="member.userId"
:url="member.avatar"
:name="member.nickname"
:size="size"
:clickable="clickable"
:add-source="2"
:add-source-extra="groupName"
/>
<div
class="w-full mt-1 overflow-hidden text-12px leading-[18px] text-center truncate text-[var(--el-text-color-regular)]"
@ -35,10 +38,12 @@ withDefaults(
member: GroupMemberLite
clickable?: boolean // UserInfoCard
size?: number // 38 50 PC
groupName?: string // 'XX ' YY add_source=GROUP
}>(),
{
clickable: false,
size: 38
size: 38,
groupName: ''
}
)
</script>

View File

@ -60,13 +60,16 @@ const props = withDefaults(
previewable?: boolean // clickable
previewZIndex?: number // z-index z-index UserInfoCard
user?: User //
addSource?: number // UserInfoCard FriendAddDialog 1=
addSourceExtra?: string // addSource=2 XX YY
}>(),
{
size: 42,
radius: '15%',
clickable: true,
previewable: false,
previewZIndex: 2000
previewZIndex: 2000,
addSource: 1 // @AI
}
)
@ -126,7 +129,12 @@ function handleClick(e: MouseEvent) {
}
// user
if (props.user) {
uiStore.openUserInfoCard(props.user, { x: e.clientX + 20, y: e.clientY })
uiStore.openUserInfoCard(
props.user,
{ x: e.clientX + 20, y: e.clientY },
props.addSource,
props.addSourceExtra
)
return
}
// user id + +
@ -140,7 +148,9 @@ function handleClick(e: MouseEvent) {
nickname: props.name,
avatar: props.url
},
{ x: e.clientX + 20, y: e.clientY }
{ x: e.clientX + 20, y: e.clientY },
props.addSource,
props.addSourceExtra
)
}
</script>

View File

@ -139,6 +139,14 @@
<el-button type="primary" @click="handleAddFriend"></el-button>
</div>
</template>
<!-- 加好友弹窗携带预填用户跳过搜索步骤直接进申请表单理由按 addSource 区分话术 -->
<FriendAddDialog
v-model="addFriendVisible"
:preset-user="presetUserForAdd"
:add-source="addSource"
:add-source-extra="addSourceExtra"
/>
</div>
</template>
@ -149,7 +157,8 @@ import Icon from '@/components/Icon/src/Icon.vue'
import { useMessage } from '@/hooks/web/useMessage'
import UserAvatar from './UserAvatar.vue'
import { getSimpleUser } from '@/api/system/user'
import FriendAddDialog from '../friend/FriendAddDialog.vue'
import { getSimpleUser, type UserVO } from '@/api/system/user'
import { useFriendStore } from '../../store/friendStore'
import { getGenderColor, getGenderIcon } from '../../../utils/user'
import type { User } from '../../types'
@ -174,10 +183,15 @@ const props = withDefaults(
displayName?: string
/** UserAvatar 预览层 z-index放在高 z-index 浮层(如 UserInfoCard里需手动抬高 */
previewZIndex?: number
/** 加好友来源1=搜索 2=群聊 3=扫码 4=名片;默认 1搜索参见 ImFriendAddSourceEnum */
addSource?: number
/** 来源附带信息addSource=2群聊时传群名用于「我是 XX 群的 YY」预填话术 */
addSourceExtra?: string
}>(),
{
relation: 'readonly',
previewZIndex: 2000
previewZIndex: 2000,
addSource: 1
}
)
@ -281,16 +295,26 @@ function handleChat() {
emit('chat', props.user)
}
/** 加为好友:成功后 friendStore 反应到 isFriend父级的 relation 自然翻 friend本组件随之换装到 3 图标 */
async function handleAddFriend() {
// TODO @AI ====
// + props.user FriendAddDialog
const addFriendVisible = ref(false)
const presetUserForAdd = ref<UserVO | null>(null)
/** 加为好友:弹 FriendAddDialog带预填用户让用户填申请理由 + 备注后再发申请 */
function handleAddFriend() {
if (!props.user?.id) {
return
}
await friendStore.addFriend(props.user.id, {
presetUserForAdd.value = {
id: props.user.id,
nickname: props.user.nickname,
avatar: props.user.avatar
})
message.success('已添加好友')
avatar: props.user.avatar,
sex: props.user.sex,
deptId: props.user.deptId,
deptName: props.user.deptName
} as UserVO
addFriendVisible.value = true
}
/** 删除联系人confirm → friendStore.deleteFriend内部级联清会话→ 通知父级关浮层 / 清选中 */

View File

@ -17,6 +17,8 @@
:display-name="remark"
:relation="relation"
:preview-z-index="10000"
:add-source="card.addSource"
:add-source-extra="card.addSourceExtra"
@chat="handleSendMessage"
@deleted="handleClose"
/>

View File

@ -0,0 +1,171 @@
<template>
<!--
新的朋友详情面板
- 头像 + 昵称
- 申请理由块
- 来源行
- 操作按钮我发起 / 别人加我 + 未处理 / 同意 / 拒绝状态切换
-->
<div class="flex flex-col items-center px-6 pt-12">
<UserAvatar
:id="peerUserId"
:url="peerAvatar"
:name="peerNickname"
:size="64"
:clickable="false"
/>
<div class="mt-3 text-base font-semibold text-[var(--el-text-color-primary)]">
{{ peerNickname }}
</div>
<!-- 申请理由块 -->
<div
v-if="request.applyContent"
class="w-full max-w-[420px] mt-6 px-3.5 py-3 rounded-md bg-[var(--el-fill-color-light)] text-13px text-[var(--el-text-color-primary)]"
>
<span v-if="iSentIt">:</span>
<span class="ml-1">{{ request.applyContent }}</span>
</div>
<!-- 来源行 -->
<div
v-if="addSourceLabel"
class="w-full max-w-[420px] mt-3 flex items-center text-13px text-[var(--el-text-color-secondary)]"
>
<span class="w-12 flex-shrink-0">来源</span>
<span class="text-[var(--el-text-color-primary)]">{{ addSourceLabel }}</span>
</div>
<!-- 拒绝理由拒绝状态展示 -->
<!-- TODO @AI会折行拒绝理由 -->
<div
v-if="request.handleResult === 2 && request.handleContent"
class="w-full max-w-[420px] mt-3 flex items-start text-13px text-[var(--el-text-color-secondary)]"
>
<span class="w-12 flex-shrink-0">拒绝理由</span>
<span class="text-[var(--el-text-color-primary)]">{{ request.handleContent }}</span>
</div>
<!-- 操作按钮 -->
<div class="w-full max-w-[420px] mt-8 flex justify-center">
<!-- 我发起 + 等待中禁用等待对方验证 -->
<el-button v-if="iSentIt && request.handleResult === 0" disabled>
等待对方验证
</el-button>
<!-- 别人加我 + 等待中同意 / 拒绝 -->
<template v-if="!iSentIt && request.handleResult === 0">
<el-button @click="handleRefuse" :loading="refusing">拒绝</el-button>
<el-button type="primary" @click="handleAgree" :loading="agreeing">
同意
</el-button>
</template>
<!-- 已同意发消息 -->
<el-button
v-if="request.handleResult === 1"
type="primary"
@click="emit('chat', peerUserId)"
>
发消息
</el-button>
<!-- 已拒绝占位禁用按钮 -->
<el-button v-if="request.handleResult === 2" disabled>已拒绝</el-button>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { useMessage } from '@/hooks/web/useMessage'
import { ElMessageBox } from 'element-plus'
import UserAvatar from '../../components/user/UserAvatar.vue'
import { useFriendStore } from '../../store/friendStore'
import { getCurrentUserId } from '../../../utils/storage'
import type { FriendRequest } from '../../types'
defineOptions({ name: 'ImContactFriendRequestDetail' })
const props = defineProps<{
request: FriendRequest
}>()
const emit = defineEmits<{
chat: [peerUserId: number]
}>()
const friendStore = useFriendStore()
const message = useMessage()
const currentUserId = Number(getCurrentUserId() || 0)
/** 是不是我发起的fromUserId === me */
const iSentIt = computed(() => props.request.fromUserId === currentUserId)
/** 对端的用户编号 / 昵称 / 头像 */
const peerUserId = computed(() =>
iSentIt.value ? props.request.toUserId : props.request.fromUserId
)
const peerNickname = computed(() =>
iSentIt.value
? props.request.toNickname || String(props.request.toUserId)
: props.request.fromNickname || String(props.request.fromUserId)
)
const peerAvatar = computed(() =>
iSentIt.value ? props.request.toAvatar : props.request.fromAvatar
)
/** 添加来源文案;对齐后端 ImFriendAddSourceEnum1 搜索 / 2 群聊 / 3 扫码 / 4 名片 */
// TODO @AI
const addSourceLabel = computed(() => {
switch (props.request.addSource) {
case 1:
return '通过搜索添加'
case 2:
return '通过群聊添加'
case 3:
return '通过扫码添加'
case 4:
return '通过名片添加'
default:
return ''
}
})
const agreeing = ref(false)
const refusing = ref(false)
/** 同意申请 */
async function handleAgree() {
agreeing.value = true
try {
await friendStore.agreeFriendRequest(props.request.id)
message.success('已同意好友申请')
} finally {
agreeing.value = false
}
}
/** 拒绝申请 */
async function handleRefuse() {
// TODO @AI system user index.vue
let handleContent: string | undefined
try {
const result = await ElMessageBox.prompt('可填写拒绝理由(选填)', '拒绝好友申请', {
confirmButtonText: '拒绝',
cancelButtonText: '取消',
inputType: 'textarea',
inputValue: '',
inputPlaceholder: '不填则不告知对方原因',
inputValidator: (value: string) => (value || '').length <= 255 || '最多 255 个字符'
})
handleContent = (result as { value?: string }).value || undefined
} catch {
return
}
refusing.value = true
try {
await friendStore.refuseFriendRequest(props.request.id, handleContent)
message.success('已拒绝好友申请')
} finally {
refusing.value = false
}
}
</script>

View File

@ -0,0 +1,120 @@
<template>
<!--
通讯录 - 新的朋友分组
- 自治折叠状态由组件内 ref 管理默认展开
- 列表项展示头像 + 昵称 + 申请理由 + 状态标签等待验证 / 已添加 / 已拒绝
- 选中态由父级 activeId 透传点击触发 select 事件
-->
<div>
<!-- 折叠分组头 -->
<!-- TODO @AItool 应该因为通讯录新的朋友有个计数未处理数量 -->
<div
class="flex gap-2 items-center px-3.5 py-2.5 text-15px text-[var(--el-text-color-primary)] cursor-pointer select-none hover:bg-[var(--el-fill-color-light)]"
@click="expanded = !expanded"
>
<Icon :icon="expanded ? 'ep:caret-bottom' : 'ep:caret-right'" :size="14" />
<span class="flex-1">新的朋友</span>
<!-- 红点未处理且别人加我的 -->
<el-badge v-if="unhandledCount > 0" :value="unhandledCount" :max="99" class="mr-2" />
<span class="text-sm text-[var(--el-text-color-secondary)]">{{ requests.length }}</span>
</div>
<div v-show="expanded">
<div
v-for="request in requests"
:key="request.id"
class="flex gap-3 items-start px-3.5 py-2.5 cursor-pointer transition-colors hover:bg-[var(--el-fill-color-light)]"
:class="{
'bg-[var(--el-fill-color)]': activeId === request.id
}"
@click="emit('select', request)"
>
<UserAvatar
:id="getPeerUserId(request)"
:url="getPeerAvatar(request)"
:name="getPeerNickname(request)"
:size="36"
:clickable="false"
/>
<div class="flex-1 min-w-0 overflow-hidden">
<div class="flex justify-between gap-2 items-center">
<span class="flex-1 text-sm font-medium truncate text-[var(--el-text-color-primary)]">
{{ getPeerNickname(request) }}
</span>
<span class="flex-shrink-0 text-12px text-[var(--el-text-color-secondary)]">
{{ statusLabel(request) }}
</span>
</div>
<div
v-if="request.applyContent"
class="mt-0.5 text-xs truncate text-[var(--el-text-color-secondary)]"
>
{{ request.applyContent }}
</div>
</div>
</div>
<div
v-if="requests.length === 0"
class="py-3 text-12px text-center text-[var(--el-text-color-disabled)]"
>
暂无新的朋友
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import UserAvatar from '../../components/user/UserAvatar.vue'
import { getCurrentUserId } from '../../../utils/storage'
import type { FriendRequest } from '../../types'
defineOptions({ name: 'ImContactFriendRequestList' })
const props = defineProps<{
requests: FriendRequest[]
activeId?: number
}>()
const emit = defineEmits<{
select: [request: FriendRequest]
}>()
const expanded = ref(true)
// TODO @AI Number
const currentUserId = Number(getCurrentUserId() || 0)
/** 未处理 + 别人加我的(接收方=我)才进红点;我发起的不进 */
const unhandledCount = computed(
() => props.requests.filter((r) => r.handleResult === 0 && r.toUserId === currentUserId).length
)
/** 列表项展示对端fromUserId == 我 → 对端 = toUser否则对端 = fromUser */
function getPeerUserId(request: FriendRequest): number {
return request.fromUserId === currentUserId ? request.toUserId : request.fromUserId
}
// TODO @AI
function getPeerNickname(request: FriendRequest): string {
return request.fromUserId === currentUserId
? request.toNickname || String(request.toUserId)
: request.fromNickname || String(request.fromUserId)
}
// TODO @AI
function getPeerAvatar(request: FriendRequest): string | undefined {
return request.fromUserId === currentUserId ? request.toAvatar : request.fromAvatar
}
/** 状态文案:未处理 / 同意 / 拒绝 */
// TODO @AI
function statusLabel(request: FriendRequest): string {
if (request.handleResult === 1) {
return '已添加'
}
if (request.handleResult === 2) {
return '已拒绝'
}
return '等待验证'
}
</script>

View File

@ -21,7 +21,13 @@
<div class="text-13px text-[var(--el-text-color-secondary)]"> {{ memberCount }} 位成员 </div>
<!-- 成员宫格纯展示宽度跟着 320 容器自动换行不带"邀请 +"瓦片 -->
<div class="flex flex-wrap gap-2 justify-center w-full pt-2">
<GroupMemberGrid v-for="member in members" :key="member.userId" :member="member" />
<!-- TODO @AI是不是传入 groupname 更合适不应该给别人看到我自己自定义的 -->
<GroupMemberGrid
v-for="member in members"
:key="member.userId"
:member="member"
:group-name="group.showGroupName || group.name"
/>
</div>
<div class="mt-4">
<el-button type="primary" @click="emit('chat', group)">进入群聊</el-button>

View File

@ -18,8 +18,13 @@
</el-input>
</div>
<!-- 列表主体 GroupList / FriendList 个子组件各自管理折叠 + 过滤本页只透传选中态 -->
<!-- 列表主体 FriendRequestList / GroupList / FriendList 个子组件各自管理折叠 + 过滤本页只透传选中态 -->
<el-scrollbar class="flex-1">
<FriendRequestList
:requests="friendRequests"
:active-id="selection?.type === 'request' ? selection.request.id : undefined"
@select="handleSelectRequest"
/>
<GroupList
:groups="groups"
:keyword="keyword"
@ -70,6 +75,12 @@
:group="selection.group"
@chat="handleChatGroup"
/>
<!-- 新的朋友 - 申请详情 -->
<FriendRequestDetail
v-else-if="selection.type === 'request'"
:request="currentRequest"
@chat="handleChatPeer"
/>
</div>
</div>
</template>
@ -83,13 +94,15 @@ import { useRouter } from 'vue-router'
import ResizableAside from '../../components/ResizableAside.vue'
import UserInfo from '../../components/user/UserInfo.vue'
import FriendList from './FriendList.vue'
import FriendRequestList from './FriendRequestList.vue'
import FriendRequestDetail from './FriendRequestDetail.vue'
import GroupList from './GroupList.vue'
import GroupDetail from './GroupDetail.vue'
import { useConversationStore } from '../../store/conversationStore'
import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore'
import { getFriendDisplayName, getGroupDisplayName } from '../../../utils/user'
import type { Friend, FriendLite, Group, GroupLite, User } from '../../types'
import type { Friend, FriendLite, FriendRequest, Group, GroupLite, User } from '../../types'
import { ImConversationType } from '../../../utils/constants'
import { StorageKeys } from '../../../utils/storage'
import { CommonStatusEnum } from '@/utils/constants'
@ -102,12 +115,27 @@ const friendStore = useFriendStore()
const groupStore = useGroupStore()
const message = useMessage()
/** 用 type 判别选中是好友还是群聊 */
type Selection = { type: 'friend'; friend: FriendLite } | { type: 'group'; group: GroupLite }
/** 用 type 判别选中是好友 / 群聊 / 好友申请 */
type Selection =
| { type: 'friend'; friend: FriendLite }
| { type: 'group'; group: GroupLite }
| { type: 'request'; request: FriendRequest }
const selection = ref<Selection | null>(null)
const keyword = ref('')
/** 选中申请详情:详情用 store 里的最新副本(同意 / 拒绝后状态会变) */
const currentRequest = computed<FriendRequest>(() => {
const req = selection.value?.type === 'request' ? selection.value.request : null
if (!req) {
return {} as FriendRequest
}
return friendStore.findFriendRequest(req.id) || req
})
/** 我相关的申请列表(用 friendStore 里的实时副本,便于通知到达后自动刷新) */
const friendRequests = computed<FriendRequest[]>(() => friendStore.friendRequests)
/** 好友列表的展示快照:附带后端算好的拼音,给 FriendList 做字母分桶 / 拼音搜索 */
const friends = computed<FriendLite[]>(() =>
friendStore.getActiveFriends.map((friend: Friend) => ({
@ -145,7 +173,11 @@ const friendUser = computed<User | null>(() => {
})
onMounted(async () => {
await Promise.all([friendStore.fetchFriends(), groupStore.fetchGroups()])
await Promise.all([
friendStore.fetchFriends(),
friendStore.fetchFriendRequests(),
groupStore.fetchGroups()
])
})
/** 选中好友 → 切到好友详情 */
@ -158,6 +190,25 @@ function handleSelectGroup(group: GroupLite) {
selection.value = { type: 'group', group }
}
/** 选中好友申请 → 切到「新的朋友」详情 */
function handleSelectRequest(request: FriendRequest) {
selection.value = { type: 'request', request }
}
/** 申请详情里点「发消息」:直接进与对端的私聊会话 */
function handleChatPeer(peerUserId: number) {
const friend = friendStore.getFriend(peerUserId)
const conversationName = friend ? getFriendDisplayName(friend) : String(peerUserId)
conversationStore.openConversation(
peerUserId,
ImConversationType.PRIVATE,
conversationName,
friend?.avatar || '',
{ muted: !!friend?.muted }
)
router.push({ name: 'ImHomeConversation' })
}
/** 进入与该好友的私聊会话 */
function handleChatFriend(friend: FriendLite) {
// friendStore +

View File

@ -29,6 +29,7 @@
:member="member"
:size="50"
clickable
:group-name="group.name"
/>
<!-- 添加任何成员都能邀请 -->

View File

@ -12,20 +12,32 @@ import type { User } from '../types'
*/
export const useImUiStore = defineStore('imUiStore', () => {
// ==================== 用户名片 UserInfoCard ====================
// 用户名片悬浮卡:头像 / 昵称等触发点遍布会话、群成员、@ 选择器等列表,
// 用户名片悬浮卡
const userInfoCard = reactive({
show: false,
user: null as User | null,
position: { x: 0, y: 0 }
position: { x: 0, y: 0 },
// TODO @AI1 要走枚举
addSource: 1 as number, // addSource / addSourceExtra 跟随触发点带入「加好友」来源(群成员入口 = GROUP + 群名;其余默认搜索)
addSourceExtra: '' as string
})
/** 打开用户名片 */
function openUserInfoCard(user: User, position: { x: number; y: number }) {
function openUserInfoCard(
user: User,
position: { x: number; y: number },
// TODO @AI1 要走枚举
addSource: number = 1,
addSourceExtra: string = ''
) {
const viewportWidth = document.documentElement.clientWidth
const viewportHeight = document.documentElement.clientHeight
userInfoCard.user = user
userInfoCard.position.x = Math.min(position.x, viewportWidth - 350)
userInfoCard.position.y = Math.min(position.y, viewportHeight - 220)
userInfoCard.addSource = addSource
userInfoCard.addSourceExtra = addSourceExtra
userInfoCard.show = true
}