✨ feat(im): 增加好友申请的逻辑(v1.1:增加各种 code review 注释)
parent
f86cd30af4
commit
1469d8bb3d
|
|
@ -1,75 +1,129 @@
|
||||||
<template>
|
<template>
|
||||||
<!--
|
<!--
|
||||||
添加好友对话框
|
添加好友对话框(双层流程)
|
||||||
- 按昵称搜索用户列表(最多 20 条)
|
- 第一层 search:按昵称搜索用户列表
|
||||||
- 点 "添加" 直接发好友申请,friendStore 落地后按钮自动切到 "已添加"
|
- 第二层 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">
|
||||||
<el-input
|
<!-- 第一层:搜索 + 用户列表 -->
|
||||||
v-model="keyword"
|
<template v-if="step === 'search'">
|
||||||
placeholder="输入昵称回车搜索(最多展示 20 条)"
|
<el-input
|
||||||
clearable
|
v-model="keyword"
|
||||||
@keyup.enter="handleSearch"
|
placeholder="输入昵称回车搜索(最多展示 20 条)"
|
||||||
>
|
clearable
|
||||||
<template #suffix>
|
@keyup.enter="handleSearch"
|
||||||
<Icon icon="ant-design:search-outlined" class="cursor-pointer" @click="handleSearch" />
|
|
||||||
</template>
|
|
||||||
</el-input>
|
|
||||||
|
|
||||||
<el-scrollbar v-loading="loading" class="h-[400px] mt-2.5">
|
|
||||||
<div
|
|
||||||
v-if="users.length === 0"
|
|
||||||
class="py-10 text-13px text-center text-[var(--el-text-color-disabled)]"
|
|
||||||
>
|
>
|
||||||
{{ searched ? '没有搜到用户' : '输入关键字后回车开始搜索' }}
|
<template #suffix>
|
||||||
</div>
|
<Icon icon="ant-design:search-outlined" class="cursor-pointer" @click="handleSearch" />
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
|
||||||
|
<el-scrollbar v-loading="loading" class="h-[400px] mt-2.5">
|
||||||
|
<div
|
||||||
|
v-if="users.length === 0"
|
||||||
|
class="py-10 text-13px text-center text-[var(--el-text-color-disabled)]"
|
||||||
|
>
|
||||||
|
{{ searched ? '没有搜到用户' : '输入关键字后回车开始搜索' }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="user in users"
|
||||||
|
:key="user.id"
|
||||||
|
v-show="user.id !== currentUserId"
|
||||||
|
class="flex gap-3 items-center px-2 py-2.5 border-b border-[var(--el-border-color-lighter)]"
|
||||||
|
>
|
||||||
|
<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(--el-text-color-primary)]"
|
||||||
|
>
|
||||||
|
<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(--el-text-color-secondary)]"
|
||||||
|
>
|
||||||
|
{{ user.deptName }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 已是好友显示「已添加」;否则显示「添加」(点击进入 apply 步骤) -->
|
||||||
|
<el-button
|
||||||
|
v-if="!friendStore.isFriend(user.id)"
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@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
|
<div
|
||||||
v-for="user in users"
|
class="flex gap-3 items-center px-2 py-3 mb-4 rounded-md bg-[var(--el-fill-color-light)]"
|
||||||
:key="user.id"
|
|
||||||
v-show="user.id !== currentUserId"
|
|
||||||
class="flex gap-3 items-center px-2 py-2.5 border-b border-[var(--el-border-color-lighter)]"
|
|
||||||
>
|
>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
:id="user.id"
|
:id="targetUser.id"
|
||||||
:url="user.avatar"
|
:url="targetUser.avatar"
|
||||||
:name="user.nickname"
|
:name="targetUser.nickname"
|
||||||
:size="42"
|
:size="42"
|
||||||
:clickable="false"
|
:clickable="false"
|
||||||
/>
|
/>
|
||||||
<div class="flex-1 min-w-0 overflow-hidden">
|
<div class="flex-1 min-w-0 overflow-hidden">
|
||||||
<!-- 昵称 + 性别图标 -->
|
<div class="text-sm font-semibold text-[var(--el-text-color-primary)] truncate">
|
||||||
<div
|
{{ targetUser.nickname }}
|
||||||
class="flex items-center gap-1 text-sm font-semibold text-[var(--el-text-color-primary)]"
|
|
||||||
>
|
|
||||||
<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>
|
||||||
<!-- 部门 -->
|
|
||||||
<div
|
<div
|
||||||
v-if="user.deptName"
|
v-if="targetUser.deptName"
|
||||||
class="mt-0.5 text-xs truncate text-[var(--el-text-color-secondary)]"
|
class="mt-0.5 text-xs truncate text-[var(--el-text-color-secondary)]"
|
||||||
>
|
>
|
||||||
{{ user.deptName }}
|
{{ targetUser.deptName }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 添加操作 -->
|
|
||||||
<el-button
|
|
||||||
v-if="!friendStore.isFriend(user.id)"
|
|
||||||
type="primary"
|
|
||||||
size="small"
|
|
||||||
@click="handleAdd(user)"
|
|
||||||
>
|
|
||||||
添加
|
|
||||||
</el-button>
|
|
||||||
<el-button v-else size="small" disabled>已添加</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
</el-scrollbar>
|
|
||||||
|
<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>
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -77,6 +131,7 @@
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import Icon from '@/components/Icon/src/Icon.vue'
|
import Icon from '@/components/Icon/src/Icon.vue'
|
||||||
import { useMessage } from '@/hooks/web/useMessage'
|
import { useMessage } from '@/hooks/web/useMessage'
|
||||||
|
import { useUserStore } from '@/store/modules/user'
|
||||||
|
|
||||||
import UserAvatar from '../user/UserAvatar.vue'
|
import UserAvatar from '../user/UserAvatar.vue'
|
||||||
import { useFriendStore } from '../../store/friendStore'
|
import { useFriendStore } from '../../store/friendStore'
|
||||||
|
|
@ -86,9 +141,21 @@ import { getSimpleUserListByNickname, type UserVO } from '@/api/system/user'
|
||||||
|
|
||||||
defineOptions({ name: 'ImFriendAddDialog' })
|
defineOptions({ name: 'ImFriendAddDialog' })
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
modelValue: boolean
|
defineProps<{
|
||||||
}>()
|
modelValue: boolean
|
||||||
|
/** 预填目标用户:不为空时跳过搜索步骤,直接进入申请表单(群成员加好友 / 名片加好友等场景) */
|
||||||
|
presetUser?: UserVO | null
|
||||||
|
/** 添加来源;参见 ImFriendAddSourceEnum */
|
||||||
|
addSource?: number
|
||||||
|
/** 来源附带信息:addSource=2(群聊)时传群名,话术拼为「我是 XX 群的 YY」 */
|
||||||
|
addSourceExtra?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
presetUser: null,
|
||||||
|
addSource: 1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: boolean]
|
'update:modelValue': [value: boolean]
|
||||||
|
|
@ -101,6 +168,7 @@ const visible = computed({
|
||||||
})
|
})
|
||||||
|
|
||||||
const friendStore = useFriendStore()
|
const friendStore = useFriendStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
const currentUserId = getCurrentUserId() // 当前登录用户编号
|
const currentUserId = getCurrentUserId() // 当前登录用户编号
|
||||||
|
|
@ -109,15 +177,60 @@ const users = ref<UserVO[]>([])
|
||||||
const searched = ref(false)
|
const searched = ref(false)
|
||||||
const loading = 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) => {
|
watch(visible, (open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
keyword.value = ''
|
resetAll()
|
||||||
users.value = []
|
|
||||||
searched.value = false
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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() {
|
async function handleSearch() {
|
||||||
searched.value = true
|
searched.value = true
|
||||||
|
|
@ -133,12 +246,38 @@ async function handleSearch() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 发起好友申请:成功后 friendStore 已落地,按钮自动切到 "已添加" */
|
/** 进入申请步骤:预填申请理由「我是 ${当前用户昵称}」(对齐微信交互) */
|
||||||
async function handleAdd(user: UserVO) {
|
function enterApply(user: UserVO) {
|
||||||
await friendStore.addFriend(user.id, {
|
targetUser.value = user
|
||||||
nickname: user.nickname,
|
const myNickname = userStore.getUser?.nickname || ''
|
||||||
avatar: user.avatar
|
applyContent.value = myNickname ? `我是${myNickname}` : ''
|
||||||
})
|
displayName.value = ''
|
||||||
message.success('已添加好友')
|
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('申请已发送,等待对方验证')
|
||||||
|
visible.value = false
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@
|
||||||
:url="member.avatar"
|
:url="member.avatar"
|
||||||
:clickable="clickable"
|
:clickable="clickable"
|
||||||
:id="member.userId"
|
:id="member.userId"
|
||||||
|
:add-source="2"
|
||||||
|
:add-source-extra="groupName"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="flex-1 h-full pl-2.5 overflow-hidden text-sm text-left truncate text-[var(--el-text-color-regular)]"
|
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),影响头像大小
|
height?: number // 行高(px),影响头像大小
|
||||||
active?: boolean // 选中态(@候选键盘高亮等)
|
active?: boolean // 选中态(@候选键盘高亮等)
|
||||||
clickable?: boolean // 头像点击是否弹 UserInfoCard;@候选场景通常禁用(避免嵌套交互)
|
clickable?: boolean // 头像点击是否弹 UserInfoCard;@候选场景通常禁用(避免嵌套交互)
|
||||||
|
groupName?: string // 群名:加好友时拼「我是 'XX 群' 的 YY」话术,落库 add_source=GROUP
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
height: 50,
|
height: 50,
|
||||||
active: false,
|
active: false,
|
||||||
clickable: false
|
clickable: false,
|
||||||
|
groupName: ''
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,15 @@
|
||||||
class="relative flex flex-col items-center px-0.5 py-1"
|
class="relative flex flex-col items-center px-0.5 py-1"
|
||||||
:style="{ width: `${size! + 16}px` }"
|
:style="{ width: `${size! + 16}px` }"
|
||||||
>
|
>
|
||||||
|
<!-- TODO @AI:add source 增加枚举; -->
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
:id="member.userId"
|
:id="member.userId"
|
||||||
:url="member.avatar"
|
:url="member.avatar"
|
||||||
:name="member.nickname"
|
:name="member.nickname"
|
||||||
:size="size"
|
:size="size"
|
||||||
:clickable="clickable"
|
:clickable="clickable"
|
||||||
|
:add-source="2"
|
||||||
|
:add-source-extra="groupName"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="w-full mt-1 overflow-hidden text-12px leading-[18px] text-center truncate text-[var(--el-text-color-regular)]"
|
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
|
member: GroupMemberLite
|
||||||
clickable?: boolean // 头像点击是否弹 UserInfoCard;选择器宫格里需要保持关闭,避免和勾选交互冲突
|
clickable?: boolean // 头像点击是否弹 UserInfoCard;选择器宫格里需要保持关闭,避免和勾选交互冲突
|
||||||
size?: number // 头像像素大小;默认 38(兼容选择器右侧已选区),群信息抽屉传 50 对齐微信 PC
|
size?: number // 头像像素大小;默认 38(兼容选择器右侧已选区),群信息抽屉传 50 对齐微信 PC
|
||||||
|
groupName?: string // 群名:加好友时拼「我是 'XX 群' 的 YY」话术,落库 add_source=GROUP
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
clickable: false,
|
clickable: false,
|
||||||
size: 38
|
size: 38,
|
||||||
|
groupName: ''
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -60,13 +60,16 @@ const props = withDefaults(
|
||||||
previewable?: boolean // 是否点头像直接放大预览;开启后忽略 clickable,不再弹名片
|
previewable?: boolean // 是否点头像直接放大预览;开启后忽略 clickable,不再弹名片
|
||||||
previewZIndex?: number // 预览层 z-index;放在高 z-index 弹层(如 UserInfoCard)里时需手动抬高
|
previewZIndex?: number // 预览层 z-index;放在高 z-index 弹层(如 UserInfoCard)里时需手动抬高
|
||||||
user?: User // 额外的用户信息,传了点击就不用现拉接口(弹名片用)
|
user?: User // 额外的用户信息,传了点击就不用现拉接口(弹名片用)
|
||||||
|
addSource?: number // 加好友来源;点头像弹 UserInfoCard 时透传给 FriendAddDialog(默认 1=搜索)
|
||||||
|
addSourceExtra?: string // 加好友来源附加:addSource=2(群聊)时传群名,话术拼为「我是 XX 群的 YY」
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
size: 42,
|
size: 42,
|
||||||
radius: '15%',
|
radius: '15%',
|
||||||
clickable: true,
|
clickable: true,
|
||||||
previewable: false,
|
previewable: false,
|
||||||
previewZIndex: 2000
|
previewZIndex: 2000,
|
||||||
|
addSource: 1 // @AI:是不是枚举下;
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -126,7 +129,12 @@ function handleClick(e: MouseEvent) {
|
||||||
}
|
}
|
||||||
// 情况一:有预传 user 信息:就直接用,省一次接口
|
// 情况一:有预传 user 信息:就直接用,省一次接口
|
||||||
if (props.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
|
return
|
||||||
}
|
}
|
||||||
// 情况二:无预传 user 信息:打开名片,传最小必要信息(id + 昵称 + 头像),位置在鼠标右侧
|
// 情况二:无预传 user 信息:打开名片,传最小必要信息(id + 昵称 + 头像),位置在鼠标右侧
|
||||||
|
|
@ -140,7 +148,9 @@ function handleClick(e: MouseEvent) {
|
||||||
nickname: props.name,
|
nickname: props.name,
|
||||||
avatar: props.url
|
avatar: props.url
|
||||||
},
|
},
|
||||||
{ x: e.clientX + 20, y: e.clientY }
|
{ x: e.clientX + 20, y: e.clientY },
|
||||||
|
props.addSource,
|
||||||
|
props.addSourceExtra
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,14 @@
|
||||||
<el-button type="primary" @click="handleAddFriend">加为好友</el-button>
|
<el-button type="primary" @click="handleAddFriend">加为好友</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- 加好友弹窗:携带预填用户跳过搜索步骤,直接进申请表单(理由按 addSource 区分话术) -->
|
||||||
|
<FriendAddDialog
|
||||||
|
v-model="addFriendVisible"
|
||||||
|
:preset-user="presetUserForAdd"
|
||||||
|
:add-source="addSource"
|
||||||
|
:add-source-extra="addSourceExtra"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -149,7 +157,8 @@ import Icon from '@/components/Icon/src/Icon.vue'
|
||||||
import { useMessage } from '@/hooks/web/useMessage'
|
import { useMessage } from '@/hooks/web/useMessage'
|
||||||
|
|
||||||
import UserAvatar from './UserAvatar.vue'
|
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 { useFriendStore } from '../../store/friendStore'
|
||||||
import { getGenderColor, getGenderIcon } from '../../../utils/user'
|
import { getGenderColor, getGenderIcon } from '../../../utils/user'
|
||||||
import type { User } from '../../types'
|
import type { User } from '../../types'
|
||||||
|
|
@ -174,10 +183,15 @@ const props = withDefaults(
|
||||||
displayName?: string
|
displayName?: string
|
||||||
/** UserAvatar 预览层 z-index;放在高 z-index 浮层(如 UserInfoCard)里需手动抬高 */
|
/** UserAvatar 预览层 z-index;放在高 z-index 浮层(如 UserInfoCard)里需手动抬高 */
|
||||||
previewZIndex?: number
|
previewZIndex?: number
|
||||||
|
/** 加好友来源:1=搜索 2=群聊 3=扫码 4=名片;默认 1(搜索);参见 ImFriendAddSourceEnum */
|
||||||
|
addSource?: number
|
||||||
|
/** 来源附带信息:addSource=2(群聊)时传群名,用于「我是 XX 群的 YY」预填话术 */
|
||||||
|
addSourceExtra?: string
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
relation: 'readonly',
|
relation: 'readonly',
|
||||||
previewZIndex: 2000
|
previewZIndex: 2000,
|
||||||
|
addSource: 1
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -281,16 +295,26 @@ function handleChat() {
|
||||||
emit('chat', props.user)
|
emit('chat', props.user)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 加为好友:成功后 friendStore 反应到 isFriend,父级的 relation 自然翻 friend,本组件随之换装到 3 图标 */
|
// TODO @AI:添加好友、删除好友,作为一个 ==== 栏目,这样好理解点;
|
||||||
async function handleAddFriend() {
|
|
||||||
|
// 加好友弹窗显隐 + 预填用户(点「加为好友」时把 props.user 传给 FriendAddDialog 跳过搜索)
|
||||||
|
const addFriendVisible = ref(false)
|
||||||
|
const presetUserForAdd = ref<UserVO | null>(null)
|
||||||
|
|
||||||
|
/** 加为好友:弹 FriendAddDialog(带预填用户),让用户填申请理由 + 备注后再发申请 */
|
||||||
|
function handleAddFriend() {
|
||||||
if (!props.user?.id) {
|
if (!props.user?.id) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await friendStore.addFriend(props.user.id, {
|
presetUserForAdd.value = {
|
||||||
|
id: props.user.id,
|
||||||
nickname: props.user.nickname,
|
nickname: props.user.nickname,
|
||||||
avatar: props.user.avatar
|
avatar: props.user.avatar,
|
||||||
})
|
sex: props.user.sex,
|
||||||
message.success('已添加好友')
|
deptId: props.user.deptId,
|
||||||
|
deptName: props.user.deptName
|
||||||
|
} as UserVO
|
||||||
|
addFriendVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除联系人:confirm → friendStore.deleteFriend(内部级联清会话)→ 通知父级关浮层 / 清选中 */
|
/** 删除联系人:confirm → friendStore.deleteFriend(内部级联清会话)→ 通知父级关浮层 / 清选中 */
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@
|
||||||
:display-name="remark"
|
:display-name="remark"
|
||||||
:relation="relation"
|
:relation="relation"
|
||||||
:preview-z-index="10000"
|
:preview-z-index="10000"
|
||||||
|
:add-source="card.addSource"
|
||||||
|
:add-source-extra="card.addSourceExtra"
|
||||||
@chat="handleSendMessage"
|
@chat="handleSendMessage"
|
||||||
@deleted="handleClose"
|
@deleted="handleClose"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 添加来源文案;对齐后端 ImFriendAddSourceEnum:1 搜索 / 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>
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
<template>
|
||||||
|
<!--
|
||||||
|
通讯录 - 新的朋友分组
|
||||||
|
- 自治:折叠状态由组件内 ref 管理,默认展开
|
||||||
|
- 列表项展示:头像 + 昵称 + 申请理由 + 状态标签(等待验证 / 已添加 / 已拒绝)
|
||||||
|
- 选中态由父级 activeId 透传;点击触发 select 事件
|
||||||
|
-->
|
||||||
|
<div>
|
||||||
|
<!-- 折叠分组头 -->
|
||||||
|
<!-- TODO @AI:tool 那,应该因为通讯录(新的朋友)有个计数;未处理数量; -->
|
||||||
|
<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>
|
||||||
|
|
@ -21,7 +21,13 @@
|
||||||
<div class="text-13px text-[var(--el-text-color-secondary)]"> {{ memberCount }} 位成员 </div>
|
<div class="text-13px text-[var(--el-text-color-secondary)]"> {{ memberCount }} 位成员 </div>
|
||||||
<!-- 成员宫格:纯展示,宽度跟着 320 容器自动换行;不带"邀请 +"瓦片 -->
|
<!-- 成员宫格:纯展示,宽度跟着 320 容器自动换行;不带"邀请 +"瓦片 -->
|
||||||
<div class="flex flex-wrap gap-2 justify-center w-full pt-2">
|
<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>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<el-button type="primary" @click="emit('chat', group)">进入群聊</el-button>
|
<el-button type="primary" @click="emit('chat', group)">进入群聊</el-button>
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,13 @@
|
||||||
</el-input>
|
</el-input>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 列表主体:拆 GroupList / FriendList 两个子组件,各自管理折叠 + 过滤;本页只透传选中态 -->
|
<!-- 列表主体:拆 FriendRequestList / GroupList / FriendList 三个子组件,各自管理折叠 + 过滤;本页只透传选中态 -->
|
||||||
<el-scrollbar class="flex-1">
|
<el-scrollbar class="flex-1">
|
||||||
|
<FriendRequestList
|
||||||
|
:requests="friendRequests"
|
||||||
|
:active-id="selection?.type === 'request' ? selection.request.id : undefined"
|
||||||
|
@select="handleSelectRequest"
|
||||||
|
/>
|
||||||
<GroupList
|
<GroupList
|
||||||
:groups="groups"
|
:groups="groups"
|
||||||
:keyword="keyword"
|
:keyword="keyword"
|
||||||
|
|
@ -70,6 +75,12 @@
|
||||||
:group="selection.group"
|
:group="selection.group"
|
||||||
@chat="handleChatGroup"
|
@chat="handleChatGroup"
|
||||||
/>
|
/>
|
||||||
|
<!-- 新的朋友 - 申请详情 -->
|
||||||
|
<FriendRequestDetail
|
||||||
|
v-else-if="selection.type === 'request'"
|
||||||
|
:request="currentRequest"
|
||||||
|
@chat="handleChatPeer"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -83,13 +94,15 @@ import { useRouter } from 'vue-router'
|
||||||
import ResizableAside from '../../components/ResizableAside.vue'
|
import ResizableAside from '../../components/ResizableAside.vue'
|
||||||
import UserInfo from '../../components/user/UserInfo.vue'
|
import UserInfo from '../../components/user/UserInfo.vue'
|
||||||
import FriendList from './FriendList.vue'
|
import FriendList from './FriendList.vue'
|
||||||
|
import FriendRequestList from './FriendRequestList.vue'
|
||||||
|
import FriendRequestDetail from './FriendRequestDetail.vue'
|
||||||
import GroupList from './GroupList.vue'
|
import GroupList from './GroupList.vue'
|
||||||
import GroupDetail from './GroupDetail.vue'
|
import GroupDetail from './GroupDetail.vue'
|
||||||
import { useConversationStore } from '../../store/conversationStore'
|
import { useConversationStore } from '../../store/conversationStore'
|
||||||
import { useFriendStore } from '../../store/friendStore'
|
import { useFriendStore } from '../../store/friendStore'
|
||||||
import { useGroupStore } from '../../store/groupStore'
|
import { useGroupStore } from '../../store/groupStore'
|
||||||
import { getFriendDisplayName, getGroupDisplayName } from '../../../utils/user'
|
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 { ImConversationType } from '../../../utils/constants'
|
||||||
import { StorageKeys } from '../../../utils/storage'
|
import { StorageKeys } from '../../../utils/storage'
|
||||||
import { CommonStatusEnum } from '@/utils/constants'
|
import { CommonStatusEnum } from '@/utils/constants'
|
||||||
|
|
@ -102,12 +115,27 @@ const friendStore = useFriendStore()
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
/** 用 type 判别选中是好友还是群聊 */
|
/** 用 type 判别选中是好友 / 群聊 / 好友申请 */
|
||||||
type Selection = { type: 'friend'; friend: FriendLite } | { type: 'group'; group: GroupLite }
|
type Selection =
|
||||||
|
| { type: 'friend'; friend: FriendLite }
|
||||||
|
| { type: 'group'; group: GroupLite }
|
||||||
|
| { type: 'request'; request: FriendRequest }
|
||||||
|
|
||||||
const selection = ref<Selection | null>(null)
|
const selection = ref<Selection | null>(null)
|
||||||
const keyword = ref('')
|
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 做字母分桶 / 拼音搜索 */
|
/** 好友列表的展示快照:附带后端算好的拼音,给 FriendList 做字母分桶 / 拼音搜索 */
|
||||||
const friends = computed<FriendLite[]>(() =>
|
const friends = computed<FriendLite[]>(() =>
|
||||||
friendStore.getActiveFriends.map((friend: Friend) => ({
|
friendStore.getActiveFriends.map((friend: Friend) => ({
|
||||||
|
|
@ -145,7 +173,11 @@ const friendUser = computed<User | null>(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
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 }
|
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) {
|
function handleChatFriend(friend: FriendLite) {
|
||||||
// 从 friendStore 同步备注 + 免打扰,避免新建会话用过期数据
|
// 从 friendStore 同步备注 + 免打扰,避免新建会话用过期数据
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@
|
||||||
:member="member"
|
:member="member"
|
||||||
:size="50"
|
:size="50"
|
||||||
clickable
|
clickable
|
||||||
|
:group-name="group.name"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 添加(任何成员都能邀请) -->
|
<!-- 添加(任何成员都能邀请) -->
|
||||||
|
|
|
||||||
|
|
@ -12,20 +12,32 @@ import type { User } from '../types'
|
||||||
*/
|
*/
|
||||||
export const useImUiStore = defineStore('imUiStore', () => {
|
export const useImUiStore = defineStore('imUiStore', () => {
|
||||||
// ==================== 用户名片 UserInfoCard ====================
|
// ==================== 用户名片 UserInfoCard ====================
|
||||||
// 用户名片悬浮卡:头像 / 昵称等触发点遍布会话、群成员、@ 选择器等列表,
|
// 用户名片悬浮卡
|
||||||
|
|
||||||
const userInfoCard = reactive({
|
const userInfoCard = reactive({
|
||||||
show: false,
|
show: false,
|
||||||
user: null as User | null,
|
user: null as User | null,
|
||||||
position: { x: 0, y: 0 }
|
position: { x: 0, y: 0 },
|
||||||
|
// TODO @AI:1 要走枚举
|
||||||
|
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 @AI:1 要走枚举
|
||||||
|
addSource: number = 1,
|
||||||
|
addSourceExtra: string = ''
|
||||||
|
) {
|
||||||
const viewportWidth = document.documentElement.clientWidth
|
const viewportWidth = document.documentElement.clientWidth
|
||||||
const viewportHeight = document.documentElement.clientHeight
|
const viewportHeight = document.documentElement.clientHeight
|
||||||
userInfoCard.user = user
|
userInfoCard.user = user
|
||||||
userInfoCard.position.x = Math.min(position.x, viewportWidth - 350)
|
userInfoCard.position.x = Math.min(position.x, viewportWidth - 350)
|
||||||
userInfoCard.position.y = Math.min(position.y, viewportHeight - 220)
|
userInfoCard.position.y = Math.min(position.y, viewportHeight - 220)
|
||||||
|
userInfoCard.addSource = addSource
|
||||||
|
userInfoCard.addSourceExtra = addSourceExtra
|
||||||
userInfoCard.show = true
|
userInfoCard.show = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue