Merge remote-tracking branch 'origin/master'

pull/882/MERGE
YunaiV 2026-06-06 23:40:27 +08:00
commit 8f24a79444
206 changed files with 32792 additions and 318 deletions

1
.gitignore vendored
View File

@ -7,3 +7,4 @@ pnpm-debug
auto-*.d.ts auto-*.d.ts
.idea .idea
.history .history
output/

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

98
.vscode/settings.json vendored
View File

@ -1,5 +1,7 @@
{ {
"typescript.tsdk": "node_modules/typescript/lib", "typescript.tsdk": "./node_modules/typescript/lib",
"volar.tsPlugin": true,
"volar.tsPluginStatus": false,
"npm.packageManager": "pnpm", "npm.packageManager": "pnpm",
"editor.tabSize": 2, "editor.tabSize": 2,
"prettier.printWidth": 100, // "prettier.printWidth": 100, //
@ -83,54 +85,75 @@
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "esbenp.prettier-vscode"
}, },
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit", "source.fixAll.eslint": "explicit"
"source.fixAll.stylelint": "explicit"
}, },
"editor.formatOnSave": true, "editor.formatOnSave": true,
"[vue]": { "[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.fixAll.stylelint": "explicit"
}
}, },
"i18n-ally.localesPaths": ["src/locales"], "i18n-ally.localesPaths": ["src/locales"],
"i18n-ally.keystyle": "nested", "i18n-ally.keystyle": "nested",
"i18n-ally.sortKeys": true, "i18n-ally.sortKeys": true,
"i18n-ally.namespace": false, "i18n-ally.namespace": true,
"i18n-ally.enabledParsers": ["ts"], "i18n-ally.enabledParsers": ["ts"],
"i18n-ally.sourceLanguage": "en", "i18n-ally.sourceLanguage": "en",
"i18n-ally.displayLanguage": "zh-CN", "i18n-ally.displayLanguage": "zh-CN",
"i18n-ally.enabledFrameworks": ["vue", "react"], "i18n-ally.enabledFrameworks": ["vue", "react"],
"cSpell.words": [ "cSpell.words": [
"brotli",
"browserslist",
"codemirror",
"commitlint",
"cropperjs",
"echart",
"echarts",
"esnext",
"esno",
"iconify",
"INTLIFY",
"lintstagedrc",
"logicflow",
"nprogress",
"pinia",
"pnpm",
"qrcode",
"sider",
"sortablejs",
"stylelint",
"svgs",
"unocss",
"unplugin",
"unref",
"videojs",
"VITE",
"vitejs",
"vueuse",
"wangeditor",
"xingyu", "xingyu",
"yudao", "yudao",
"zxcvbn" "unocss",
"browserslist",
"esnext",
"unplugin",
"qrcode",
"sider",
"pinia",
"sider",
"nprogress",
"INTLIFY",
"stylelint",
"esno",
"vitejs",
"sortablejs",
"codemirror",
"iconify",
"commitlint",
"videojs",
"echarts",
"wangeditor",
"cropperjs",
"logicflow",
"vueuse",
"zxcvbn",
"lintstagedrc",
"brotli",
"sider",
"pnpm"
],
"vetur.format.scriptInitialIndent": true,
"vetur.format.styleInitialIndent": true,
"vetur.validation.script": false,
"MicroPython.executeButton": [
{
"text": "▶",
"tooltip": "运行",
"alignment": "left",
"command": "extension.executeFile",
"priority": 3.5
}
],
"MicroPython.syncButton": [
{
"text": "$(sync)",
"tooltip": "同步",
"alignment": "left",
"command": "extension.execute",
"priority": 4
}
], ],
// //
"explorer.fileNesting.enabled": true, "explorer.fileNesting.enabled": true,
@ -139,8 +162,7 @@
"*.ts": "$(capture).test.ts, $(capture).test.tsx", "*.ts": "$(capture).test.ts, $(capture).test.tsx",
"*.tsx": "$(capture).test.ts, $(capture).test.tsx", "*.tsx": "$(capture).test.ts, $(capture).test.tsx",
"*.env": "$(capture).env.*", "*.env": "$(capture).env.*",
"package.json": "pnpm-lock.yaml,yarn.lock,LICENSE,README*,CHANGELOG*,CNAME,.gitattributes,.eslintrc-auto-import.json,.gitignore,prettier.config.js,stylelint.config.js,commitlint.config.js,.stylelintignore,.prettierignore,.gitpod.yml,.eslintrc.js,.eslintignore" "package.json": "pnpm-lock.yaml,yarn.lock,LICENSE,README*,CHANGELOG*,CNAME,.gitattributes,.gitignore,prettier.config.js,stylelint.config.js,commitlint.config.js,.stylelintignore,.prettierignore,.gitpod.yml,.eslintrc.js,.eslintignore"
}, },
"terminal.integrated.scrollback": 10000, "terminal.integrated.scrollback": 10000
"nuxt.isNuxtApp": false
} }

View File

@ -284,6 +284,19 @@
![预览图](/.image/common/iot-preview.png) ![预览图](/.image/common/iot-preview.png)
### IM 即时通讯
演示地址Vue3 + Element Plus<http://dashboard-vue3.yudao.iocoder.cn>
使用文档:<https://doc.iocoder.cn/im-preview/>
![功能图](/.image/common/im-feature.png)
| 聊天界面 | 聊天管理 |
| --- | --- |
| ![聊天界面](/.image/common/im-preview-home.png) | ![聊天管理](/.image/common/im-preview-manager.png) |
## 🐷 演示图 ## 🐷 演示图
### 系统功能 ### 系统功能

View File

@ -1,6 +1,6 @@
{ {
"name": "yudao-ui-admin-vue3", "name": "yudao-ui-admin-vue3",
"version": "2026.04-snapshot", "version": "2026.05-snapshot",
"description": "基于vue3、vite4、element-plus、typesScript", "description": "基于vue3、vite4、element-plus、typesScript",
"author": "xingyu", "author": "xingyu",
"private": false, "private": false,
@ -56,6 +56,7 @@
"jsbarcode": "^3.12.3", "jsbarcode": "^3.12.3",
"jsencrypt": "^3.5.4", "jsencrypt": "^3.5.4",
"jsoneditor": "^10.4.3", "jsoneditor": "^10.4.3",
"livekit-client": "^2.18.9",
"lodash-es": "^4.18.1", "lodash-es": "^4.18.1",
"markdown-it": "^14.1.1", "markdown-it": "^14.1.1",
"markmap-common": "^0.18.9", "markmap-common": "^0.18.9",

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,18 @@
import request from '@/config/axios'
// 用户端能看到的频道素材详情
export interface ImChannelMaterialRespVO {
id: number
channelId: number
type: number
title: string
coverUrl?: string
summary?: string
content?: string
url?: string
}
// 获取频道素材详情;用于客户端点击图文卡片渲染详情页
export const getChannelMaterial = (id: number) => {
return request.get<ImChannelMaterialRespVO>({ url: '/im/channel/material/get', params: { id } })
}

View File

@ -0,0 +1,23 @@
import request from '@/config/axios'
// 用户端表情包项(精简版)
export interface ImFacePackUserItemVO {
id: number
url: string
name?: string
width: number
height: number
}
// 用户端表情包 + 嵌套 items
export interface ImFacePackUserVO {
id: number
name: string
icon?: string
items: ImFacePackUserItemVO[]
}
// 拉取所有启用的系统表情包(含表情列表)
export const getFacePackList = () => {
return request.get<ImFacePackUserVO[]>({ url: '/im/face-pack/list' })
}

View File

@ -0,0 +1,33 @@
import request from '@/config/axios'
// 个人表情
export interface ImFaceUserItemVO {
id: number
url: string
name?: string
width: number
height: number
}
// 添加个人表情请求
export interface ImFaceUserItemSaveReqVO {
url: string
name?: string
width: number
height: number
}
// 获取我的个人表情列表
export const getFaceUserItemList = () => {
return request.get<ImFaceUserItemVO[]>({ url: '/im/face-user-item/list' })
}
// 添加个人表情
export const createFaceUserItem = (data: ImFaceUserItemSaveReqVO) => {
return request.post<number>({ url: '/im/face-user-item/create', data })
}
// 删除个人表情
export const deleteFaceUserItem = (id: number) => {
return request.delete({ url: '/im/face-user-item/delete', params: { id } })
}

View File

@ -0,0 +1,62 @@
import request from '@/config/axios'
// IM 好友 Response VO
export interface ImFriendRespVO {
id: number // 关系记录编号
friendUserId: number // 好友的用户编号
silent?: boolean // 是否免打扰
displayName?: string // 好友展示备注(仅自己可见)
displayNamePinyin?: string // 备注的拼音(小写无空格,前端按首字母分桶 / 拼音搜索)
addSource?: number // 添加来源;参见 ImFriendAddSourceEnum
pinned?: boolean // 是否置顶联系人
blocked?: boolean // 是否拉黑
status?: number // 好友状态0=正常1=已删除)
addTime?: string // 添加好友时间
deleteTime?: string // 删除好友时间
// 聚合字段(自 AdminUser
nickname?: string // 好友昵称
nicknamePinyin?: string // 昵称的拼音(小写无空格,前端按首字母分桶 / 拼音搜索)
avatar?: string // 好友头像
}
// IM 好友更新 Request VO
export interface ImFriendUpdateReqVO {
friendUserId: number // 好友的用户编号
silent?: boolean // 是否免打扰
displayName?: string // 好友展示备注
pinned?: boolean // 是否置顶联系人
}
// 获得当前登录用户的好友列表
export const getMyFriendList = () => {
return request.get<ImFriendRespVO[]>({ url: '/im/friend/list' })
}
// 获得好友详情
export const getFriend = (friendUserId: number | string) => {
return request.get<ImFriendRespVO>({ url: '/im/friend/get', params: { friendUserId } })
}
// 删除好友(单向软删除)
export const deleteFriend = (friendUserId: number | string, clear: boolean) => {
return request.delete<boolean>({
url: '/im/friend/delete',
params: { friendUserId, clear }
})
}
// 更新好友信息(备注 / 免打扰 / 联系人置顶)
export const updateFriend = (data: ImFriendUpdateReqVO) => {
return request.put<boolean>({ url: '/im/friend/update', data })
}
// 拉黑好友(必须先是好友;单边屏蔽对方私聊消息)
export const blockFriend = (friendUserId: number | string) => {
return request.put<boolean>({ url: '/im/friend/block', params: { friendUserId } })
}
// 移出黑名单
export const unblockFriend = (friendUserId: number | string) => {
return request.put<boolean>({ url: '/im/friend/unblock', params: { friendUserId } })
}

View File

@ -0,0 +1,65 @@
import request from '@/config/axios'
// IM 好友申请 Response VO
export interface ImFriendRequestRespVO {
id: number // 申请编号
fromUserId: number // 发起方用户编号
toUserId: number // 接收方用户编号
handleResult: number // 处理结果0=未处理1=同意2=拒绝
applyContent?: string // 申请理由
handleContent?: string // 处理理由(接收方拒绝时可选填)
addSource?: number // 添加来源;参见 ImFriendAddSourceEnum
handleTime?: string // 处理时间
createTime: string // 申请创建时间
// 聚合字段(自 AdminUser
fromNickname?: string // 发起方昵称
fromAvatar?: string // 发起方头像
toNickname?: string // 接收方昵称
toAvatar?: string // 接收方头像
}
// IM 好友申请发起 Request VO
export interface ImFriendRequestApplyReqVO {
toUserId: number // 接收方用户编号
applyContent?: string // 申请理由
displayName?: string // 对接收方的备注(仅自己可见)
addSource?: number // 添加来源
}
// 发起好友申请
export const applyFriendRequest = (data: ImFriendRequestApplyReqVO) => {
return request.post<number | null>({ url: '/im/friend-request/apply', data })
}
// 同意好友申请
export const agreeFriendRequest = (id: number | string) => {
return request.put<boolean>({ url: '/im/friend-request/agree', params: { id } })
}
// 拒绝好友申请
export const refuseFriendRequest = (id: number | string, handleContent?: string) => {
return request.put<boolean>({
url: '/im/friend-request/refuse',
params: { id, handleContent }
})
}
// 查询「我相关」的好友申请列表(游标分页:传 maxId 加载更多)
export const getMyFriendRequestList = (limit: number, maxId?: number) => {
const params: Record<string, number> = { limit }
if (maxId != null) {
params.maxId = maxId
}
return request.get<ImFriendRequestRespVO[]>({
url: '/im/friend-request/list',
params
})
}
// 按 id 单查「我相关」的申请记录带越权过滤WebSocket 通知到达后用)
export const getMyFriendRequest = (id: number) => {
return request.get<ImFriendRequestRespVO | null>({
url: '/im/friend-request/get',
params: { id }
})
}

137
src/api/im/group/index.ts Normal file
View File

@ -0,0 +1,137 @@
import request from '@/config/axios'
import type { ImGroupMessageRespVO } from '@/api/im/message/group'
// 群 Response VO
export interface ImGroupRespVO {
id: number // 编号
name: string // 群名称
ownerUserId: number // 群主用户编号
avatar?: string // 群头像
notice?: string // 群公告
banned?: boolean // 是否封禁
mutedAll?: boolean // 是否全群禁言
joinApproval?: boolean // 进群是否需群主 / 管理员审批
bannedTime?: string // 封禁时间
status: number // 群状态0=正常1=已解散)
dissolvedTime?: string // 解散时间
createTime?: string // 创建时间
pinnedMessages?: ImGroupMessageRespVO[] // 群置顶消息列表(后端关联回填,仅当登录用户是群成员时非空)
}
// 群消息置顶 / 取消置顶 Request VO
export interface ImGroupMessagePinReqVO {
id: number // 群编号
messageId: number // 消息编号
}
// 群创建 Request VO
export interface ImGroupCreateReqVO {
name: string // 群名称
memberUserIds?: number[] // 初始成员用户编号列表(建群同时邀请的好友,不含创建者自己)
joinApproval?: boolean // 进群是否需审批;不传默认 false 自由进群
}
// 群更新 Request VO
export interface ImGroupUpdateReqVO {
id: number // 群编号
name?: string // 群名称
avatar?: string // 群头像
notice?: string // 群公告
joinApproval?: boolean // 进群是否需审批
}
// 添加 / 撤销群管理员 Request VO
export interface ImGroupAdminReqVO {
id: number // 群编号
userIds: number[] // 目标用户编号列表
}
// 群主转让 Request VO
export interface ImGroupTransferOwnerReqVO {
id: number // 群编号
newOwnerUserId: number // 新群主用户编号
}
// 全群禁言 / 取消 Request VO
export interface ImGroupMuteAllReqVO {
id: number // 群编号
mutedAll: boolean // 是否全群禁言
}
// 成员禁言 Request VO
export interface ImGroupMuteMemberReqVO {
id: number // 群编号
userId: number // 被禁言的用户编号
mutedSeconds: number // 禁言时长0 表示永久禁言
}
// 取消成员禁言 Request VO
export interface ImGroupCancelMuteMemberReqVO {
id: number // 群编号
userId: number // 被取消禁言的用户编号
}
// 获得当前登录用户的群列表
export const getMyGroupList = () => {
return request.get<ImGroupRespVO[]>({ url: '/im/group/list' })
}
// 获得群详情
export const getGroup = (id: number | string) => {
return request.get<ImGroupRespVO>({ url: '/im/group/get', params: { id } })
}
// 创建群
export const createGroup = (data: ImGroupCreateReqVO) => {
return request.post<ImGroupRespVO>({ url: '/im/group/create', data })
}
// 更新群
export const updateGroup = (data: ImGroupUpdateReqVO) => {
return request.put<ImGroupRespVO>({ url: '/im/group/update', data })
}
// 解散群
export const dissolveGroup = (id: number | string) => {
return request.delete<boolean>({ url: '/im/group/dissolve', params: { id } })
}
// 添加群管理员(仅群主可调)
export const addGroupAdmin = (data: ImGroupAdminReqVO) => {
return request.put<boolean>({ url: '/im/group/add-admin', data })
}
// 撤销群管理员(仅群主可调)
export const removeGroupAdmin = (data: ImGroupAdminReqVO) => {
return request.put<boolean>({ url: '/im/group/remove-admin', data })
}
// 转让群主(仅老群主可调;旧群主转让后降为普通成员)
export const transferGroupOwner = (data: ImGroupTransferOwnerReqVO) => {
return request.put<boolean>({ url: '/im/group/transfer-owner', data })
}
// 置顶群消息(仅群主 / 管理员可调)
export const pinGroupMessage = (data: ImGroupMessagePinReqVO) => {
return request.put<boolean>({ url: '/im/group/pin-message', data })
}
// 取消置顶群消息(仅群主 / 管理员可调)
export const unpinGroupMessage = (data: ImGroupMessagePinReqVO) => {
return request.put<boolean>({ url: '/im/group/unpin-message', data })
}
// 全群禁言 / 取消(仅群主 / 管理员可调)
export const muteAll = (data: ImGroupMuteAllReqVO) => {
return request.put<boolean>({ url: '/im/group/mute-all', data })
}
// 禁言成员
export const muteMember = (data: ImGroupMuteMemberReqVO) => {
return request.put<boolean>({ url: '/im/group/mute-member', data })
}
// 取消成员禁言
export const cancelMuteMember = (data: ImGroupCancelMuteMemberReqVO) => {
return request.put<boolean>({ url: '/im/group/cancel-mute-member', data })
}

View File

@ -0,0 +1,76 @@
import request from '@/config/axios'
// 群成员 Response VO
export interface ImGroupMemberRespVO {
id: number // 编号
groupId: number // 群编号
userId: number // 用户编号
displayUserName?: string // 组内显示名(群主设置的备注)
groupRemark?: string // 群备注(当前用户对群的备注)
silent?: boolean // 是否免打扰
status?: number // 成员状态0=在群1=退群)
role?: number // 成员角色,参见 ImGroupMemberRole 枚举
joinTime?: string // 入群时间
quitTime?: string // 退群时间
muteEndTime?: string // 禁言到期时间
createTime?: string // 创建时间
// 聚合字段(自 AdminUser
nickname?: string // 用户昵称
avatar?: string // 用户头像
}
// 群成员邀请 Request VO
export interface ImGroupMemberInviteReqVO {
groupId: number // 群编号
memberUserIds: number[] // 被邀请的用户编号列表
}
// 群成员移除 Request VO
export interface ImGroupMemberRemoveReqVO {
groupId: number // 群编号
memberUserIds: number[] // 被移除的用户编号列表
}
// 群成员更新 Request VO
export interface ImGroupMemberUpdateReqVO {
groupId: number // 群编号
displayUserName?: string // 群内昵称
groupRemark?: string // 群备注
silent?: boolean // 是否免打扰
}
// 邀请用户加入群
export const inviteGroupMember = (data: ImGroupMemberInviteReqVO) => {
return request.post<boolean>({ url: '/im/group/invite', data })
}
// 退出群
export const quitGroup = (groupId: number | string) => {
return request.delete<boolean>({ url: '/im/group/quit', params: { groupId } })
}
// 移除群成员
export const removeGroupMember = (data: ImGroupMemberRemoveReqVO) => {
return request.delete<boolean>({ url: '/im/group/kicking', data })
}
// 获得群成员详情
export const getGroupMember = (groupId: number, userId: number) => {
return request.get<ImGroupMemberRespVO>({
url: '/im/group-member/get',
params: { groupId, userId }
})
}
// 获得指定群的成员列表(聚合 AdminUser 昵称 / 头像)
export const getGroupMemberList = (groupId: number | string) => {
return request.get<ImGroupMemberRespVO[]>({
url: '/im/group-member/list',
params: { groupId }
})
}
// 更新群成员
export const updateGroupMember = (data: ImGroupMemberUpdateReqVO) => {
return request.put<boolean>({ url: '/im/group-member/update', data })
}

View File

@ -0,0 +1,71 @@
import request from '@/config/axios'
// IM 加群申请 Response VO
export interface ImGroupRequestRespVO {
id: number // 申请编号
groupId: number // 群编号
userId: number // 申请人 / 被邀请人用户编号
inviterUserId?: number // 邀请人NULL 表示用户主动申请
handleResult: number // 处理结果0=未处理1=同意2=拒绝
applyContent?: string // 申请理由
handleContent?: string // 处理理由(拒绝时可选填)
handleUserId?: number // 处理人用户编号
addSource?: number // 加入来源;参见 ImGroupAddSourceEnum
handleTime?: string // 处理时间
createTime: string // 申请创建时间
// 聚合字段
userNickname?: string // 申请人 / 被邀请人昵称
userAvatar?: string // 申请人 / 被邀请人头像
inviterNickname?: string // 邀请人昵称
inviterAvatar?: string // 邀请人头像
groupName?: string // 群名称
groupAvatar?: string // 群头像
}
// IM 加群申请发起 Request VO
export interface ImGroupRequestApplyReqVO {
groupId: number // 群编号
applyContent?: string // 申请理由
addSource?: number // 加入来源
}
// 申请加群
export const applyJoinGroup = (data: ImGroupRequestApplyReqVO) => {
return request.post<number | null>({ url: '/im/group-request/apply', data })
}
// 同意加群申请(群主或管理员)
export const agreeGroupRequest = (id: number | string) => {
return request.put<boolean>({ url: '/im/group-request/agree', params: { id } })
}
// 拒绝加群申请(群主或管理员)
export const refuseGroupRequest = (id: number | string, handleContent?: string) => {
return request.put<boolean>({
url: '/im/group-request/refuse',
params: { id, handleContent }
})
}
// 查询「我管理的所有群」下的未处理加群申请列表(不分页);前端 store 据此派生横幅红点 + Drawer 列表
export const getUnhandledRequestList = () => {
return request.get<ImGroupRequestRespVO[]>({
url: '/im/group-request/unhandled-list'
})
}
// 查询指定群下的全部加群申请(含已处理);仅群主 / 管理员可查
export const getGroupRequestListByGroupId = (groupId: number) => {
return request.get<ImGroupRequestRespVO[]>({
url: '/im/group-request/list-by-group',
params: { groupId }
})
}
// 按 id 单查申请记录带越权过滤WebSocket 通知到达后用)
export const getMyGroupRequest = (id: number) => {
return request.get<ImGroupRequestRespVO | null>({
url: '/im/group-request/get',
params: { id }
})
}

View File

@ -0,0 +1,41 @@
import request from '@/config/axios'
export interface ImManagerChannelVO {
id: number
code: string
name: string
avatar?: string
sort: number
status: number
createTime?: Date
}
// 获得频道分页
export const getManagerChannelPage = (params: PageParam) => {
return request.get({ url: '/im/manager/channel/page', params })
}
// 获得频道详情
export const getManagerChannel = (id: number) => {
return request.get({ url: '/im/manager/channel/get', params: { id } })
}
// 新增频道
export const createManagerChannel = (data: ImManagerChannelVO) => {
return request.post({ url: '/im/manager/channel/create', data })
}
// 修改频道
export const updateManagerChannel = (data: ImManagerChannelVO) => {
return request.put({ url: '/im/manager/channel/update', data })
}
// 删除频道
export const deleteManagerChannel = (id: number) => {
return request.delete({ url: '/im/manager/channel/delete', params: { id } })
}
// 获得启用的频道精简列表(表单选择用)
export const getSimpleChannelList = () => {
return request.get<ImManagerChannelVO[]>({ url: '/im/manager/channel/simple-list' })
}

View File

@ -0,0 +1,47 @@
import request from '@/config/axios'
export interface ImManagerChannelMaterialVO {
id: number
channelId: number
channelName?: string
type: number
title: string
coverUrl?: string
summary?: string
content?: string
url?: string
createTime?: Date
}
// 获得素材分页
export const getManagerChannelMaterialPage = (params: PageParam) => {
return request.get({ url: '/im/manager/channel-material/page', params })
}
// 获得指定频道下的素材精简列表
export const getSimpleManagerChannelMaterialList = (channelId: number) => {
return request.get({
url: '/im/manager/channel-material/simple-list',
params: { channelId }
})
}
// 获得素材详情
export const getManagerChannelMaterial = (id: number) => {
return request.get({ url: '/im/manager/channel-material/get', params: { id } })
}
// 新增素材
export const createManagerChannelMaterial = (data: ImManagerChannelMaterialVO) => {
return request.post({ url: '/im/manager/channel-material/create', data })
}
// 修改素材
export const updateManagerChannelMaterial = (data: ImManagerChannelMaterialVO) => {
return request.put({ url: '/im/manager/channel-material/update', data })
}
// 删除素材
export const deleteManagerChannelMaterial = (id: number) => {
return request.delete({ url: '/im/manager/channel-material/delete', params: { id } })
}

View File

@ -0,0 +1,34 @@
import request from '@/config/axios'
export interface ImManagerChannelMessageVO {
id: number
channelId: number
channelName?: string
materialId: number
materialTitle?: string
materialCoverUrl?: string
type: number
content?: string
receiverUserIds?: number[]
sendTime?: Date
}
export interface ImManagerChannelMessageSendReqVO {
materialId: number
receiverUserIds?: number[]
}
// 立即推送频道消息
export const sendManagerChannelMessage = (data: ImManagerChannelMessageSendReqVO) => {
return request.post({ url: '/im/manager/channel-message/send', data })
}
// 删除频道消息
export const deleteManagerChannelMessage = (id: number) => {
return request.delete({ url: '/im/manager/channel-message/delete', params: { id } })
}
// 获得频道消息分页
export const getManagerChannelMessagePage = (params: PageParam) => {
return request.get({ url: '/im/manager/channel-message/page', params })
}

View File

@ -0,0 +1,46 @@
import request from '@/config/axios'
export interface ImManagerFacePackItemVO {
id: number
packId: number
url: string
name?: string
width: number
height: number
sort: number
status: number
createTime?: Date
}
// 获得表情分页
export const getManagerFacePackItemPage = (params: PageParam) => {
return request.get({ url: '/im/manager/face-pack-item/page', params })
}
// 获得表情详情
export const getManagerFacePackItem = (id: number) => {
return request.get({ url: '/im/manager/face-pack-item/get', params: { id } })
}
// 新增表情
export const createManagerFacePackItem = (data: ImManagerFacePackItemVO) => {
return request.post({ url: '/im/manager/face-pack-item/create', data })
}
// 修改表情
export const updateManagerFacePackItem = (data: ImManagerFacePackItemVO) => {
return request.put({ url: '/im/manager/face-pack-item/update', data })
}
// 删除表情
export const deleteManagerFacePackItem = (id: number) => {
return request.delete({ url: '/im/manager/face-pack-item/delete', params: { id } })
}
// 批量删除表情
export const deleteManagerFacePackItemList = (ids: number[]) => {
return request.delete({
url: '/im/manager/face-pack-item/delete-list',
params: { ids: ids.join(',') }
})
}

View File

@ -0,0 +1,43 @@
import request from '@/config/axios'
export interface ImManagerFacePackVO {
id: number
name: string
icon?: string
sort: number
status: number
createTime?: Date
}
// 获得表情包分页
export const getManagerFacePackPage = (params: PageParam) => {
return request.get({ url: '/im/manager/face-pack/page', params })
}
// 获得表情包详情
export const getManagerFacePack = (id: number) => {
return request.get({ url: '/im/manager/face-pack/get', params: { id } })
}
// 新增表情包
export const createManagerFacePack = (data: ImManagerFacePackVO) => {
return request.post({ url: '/im/manager/face-pack/create', data })
}
// 修改表情包
export const updateManagerFacePack = (data: ImManagerFacePackVO) => {
return request.put({ url: '/im/manager/face-pack/update', data })
}
// 删除表情包
export const deleteManagerFacePack = (id: number) => {
return request.delete({ url: '/im/manager/face-pack/delete', params: { id } })
}
// 批量删除表情包
export const deleteManagerFacePackList = (ids: number[]) => {
return request.delete({
url: '/im/manager/face-pack/delete-list',
params: { ids: ids.join(',') }
})
}

View File

@ -0,0 +1,22 @@
import request from '@/config/axios'
export interface ImManagerFaceUserItemVO {
id: number
userId: number
userNickname?: string
url: string
name?: string
width?: number
height?: number
createTime?: Date
}
// 获得用户表情分页
export const getManagerFaceUserItemPage = (params: PageParam) => {
return request.get({ url: '/im/manager/face-user-item/page', params })
}
// 删除用户表情
export const deleteManagerFaceUserItem = (id: number) => {
return request.delete({ url: '/im/manager/face-user-item/delete', params: { id } })
}

View File

@ -0,0 +1,23 @@
import request from '@/config/axios'
export interface ImManagerFriendVO {
id: number
userId: number
userNickname?: string
friendUserId: number
friendNickname?: string
displayName?: string
addSource?: number
silent: boolean
pinned: boolean
blocked: boolean
status: number
addTime?: Date
deleteTime?: Date
createTime: Date
}
// 获得好友关系分页
export const getManagerFriendPage = (params: PageParam) => {
return request.get({ url: '/im/manager/friend/page', params })
}

View File

@ -0,0 +1,21 @@
import request from '@/config/axios'
export interface ImManagerFriendRequestVO {
id: number
fromUserId: number
fromNickname?: string
toUserId: number
toNickname?: string
applyContent?: string
displayName?: string
addSource?: number
handleResult: number
handleContent?: string
handleTime?: Date
createTime: Date
}
// 获得好友申请分页
export const getManagerFriendRequestPage = (params: PageParam) => {
return request.get({ url: '/im/manager/friend-request/page', params })
}

View File

@ -0,0 +1,62 @@
import request from '@/config/axios'
export interface ImManagerGroupVO {
id: number
name: string
avatar?: string
notice?: string
ownerUserId: number
ownerNickname?: string
memberCount?: number
status: number
banned: boolean
mutedAll?: boolean // 是否全群禁言
bannedReason?: string
bannedTime?: Date
dissolvedTime?: Date
createTime: Date
}
export interface ImManagerGroupMemberVO {
userId: number
nickname?: string
avatar?: string
displayUserName?: string
groupRemark?: string
silent?: boolean
status: number
role?: number // 成员角色,参见 ImGroupMemberRole 枚举
joinTime?: Date
quitTime?: Date
muteEndTime?: Date // 禁言到期时间
}
// 获得群分页
export const getManagerGroupPage = (params: PageParam) => {
return request.get({ url: '/im/manager/group/page', params })
}
// 获得群详情
export const getManagerGroup = (id: number) => {
return request.get({ url: '/im/manager/group/get', params: { id } })
}
// 封禁群
export const banManagerGroup = (data: { id: number; reason: string }) => {
return request.put({ url: '/im/manager/group/ban', data })
}
// 解封群
export const unbanManagerGroup = (id: number) => {
return request.put({ url: '/im/manager/group/unban', params: { id } })
}
// 解散群
export const dissolveManagerGroup = (id: number) => {
return request.delete({ url: '/im/manager/group/dissolve', params: { id } })
}
// 获得群成员列表(含已退群成员,由前端按需过滤)
export const getManagerGroupMemberList = (groupId: number) => {
return request.get({ url: '/im/manager/group/member/list', params: { groupId } })
}

View File

@ -0,0 +1,24 @@
import request from '@/config/axios'
export interface ImManagerGroupRequestVO {
id: number
groupId: number
groupName?: string
userId: number
userNickname?: string
inviterUserId?: number
inviterNickname?: string
applyContent?: string
addSource?: number
handleResult: number
handleUserId?: number
handleNickname?: string
handleContent?: string
handleTime?: Date
createTime: Date
}
// 获得加群申请分页
export const getManagerGroupRequestPage = (params: PageParam) => {
return request.get({ url: '/im/manager/group-request/page', params })
}

View File

@ -0,0 +1,29 @@
import request from '@/config/axios'
export interface ImManagerGroupMessageVO {
id: number
clientMessageId?: string
groupId: number
groupName?: string
senderId: number
senderNickname?: string
type: number
content: string
status: number
atUserIds?: number[]
// 与 atUserIds 同长度;后端对找不到 / 已删除的成员返回 nullUI 用 `?.[idx] || userId` 回退到 userId 渲染
atUserNicknames?: (string | null)[]
receiptStatus?: number
sendTime: Date
createTime: Date
}
// 获得群聊消息分页
export const getManagerGroupMessagePage = (params: PageParam) => {
return request.get({ url: '/im/manager/message/group/page', params })
}
// 获得群聊消息详情
export const getManagerGroupMessage = (id: number) => {
return request.get({ url: '/im/manager/message/group/get', params: { id } })
}

View File

@ -0,0 +1,25 @@
import request from '@/config/axios'
export interface ImManagerPrivateMessageVO {
id: number
clientMessageId?: string
senderId: number
senderNickname?: string
receiverId: number
receiverNickname?: string
type: number
content: string
status: number
sendTime: Date
createTime: Date
}
// 获得私聊消息分页
export const getManagerPrivateMessagePage = (params: PageParam) => {
return request.get({ url: '/im/manager/message/private/page', params })
}
// 获得私聊消息详情
export const getManagerPrivateMessage = (id: number) => {
return request.get({ url: '/im/manager/message/private/get', params: { id } })
}

View File

@ -0,0 +1,40 @@
import request from '@/config/axios'
export interface ImManagerRtcCallVO {
id: number
room: string
conversationType: number
mediaType: number
inviterUserId: number
inviterNickname?: string
groupId?: number
groupName?: string
status: number
endReason?: number
startTime: Date
acceptTime?: Date
endTime?: Date
createTime: Date
}
export interface ImManagerRtcParticipantVO {
id: number
callId: number
userId: number
userNickname?: string
role: number
status: number
inviteTime: Date
acceptTime?: Date
leaveTime?: Date
}
// 获得通话记录分页
export const getManagerRtcCallPage = (params: PageParam) => {
return request.get({ url: '/im/manager/rtc/page', params })
}
// 获得通话参与者列表
export const getManagerRtcCallParticipantList = (id: number) => {
return request.get({ url: '/im/manager/rtc/participant-list', params: { id } })
}

View File

@ -0,0 +1,43 @@
import request from '@/config/axios'
export interface ImManagerSensitiveWordVO {
id: number
word: string
status: number
creator?: string
creatorName?: string
createTime?: Date
}
// 获得敏感词分页
export const getManagerSensitiveWordPage = (params: PageParam) => {
return request.get({ url: '/im/manager/sensitive-word/page', params })
}
// 获得敏感词详情
export const getManagerSensitiveWord = (id: number) => {
return request.get({ url: '/im/manager/sensitive-word/get', params: { id } })
}
// 新增敏感词
export const createManagerSensitiveWord = (data: ImManagerSensitiveWordVO) => {
return request.post({ url: '/im/manager/sensitive-word/create', data })
}
// 修改敏感词
export const updateManagerSensitiveWord = (data: ImManagerSensitiveWordVO) => {
return request.put({ url: '/im/manager/sensitive-word/update', data })
}
// 删除敏感词
export const deleteManagerSensitiveWord = (id: number) => {
return request.delete({ url: '/im/manager/sensitive-word/delete', params: { id } })
}
// 批量删除敏感词
export const deleteManagerSensitiveWordList = (ids: number[]) => {
return request.delete({
url: '/im/manager/sensitive-word/delete-list',
params: { ids: ids.join(',') }
})
}

View File

@ -0,0 +1,70 @@
import request from '@/config/axios'
export interface ImStatisticsOverviewVO {
totalUser: number
newUserToday: number
totalGroup: number
newGroupToday: number
activeUserDaily: number
activeUserWeekly: number
activeUserMonthly: number
privateMessageToday: number
groupMessageToday: number
privateMessageYesterday: number
groupMessageYesterday: number
}
export interface ImStatisticsTrendVO {
dates: string[]
series: Record<string, number[]>
}
export interface ImStatisticsMessageTypeVO {
type: number // 参见 ImMessageTypeEnum 枚举类,由前端按 DICT_TYPE.IM_MESSAGE_TYPE 翻译
value: number
}
export interface ImStatisticsGroupSizeVO {
range: string
count: number
}
export interface ImStatisticsTopSenderVO {
userId: number
nickname: string
messageCount: number
}
// 获得 KPI 概览
export const getStatisticsOverview = (): Promise<ImStatisticsOverviewVO> => {
return request.get<ImStatisticsOverviewVO>({ url: '/im/manager/statistics/overview' })
}
// 获得消息趋势(私聊 + 群聊双线)
export const getMessageTrend = (days: number): Promise<ImStatisticsTrendVO> => {
return request.get<ImStatisticsTrendVO>({ url: '/im/manager/statistics/message-trend', params: { days } })
}
// 获得用户趋势(新增注册 + 日活双线)
export const getUserTrend = (days: number): Promise<ImStatisticsTrendVO> => {
return request.get<ImStatisticsTrendVO>({ url: '/im/manager/statistics/user-trend', params: { days } })
}
// 获得消息类型分布(最近 30 天)
export const getMessageTypeDistribution = (): Promise<ImStatisticsMessageTypeVO[]> => {
return request.get<ImStatisticsMessageTypeVO[]>({
url: '/im/manager/statistics/message-type-distribution'
})
}
// 获得群规模分布
export const getGroupSizeDistribution = (): Promise<ImStatisticsGroupSizeVO[]> => {
return request.get<ImStatisticsGroupSizeVO[]>({
url: '/im/manager/statistics/group-size-distribution'
})
}
// 获得消息 TOP 发送者(最近 30 天)
export const getTopSenders = (): Promise<ImStatisticsTopSenderVO[]> => {
return request.get<ImStatisticsTopSenderVO[]>({ url: '/im/manager/statistics/top-senders' })
}

View File

@ -0,0 +1,30 @@
import request from '@/config/axios'
export interface ImChannelMessageRespVO {
id: number
clientMessageId?: string
channelId: number
materialId: number
type: number
content: string
/** 当前用户已读态pull 时按 Redis 游标计算填充,多端同步使用 */
status?: number
sendTime: string
}
// 拉取当前用户应收的频道消息(离线增量);按 minId 游标分页
export const pullChannelMessages = (params: { minId: number; size?: number }, signal?: AbortSignal) => {
return request.get<ImChannelMessageRespVO[]>({
url: '/im/channel/message/pull',
params,
signal
})
}
// 上报频道消息已读位置;切到频道会话或拉到新消息后调
export const readChannelMessages = (channelId: number, messageId: number) => {
return request.put({
url: '/im/channel/message/read',
params: { channelId, messageId }
})
}

View File

@ -0,0 +1,76 @@
import request from '@/config/axios'
// 群聊消息 Response VO
export interface ImGroupMessageRespVO {
id: number // 消息编号
clientMessageId: string // 客户端消息编号
senderId: number // 发送人编号
groupId: number // 群编号
type: number // 消息类型
content: string // 消息内容JSON 格式)
status: number // 消息状态
sendTime: string // 发送时间
atUserIds?: number[] // @ 目标用户编号列表
receiverUserIds?: number[] // 定向接收用户编号列表
receiptStatus?: number // 回执状态
readCount?: number // 已读人数(回执消息、且发送人为当前用户时有值)
}
// 群聊消息发送 Request VO
export interface ImGroupMessageSendReqVO {
clientMessageId: string // 客户端消息编号
groupId: number // 群编号
type: number // 消息类型
content: string // 消息内容JSON 格式)
atUserIds?: number[] // @ 目标用户编号列表
receipt?: boolean // 是否需要回执
}
// 群聊历史消息列表 Request VO
export interface ImGroupMessageListReqVO {
groupId: number | string // 群编号
maxId?: number | string // 起始消息编号(不含),为空则从最新消息开始
limit: number // 拉取数量1 ~ 200
}
// 发送群聊消息
export const sendGroupMessage = (data: ImGroupMessageSendReqVO) => {
return request.post<ImGroupMessageRespVO>({ url: '/im/message/group/send', data })
}
// 拉取群聊消息(增量)
export const pullGroupMessages = (
params: { minId: number | string; size: number },
signal?: AbortSignal
) => {
return request.get<ImGroupMessageRespVO[]>({ url: '/im/message/group/pull', params, signal })
}
// 查询群聊历史消息
export const getGroupMessageList = (params: ImGroupMessageListReqVO) => {
return request.get<ImGroupMessageRespVO[]>({ url: '/im/message/group/list', params })
}
// 标记群聊消息已读
export const readGroupMessages = (groupId: number | string, messageId: number | string) => {
return request.put<boolean>({
url: '/im/message/group/read',
params: { groupId, messageId }
})
}
// 撤回群聊消息
export const recallGroupMessage = (id: number | string) => {
return request.delete<ImGroupMessageRespVO>({
url: '/im/message/group/recall',
params: { id }
})
}
// 获取群消息已读用户列表
export const getGroupReadUsers = (params: {
groupId: number | string
messageId: number | string
}) => {
return request.get<number[]>({ url: '/im/message/group/get-read-user-ids', params })
}

View File

@ -0,0 +1,71 @@
import request from '@/config/axios'
// 私聊消息 Response VO
export interface ImPrivateMessageRespVO {
id: number // 消息编号
clientMessageId: string // 客户端消息编号
senderId: number // 发送人编号
receiverId: number // 接收人编号
type: number // 消息类型
content: string // 消息内容JSON 格式)
status: number // 消息状态
sendTime: string // 发送时间
}
// 私聊消息发送 Request VO
export interface ImPrivateMessageSendReqVO {
clientMessageId: string // 客户端消息编号
receiverId: number // 接收人编号
type: number // 消息类型
content: string // 消息内容JSON 格式)
}
// 私聊历史消息列表 Request VO
export interface ImPrivateMessageListReqVO {
receiverId: number | string // 接收人编号(对方)
maxId?: number | string // 起始消息编号(不含),为空则从最新消息开始
limit: number // 拉取数量1 ~ 200
}
// 发送私聊消息
export const sendPrivateMessage = (data: ImPrivateMessageSendReqVO) => {
return request.post<ImPrivateMessageRespVO>({ url: '/im/message/private/send', data })
}
// 拉取私聊消息(增量)
export const pullPrivateMessages = (
params: { minId: number | string; size: number },
signal?: AbortSignal
) => {
return request.get<ImPrivateMessageRespVO[]>({ url: '/im/message/private/pull', params, signal })
}
// 查询私聊历史消息
export const getPrivateMessageList = (params: ImPrivateMessageListReqVO) => {
return request.get<ImPrivateMessageRespVO[]>({ url: '/im/message/private/list', params })
}
// 标记私聊消息已读
export const readPrivateMessages = (receiverId: number | string, messageId: number | string) => {
return request.put<boolean>({
url: '/im/message/private/read',
params: { receiverId, messageId }
})
}
// 查询对方已读到我发的最大消息 id多端 / 离线后用于补齐已读状态)
export const getPrivateMaxReadMessageId = (peerId: number | string, signal?: AbortSignal) => {
return request.get<number | null>({
url: '/im/message/private/max-read-message-id',
params: { peerId },
signal
})
}
// 撤回私聊消息
export const recallPrivateMessage = (id: number | string) => {
return request.delete<ImPrivateMessageRespVO>({
url: '/im/message/private/recall',
params: { id }
})
}

88
src/api/im/rtc/index.ts Normal file
View File

@ -0,0 +1,88 @@
import request from '@/config/axios'
// 创建新通话请求 VO
export interface ImRtcCallCreateReqVO {
conversationType: number
mediaType: number
groupId?: number
inviteeIds: number[] // 被邀请的用户编号集合;私聊必传 1 个对端,群聊必传至少 1 人
}
// 通话中追加邀请请求 VO仅群通话可用
export interface ImRtcCallInviteReqVO {
room: string
inviteeIds: number[]
}
// 通话会话 VOcreate / join / accept / refreshToken 共用
export interface ImRtcCallRespVO {
room: string // 业务通话编号(同时作为 LiveKit 房间名)
livekitUrl: string
token?: string // ENDED 状态时为 null无需 connect LiveKit
conversationType: number
mediaType: number
status: number
endReason?: number // 结束原因;仅 status=ENDED 时有值
inviterId: number
groupId?: number
inviteeIds?: number[]
joinedUserIds?: number[]
}
// 群活跃通话查询响应;不含 token
export interface ImRtcGroupCallRespVO {
room: string
groupId: number
mediaType: number
inviterId: number
joinedUserIds?: number[]
inviteeIds?: number[]
}
// 创建新通话;私聊或群聊根据 conversationType 区分
export const createCall = (data: ImRtcCallCreateReqVO) => {
return request.post<ImRtcCallRespVO>({ url: '/im/rtc/create', data })
}
// 通话中追加邀请;仅群通话可用
export const inviteCall = (data: ImRtcCallInviteReqVO) => {
return request.post<boolean>({ url: '/im/rtc/invite', data })
}
// 加入已有群通话;用于胶囊条「加入」按钮
export const joinCall = (room: string) => {
return request.post<ImRtcCallRespVO>({ url: '/im/rtc/join', params: { room } })
}
// 接听通话
export const acceptCall = (room: string) => {
return request.post<ImRtcCallRespVO>({ url: '/im/rtc/accept', params: { room } })
}
// 拒绝通话
export const rejectCall = (room: string) => {
return request.post<boolean>({ url: '/im/rtc/reject', params: { room } })
}
// 取消邀请;主叫接通前调用
export const cancelCall = (room: string) => {
return request.post<boolean>({ url: '/im/rtc/cancel', params: { room } })
}
// 离开通话;接通后调用
export const leaveCall = (room: string) => {
return request.post<boolean>({ url: '/im/rtc/leave', params: { room } })
}
// 振铃超时检查RUNNING 端 timer 兜底,触发后端立即扫描该 room 的超时 INVITING接口静默
export const noAnswerCallCheck = (room: string) => {
return request.post<boolean>({ url: '/im/rtc/no-answer-call-check', params: { room } })
}
// 查询当前进行中的通话;目前仅群聊场景(胶囊条),返回 null 表示无活跃通话
export const getActiveCall = (groupId: number) => {
return request.get<ImRtcGroupCallRespVO | null>({
url: '/im/rtc/get-active-call',
params: { groupId }
})
}

View File

@ -10,6 +10,7 @@ export interface DeptVO {
phone: string phone: string
email: string email: string
createTime: Date createTime: Date
children?: DeptVO[]
} }
// 查询部门(精简)列表 // 查询部门(精简)列表

View File

@ -5,6 +5,7 @@ export interface UserVO {
username: string username: string
nickname: string nickname: string
deptId: number deptId: number
deptName?: string
postIds: string[] postIds: string[]
email: string email: string
mobile: string mobile: string
@ -15,7 +16,24 @@ export interface UserVO {
remark: string remark: string
loginDate: Date loginDate: Date
createTime: Date createTime: Date
disabled?: boolean }
// 获取用户精简信息列表
export const getSimpleUserList = (): Promise<UserVO[]> => {
return request.get({ url: '/system/user/simple-list' })
}
// 按用户编号查询用户精简信息(点头像弹名片)
export const getSimpleUser = (id: number | string) => {
return request.get<UserVO>({ url: '/system/user/get-simple', params: { id } })
}
// 按昵称模糊搜索用户(加好友)
export const getSimpleUserListByNickname = (nickname: string) => {
return request.get<UserVO[]>({
url: '/system/user/list-by-nickname',
params: { nickname }
})
} }
// 查询用户管理列表 // 查询用户管理列表
@ -80,8 +98,3 @@ export const updateUserStatus = (id: number, status: number) => {
} }
return request.put({ url: '/system/user/update-status', data: data }) return request.put({ url: '/system/user/update-status', data: data })
} }
// 获取用户精简信息列表
export const getSimpleUserList = (): Promise<UserVO[]> => {
return request.get({ url: '/system/user/simple-list' })
}

Binary file not shown.

View File

@ -2,7 +2,7 @@ import request from '@/config/axios'
import { isEmpty } from '@/utils/is' import { isEmpty } from '@/utils/is'
import { ApiSelectProps } from '@/components/FormCreate/src/type' import { ApiSelectProps } from '@/components/FormCreate/src/type'
import { jsonParse } from '@/utils' import { jsonParse } from '@/utils'
import { useUserStoreWithOut } from '@/store/modules/user' import { getCurrentUserId } from '@/utils/auth'
export const useApiSelect = (option: ApiSelectProps) => { export const useApiSelect = (option: ApiSelectProps) => {
return defineComponent({ return defineComponent({
@ -99,9 +99,7 @@ export const useApiSelect = (option: ApiSelectProps) => {
} }
// 获取当前用户 ID // 获取当前用户 ID
const userStore = useUserStoreWithOut() const currentUserId = getCurrentUserId()
const user = userStore.getUser
const currentUserId = user?.id
if (currentUserId) { if (currentUserId) {
// 根据多选/单选模式设置默认值 // 根据多选/单选模式设置默认值
const defaultValue = props.multiple ? [currentUserId] : currentUserId const defaultValue = props.multiple ? [currentUserId] : currentUserId

View File

@ -89,6 +89,22 @@ onMounted(() => {
</div> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
//
// DOM div.message > ElPopover > ElBadge(inline-block) > Icon
// ElBadge inline-block flex baseline ElIcon 1-2px
// .message flex-center 穿 .el-badge flex-center
// inline-block baseline
.message {
display: flex;
align-items: center;
height: 100%;
:deep(.el-badge) {
display: flex;
align-items: center;
}
}
.message-empty { .message-empty {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -1,6 +1,7 @@
<script lang="tsx"> <script lang="tsx">
import { defineComponent, computed } from 'vue' import { defineComponent, computed } from 'vue'
import { Message } from '@/layout/components//Message' import router from '@/router'
import { Message } from '@/layout/components/Message'
import { Collapse } from '@/layout/components/Collapse' import { Collapse } from '@/layout/components/Collapse'
import { UserInfo } from '@/layout/components/UserInfo' import { UserInfo } from '@/layout/components/UserInfo'
import { Screenfull } from '@/layout/components/Screenfull' import { Screenfull } from '@/layout/components/Screenfull'
@ -11,6 +12,7 @@ import RouterSearch from '@/components/RouterSearch/index.vue'
import TenantVisit from '@/layout/components/TenantVisit/index.vue' import TenantVisit from '@/layout/components/TenantVisit/index.vue'
import { useAppStore } from '@/store/modules/app' import { useAppStore } from '@/store/modules/app'
import { useDesign } from '@/hooks/web/useDesign' import { useDesign } from '@/hooks/web/useDesign'
import { Icon } from '@/components/Icon'
import { checkPermi } from '@/utils/permission' import { checkPermi } from '@/utils/permission'
const { getPrefixCls, variables } = useDesign() const { getPrefixCls, variables } = useDesign()
@ -48,6 +50,14 @@ const hasTenantVisitPermission = computed(
() => import.meta.env.VITE_APP_TENANT_ENABLE === 'true' && checkPermi(['system:tenant:visit']) () => import.meta.env.VITE_APP_TENANT_ENABLE === 'true' && checkPermi(['system:tenant:visit'])
) )
// name resolve URL IM
// IM push Tab
const goToChat = () => {
// name resolve URL IM
const { href } = router.resolve({ name: 'ImHome' })
window.open(href, '_blank')
}
export default defineComponent({ export default defineComponent({
name: 'ToolHeader', name: 'ToolHeader',
setup() { setup() {
@ -73,7 +83,9 @@ export default defineComponent({
{screenfull.value ? ( {screenfull.value ? (
<Screenfull class="custom-hover" color="var(--top-header-text-color)"></Screenfull> <Screenfull class="custom-hover" color="var(--top-header-text-color)"></Screenfull>
) : undefined} ) : undefined}
{search.value ? <RouterSearch isModal={false} color="var(--top-header-text-color)"/> : undefined} {search.value ? (
<RouterSearch isModal={false} color="var(--top-header-text-color)" />
) : undefined}
{size.value ? ( {size.value ? (
<SizeDropdown class="custom-hover" color="var(--top-header-text-color)"></SizeDropdown> <SizeDropdown class="custom-hover" color="var(--top-header-text-color)"></SizeDropdown>
) : undefined} ) : undefined}
@ -86,6 +98,10 @@ export default defineComponent({
{message.value ? ( {message.value ? (
<Message class="custom-hover" color="var(--top-header-text-color)"></Message> <Message class="custom-hover" color="var(--top-header-text-color)"></Message>
) : undefined} ) : undefined}
{/* IM 聊天入口 */}
<div class="custom-hover" onClick={goToChat}>
<Icon color="var(--top-header-text-color)" size={18} icon="ep:chat-dot-round" />
</div>
<UserInfo></UserInfo> <UserInfo></UserInfo>
</div> </div>
</div> </div>

View File

@ -794,6 +794,35 @@ const remainingRouter: AppRouteRecordRaw[] = [
} }
} }
] ]
},
{
path: '/im',
name: 'Im',
redirect: '/im/home/conversation',
meta: { hidden: true, title: 'IM 即时通讯' },
children: [
{
path: 'home',
component: () => import('@/views/im/home/index.vue'),
name: 'ImHome',
redirect: '/im/home/conversation',
meta: { hidden: true, title: '聊天' },
children: [
{
path: 'conversation',
component: () => import('@/views/im/home/pages/conversation/index.vue'),
name: 'ImHomeConversation',
meta: { hidden: true, title: '消息' }
},
{
path: 'contact',
component: () => import('@/views/im/home/pages/contact/index.vue'),
name: 'ImHomeContact',
meta: { hidden: true, title: '通讯录' }
}
]
}
]
} }
] ]

View File

@ -37,6 +37,12 @@ export const formatToken = (token: string): string => {
} }
// ========== 账号相关 ========== // ========== 账号相关 ==========
/** 获取当前登录用户编号 */
export const getCurrentUserId = (): number => {
const user = wsCache.get(CACHE_KEY.USER)?.user
return Number(user?.id) || 0
}
export type LoginFormType = { export type LoginFormType = {
tenantName: string tenantName: string
username: string username: string

View File

@ -64,6 +64,15 @@ export const SystemUserSocialTypeEnum = {
} }
} }
/**
* system_user_sex
*/
export const SystemUserSexEnum = {
UNKNOWN: 0, // 未知
MALE: 1, // 男
FEMALE: 2 // 女
}
// ========== INFRA 模块 ========== // ========== INFRA 模块 ==========
/** /**
* *

View File

@ -331,5 +331,25 @@ export enum DICT_TYPE {
MES_MD_AUTO_CODE_CYCLE_METHOD = 'mes_md_auto_code_cycle_method', // MES 编码规则循环方式 MES_MD_AUTO_CODE_CYCLE_METHOD = 'mes_md_auto_code_cycle_method', // MES 编码规则循环方式
MES_WM_BARCODE_FORMAT = 'mes_wm_barcode_format', // MES 条码格式 MES_WM_BARCODE_FORMAT = 'mes_wm_barcode_format', // MES 条码格式
MES_WM_BARCODE_BIZ_TYPE = 'mes_wm_barcode_biz_type', // MES 条码业务类型 MES_WM_BARCODE_BIZ_TYPE = 'mes_wm_barcode_biz_type', // MES 条码业务类型
MES_WM_PACKAGE_STATUS = 'mes_wm_package_status' // MES 装箱单状态 MES_WM_PACKAGE_STATUS = 'mes_wm_package_status', // MES 装箱单状态
// ========== IM - 即时通讯模块 ==========
IM_MESSAGE_TYPE = 'im_message_type', // IM 消息类型
IM_PRIVATE_MESSAGE_STATUS = 'im_private_message_status', // IM 私聊消息状态0=未读 / 2=已撤回 / 3=已读
IM_GROUP_MESSAGE_STATUS = 'im_group_message_status', // IM 群聊消息状态0=正常 / 2=已撤回
IM_GROUP_MESSAGE_RECEIPT_STATUS = 'im_group_message_receipt_status', // IM 群消息回执状态
IM_FRIEND_STATUS = 'im_friend_status', // IM 好友状态
IM_FRIEND_ADD_SOURCE = 'im_friend_add_source', // IM 好友添加来源
IM_FRIEND_REQUEST_HANDLE_RESULT = 'im_friend_request_handle_result', // IM 好友申请处理结果
IM_GROUP_STATUS = 'im_group_status', // IM 群状态
IM_GROUP_MEMBER_ROLE = 'im_group_member_role', // IM 群成员角色
IM_GROUP_ADD_SOURCE = 'im_group_add_source', // IM 加群来源
IM_GROUP_REQUEST_HANDLE_RESULT = 'im_group_request_handle_result', // IM 加群申请处理结果
IM_RTC_CALL_MEDIA_TYPE = 'im_rtc_call_media_type', // IM 通话媒体类型1=语音 / 2=视频
IM_RTC_CALL_CONVERSATION_TYPE = 'im_rtc_call_conversation_type', // IM 通话会话类型1=私聊 / 2=群聊
IM_RTC_CALL_STATUS = 'im_rtc_call_status', // IM 通话状态10=创建 / 20=进行中 / 30=已结束
IM_RTC_CALL_END_REASON = 'im_rtc_call_end_reason', // IM 通话结束原因1=通话结束 / 2=已拒绝 / 3=已取消 / 4=无人接听 / 5=对方正忙 / 9=通话异常
IM_RTC_PARTICIPANT_ROLE = 'im_rtc_participant_role', // IM 通话参与角色1=发起人 / 2=被邀请者 / 3=主动加入者
IM_RTC_PARTICIPANT_STATUS = 'im_rtc_participant_status', // IM 通话参与状态10=邀请中 / 20=已加入 / 30=已拒绝 / 40=未应答 / 50=已离开
IM_CHANNEL_MATERIAL_TYPE = 'im_channel_material_type' // IM 频道素材内容类型1=富文本 / 2=外链
} }

View File

@ -288,15 +288,3 @@ export const isInContainer = (el: Element, container: any) => {
) )
} }
// TODO @AI拿到 /Users/yunai/Java/yudao-all-in-one/yudao-ui-admin-vue3/src/utils/index.ts放在 domutils 有点奇怪!
/** HTML 转义函数,防止 XSS */
export const escapeHtml = (text: string): string => {
const map: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
}
return text.replace(/[&<>"']/g, (char) => map[char])
}

View File

@ -209,6 +209,18 @@ export function formatPast2(ms: number): string {
} }
} }
/**
* mm:ss
*
* @param seconds
*/
export function formatSeconds(seconds: number): string {
const s = Math.max(0, Math.floor(seconds || 0))
const mm = Math.floor(s / 60).toString().padStart(2, '0')
const ss = (s % 60).toString().padStart(2, '0')
return `${mm}:${ss}`
}
/** /**
* element plus Formatter 使 YYYY-MM-DD HH:mm:ss * element plus Formatter 使 YYYY-MM-DD HH:mm:ss
* *

View File

@ -534,3 +534,15 @@ export const subString = (str: string, start: number, end: number) => {
} }
return str return str
} }
/** HTML 转义函数,防止 XSS */
export const escapeHtml = (text: string): string => {
const map: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
}
return text.replace(/[&<>"']/g, (char) => map[char])
}

22
src/utils/url.ts Normal file
View File

@ -0,0 +1,22 @@
const OPENABLE_PROTOCOLS = new Set(['http:', 'https:', 'blob:'])
/** 判断 URL 是否允许在新窗口打开 */
export function isOpenableUrl(url?: string | null): boolean {
if (!url) {
return false
}
try {
const parsed = new URL(url, window.location.origin)
return OPENABLE_PROTOCOLS.has(parsed.protocol)
} catch {
return false
}
}
/** 安全打开 URL */
export function openSafeUrl(url?: string | null): void {
if (!url || !isOpenableUrl(url)) {
return
}
window.open(url, '_blank', 'noopener,noreferrer')
}

View File

@ -284,7 +284,7 @@ import * as FormApi from '@/api/bpm/form'
import { setConfAndFields2 } from '@/utils/formCreate' import { setConfAndFields2 } from '@/utils/formCreate'
import { BpmModelFormType } from '@/utils/constants' import { BpmModelFormType } from '@/utils/constants'
import { checkPermi } from '@/utils/permission' import { checkPermi } from '@/utils/permission'
import { useUserStoreWithOut } from '@/store/modules/user' import { getCurrentUserId } from '@/utils/auth'
import { useAppStore } from '@/store/modules/app' import { useAppStore } from '@/store/modules/app'
import { cloneDeep, isEqual } from 'lodash-es' import { cloneDeep, isEqual } from 'lodash-es'
import { useDebounceFn } from '@vueuse/core' import { useDebounceFn } from '@vueuse/core'
@ -333,7 +333,6 @@ const emit = defineEmits(['success'])
const message = useMessage() // const message = useMessage() //
const { t } = useI18n() // const { t } = useI18n() //
const { push } = useRouter() // const { push } = useRouter() //
const userStore = useUserStoreWithOut() //
const isDark = computed(() => useAppStore().getIsDark) // const isDark = computed(() => useAppStore().getIsDark) //
const router = useRouter() // const router = useRouter() //
@ -501,7 +500,7 @@ const handleFormDetail = async (row: any) => {
/** 判断是否可以操作 */ /** 判断是否可以操作 */
const isManagerUser = (row: any) => { const isManagerUser = (row: any) => {
const userId = userStore.getUser.id const userId = getCurrentUserId()
return row.managerUserIds && row.managerUserIds.includes(userId) return row.managerUserIds && row.managerUserIds.includes(userId)
} }

View File

@ -88,7 +88,7 @@
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useMessage } from '@/hooks/web/useMessage' import { useMessage } from '@/hooks/web/useMessage'
import { useTagsViewStore } from '@/store/modules/tagsView' import { useTagsViewStore } from '@/store/modules/tagsView'
import { useUserStoreWithOut } from '@/store/modules/user' import { getCurrentUserId } from '@/utils/auth'
import * as ModelApi from '@/api/bpm/model' import * as ModelApi from '@/api/bpm/model'
import * as FormApi from '@/api/bpm/form' import * as FormApi from '@/api/bpm/form'
import { CategoryApi, CategoryVO } from '@/api/bpm/category' import { CategoryApi, CategoryVO } from '@/api/bpm/category'
@ -107,7 +107,6 @@ const { delView } = useTagsViewStore() // 视图操作
const tagsView = useTagsView() const tagsView = useTagsView()
const route = useRoute() const route = useRoute()
const message = useMessage() const message = useMessage()
const userStore = useUserStoreWithOut()
// //
const basicInfoRef = ref() const basicInfoRef = ref()
@ -237,7 +236,7 @@ const initData = async () => {
} else { } else {
// //
formData.value.startUserType = 0 // formData.value.startUserType = 0 //
formData.value.managerUserIds.push(userStore.getUser.id) formData.value.managerUserIds.push(getCurrentUserId())
} }
// //

View File

@ -513,7 +513,7 @@
<SignDialog ref="signRef" @success="handleSignFinish" /> <SignDialog ref="signRef" @success="handleSignFinish" />
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useUserStoreWithOut } from '@/store/modules/user' import { getCurrentUserId } from '@/utils/auth'
import { setConfAndFields2 } from '@/utils/formCreate' import { setConfAndFields2 } from '@/utils/formCreate'
import * as TaskApi from '@/api/bpm/task' import * as TaskApi from '@/api/bpm/task'
import * as ProcessInstanceApi from '@/api/bpm/processInstance' import * as ProcessInstanceApi from '@/api/bpm/processInstance'
@ -536,7 +536,7 @@ defineOptions({ name: 'ProcessInstanceBtnContainer' })
const router = useRouter() // const router = useRouter() //
const message = useMessage() // const message = useMessage() //
const userId = useUserStoreWithOut().getUser.id // const userId = getCurrentUserId() //
const emit = defineEmits(['success']) // success const emit = defineEmits(['success']) // success
const props = defineProps<{ const props = defineProps<{

View File

@ -140,7 +140,7 @@ import * as BusinessApi from '@/api/crm/business'
import * as BusinessStatusApi from '@/api/crm/business/status' import * as BusinessStatusApi from '@/api/crm/business/status'
import * as CustomerApi from '@/api/crm/customer' import * as CustomerApi from '@/api/crm/customer'
import * as UserApi from '@/api/system/user' import * as UserApi from '@/api/system/user'
import { useUserStore } from '@/store/modules/user' import { getCurrentUserId } from '@/utils/auth'
import BusinessProductForm from './components/BusinessProductForm.vue' import BusinessProductForm from './components/BusinessProductForm.vue'
import { erpPriceMultiply, erpPriceInputFormatter } from '@/utils' import { erpPriceMultiply, erpPriceInputFormatter } from '@/utils'
@ -233,7 +233,7 @@ const open = async (type: string, id?: number, customerId?: number, contactId?:
userOptions.value = await UserApi.getSimpleUserList() userOptions.value = await UserApi.getSimpleUserList()
// //
if (formType.value === 'create') { if (formType.value === 'create') {
formData.value.ownerUserId = useUserStore().getUser.id formData.value.ownerUserId = getCurrentUserId()
} }
} }
defineExpose({ open }) // open defineExpose({ open }) // open

View File

@ -150,7 +150,7 @@ import * as ClueApi from '@/api/crm/clue'
import * as AreaApi from '@/api/system/area' import * as AreaApi from '@/api/system/area'
import { defaultProps } from '@/utils/tree' import { defaultProps } from '@/utils/tree'
import * as UserApi from '@/api/system/user' import * as UserApi from '@/api/system/user'
import { useUserStore } from '@/store/modules/user' import { getCurrentUserId } from '@/utils/auth'
const { t } = useI18n() // const { t } = useI18n() //
const message = useMessage() // const message = useMessage() //
@ -205,7 +205,7 @@ const open = async (type: string, id?: number) => {
userOptions.value = await UserApi.getSimpleUserList() userOptions.value = await UserApi.getSimpleUserList()
// //
if (formType.value === 'create') { if (formType.value === 'create') {
formData.value.ownerUserId = useUserStore().getUser.id formData.value.ownerUserId = getCurrentUserId()
} }
} }
defineExpose({ open }) // open defineExpose({ open }) // open

View File

@ -177,7 +177,7 @@ import * as UserApi from '@/api/system/user'
import * as CustomerApi from '@/api/crm/customer' import * as CustomerApi from '@/api/crm/customer'
import * as AreaApi from '@/api/system/area' import * as AreaApi from '@/api/system/area'
import { defaultProps } from '@/utils/tree' import { defaultProps } from '@/utils/tree'
import { useUserStore } from '@/store/modules/user' import { getCurrentUserId } from '@/utils/auth'
const { t } = useI18n() // const { t } = useI18n() //
const message = useMessage() // const message = useMessage() //
@ -252,7 +252,7 @@ const open = async (type: string, id?: number, customerId?: number, businessId?:
userOptions.value = await UserApi.getSimpleUserList() userOptions.value = await UserApi.getSimpleUserList()
// //
if (formType.value === 'create') { if (formType.value === 'create') {
formData.value.ownerUserId = useUserStore().getUser.id formData.value.ownerUserId = getCurrentUserId()
} }
} }
defineExpose({ open }) // open defineExpose({ open }) // open

View File

@ -200,7 +200,7 @@ import * as UserApi from '@/api/system/user'
import * as ContactApi from '@/api/crm/contact' import * as ContactApi from '@/api/crm/contact'
import * as BusinessApi from '@/api/crm/business' import * as BusinessApi from '@/api/crm/business'
import { erpPriceMultiply, erpPriceInputFormatter } from '@/utils' import { erpPriceMultiply, erpPriceInputFormatter } from '@/utils'
import { useUserStore } from '@/store/modules/user' import { getCurrentUserId } from '@/utils/auth'
import ContractProductForm from '@/views/crm/contract/components/ContractProductForm.vue' import ContractProductForm from '@/views/crm/contract/components/ContractProductForm.vue'
const { t } = useI18n() // const { t } = useI18n() //
@ -284,7 +284,7 @@ const open = async (type: string, id?: number) => {
userOptions.value = await UserApi.getSimpleUserList() userOptions.value = await UserApi.getSimpleUserList()
// //
if (formType.value === 'create') { if (formType.value === 'create') {
formData.value.ownerUserId = useUserStore().getUser.id formData.value.ownerUserId = getCurrentUserId()
} }
// //
contactList.value = await ContactApi.getSimpleContactList() contactList.value = await ContactApi.getSimpleContactList()

View File

@ -150,7 +150,7 @@ import * as CustomerApi from '@/api/crm/customer'
import * as AreaApi from '@/api/system/area' import * as AreaApi from '@/api/system/area'
import { defaultProps } from '@/utils/tree' import { defaultProps } from '@/utils/tree'
import * as UserApi from '@/api/system/user' import * as UserApi from '@/api/system/user'
import { useUserStore } from '@/store/modules/user' import { getCurrentUserId } from '@/utils/auth'
const { t } = useI18n() // const { t } = useI18n() //
const message = useMessage() // const message = useMessage() //
@ -205,7 +205,7 @@ const open = async (type: string, id?: number) => {
userOptions.value = await UserApi.getSimpleUserList() userOptions.value = await UserApi.getSimpleUserList()
// //
if (formType.value === 'create') { if (formType.value === 'create') {
formData.value.ownerUserId = useUserStore().getUser.id formData.value.ownerUserId = getCurrentUserId()
} }
} }
defineExpose({ open }) // open defineExpose({ open }) // open

View File

@ -54,7 +54,7 @@ import * as CustomerApi from '@/api/crm/customer'
import download from '@/utils/download' import download from '@/utils/download'
import type { UploadUserFile } from 'element-plus' import type { UploadUserFile } from 'element-plus'
import * as UserApi from '@/api/system/user' import * as UserApi from '@/api/system/user'
import { useUserStore } from '@/store/modules/user' import { getCurrentUserId } from '@/utils/auth'
defineOptions({ name: 'CrmCustomerImportForm' }) defineOptions({ name: 'CrmCustomerImportForm' })
@ -74,7 +74,7 @@ const open = async () => {
await resetForm() await resetForm()
// //
userOptions.value = await UserApi.getSimpleUserList() userOptions.value = await UserApi.getSimpleUserList()
ownerUserId.value = useUserStore().getUser.id ownerUserId.value = getCurrentUserId()
} }
defineExpose({ open }) // open defineExpose({ open }) // open

View File

@ -46,7 +46,7 @@
import { dateFormatter } from '@/utils/formatTime' import { dateFormatter } from '@/utils/formatTime'
import { ElTable } from 'element-plus' import { ElTable } from 'element-plus'
import * as PermissionApi from '@/api/crm/permission' import * as PermissionApi from '@/api/crm/permission'
import { useUserStoreWithOut } from '@/store/modules/user' import { getCurrentUserId } from '@/utils/auth'
import CrmPermissionForm from './PermissionForm.vue' import CrmPermissionForm from './PermissionForm.vue'
import { DICT_TYPE } from '@/utils/dict' import { DICT_TYPE } from '@/utils/dict'
@ -64,7 +64,6 @@ const list = ref<PermissionApi.PermissionVO[]>([]) // 列表的数据
const formData = ref({ const formData = ref({
ownerUserId: 0 ownerUserId: 0
}) })
const userStore = useUserStoreWithOut() //
/** 查询列表 */ /** 查询列表 */
const getList = async () => { const getList = async () => {
@ -75,13 +74,14 @@ const getList = async () => {
bizId: props.bizId bizId: props.bizId
}) })
list.value = data list.value = data
const currentUserId = getCurrentUserId()
const permission = list.value.find( const permission = list.value.find(
(item) => (item) =>
item.userId === userStore.getUser.id && item.userId === currentUserId &&
item.level === PermissionApi.PermissionLevelEnum.OWNER item.level === PermissionApi.PermissionLevelEnum.OWNER
) )
if (permission) { if (permission) {
formData.value.ownerUserId = userStore.getUser.id formData.value.ownerUserId = currentUserId
} }
} finally { } finally {
loading.value = false loading.value = false
@ -150,7 +150,7 @@ watch(
) )
validateOwnerUser.value = false validateOwnerUser.value = false
validateWrite.value = false validateWrite.value = false
const userId = userStore.getUser?.id const userId = getCurrentUserId()
list.value list.value
.filter((item) => item.userId === userId) .filter((item) => item.userId === userId)
.forEach((item) => { .forEach((item) => {
@ -176,15 +176,16 @@ const emits = defineEmits<{
}>() }>()
/** 退出团队 */ /** 退出团队 */
const handleQuit = async () => { const handleQuit = async () => {
const currentUserId = getCurrentUserId()
const permission = list.value.find( const permission = list.value.find(
(item) => (item) =>
item.userId === userStore.getUser.id && item.level === PermissionApi.PermissionLevelEnum.OWNER item.userId === currentUserId && item.level === PermissionApi.PermissionLevelEnum.OWNER
) )
if (permission) { if (permission) {
message.warning('负责人不能退出团队!') message.warning('负责人不能退出团队!')
return return
} }
const userPermission = list.value.find((item) => item.userId === userStore.getUser.id) const userPermission = list.value.find((item) => item.userId === currentUserId)
if (!userPermission) { if (!userPermission) {
return return
} }

View File

@ -103,7 +103,7 @@ import * as ProductApi from '@/api/crm/product'
import * as ProductCategoryApi from '@/api/crm/product/category' import * as ProductCategoryApi from '@/api/crm/product/category'
import { defaultProps, handleTree } from '@/utils/tree' import { defaultProps, handleTree } from '@/utils/tree'
import { getSimpleUserList, UserVO } from '@/api/system/user' import { getSimpleUserList, UserVO } from '@/api/system/user'
import { useUserStore } from '@/store/modules/user' import { getCurrentUserId } from '@/utils/auth'
defineOptions({ name: 'CrmProductForm' }) defineOptions({ name: 'CrmProductForm' })
@ -114,7 +114,7 @@ const dialogVisible = ref(false) // 弹窗的是否展示
const dialogTitle = ref('') // const dialogTitle = ref('') //
const formLoading = ref(false) // 12 const formLoading = ref(false) // 12
const formType = ref('') // create - update - const formType = ref('') // create - update -
const userId = useUserStore().getUser.id // const userId = getCurrentUserId() //
const formData = ref({ const formData = ref({
id: undefined, id: undefined,
name: undefined, name: undefined,

View File

@ -150,7 +150,7 @@ import { ReceivableVO } from '@/api/crm/receivable'
import * as UserApi from '@/api/system/user' import * as UserApi from '@/api/system/user'
import * as CustomerApi from '@/api/crm/customer' import * as CustomerApi from '@/api/crm/customer'
import * as ContractApi from '@/api/crm/contract' import * as ContractApi from '@/api/crm/contract'
import { useUserStore } from '@/store/modules/user' import { getCurrentUserId } from '@/utils/auth'
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
const { t } = useI18n() // const { t } = useI18n() //
@ -200,7 +200,7 @@ const open = async (
customerList.value = await CustomerApi.getCustomerSimpleList() customerList.value = await CustomerApi.getCustomerSimpleList()
// //
if (formType.value === 'create') { if (formType.value === 'create') {
formData.value.ownerUserId = useUserStore().getUser.id formData.value.ownerUserId = getCurrentUserId()
} }
// //
if (receivablePlan) { if (receivablePlan) {

View File

@ -135,7 +135,7 @@ import * as ReceivablePlanApi from '@/api/crm/receivable/plan'
import * as UserApi from '@/api/system/user' import * as UserApi from '@/api/system/user'
import * as CustomerApi from '@/api/crm/customer' import * as CustomerApi from '@/api/crm/customer'
import * as ContractApi from '@/api/crm/contract' import * as ContractApi from '@/api/crm/contract'
import { useUserStore } from '@/store/modules/user' import { getCurrentUserId } from '@/utils/auth'
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { cloneDeep } from 'lodash-es' import { cloneDeep } from 'lodash-es'
@ -182,7 +182,7 @@ const open = async (type: string, id?: number, customerId?: number, contractId?:
customerList.value = await CustomerApi.getCustomerSimpleList() customerList.value = await CustomerApi.getCustomerSimpleList()
// //
if (formType.value === 'create') { if (formType.value === 'create') {
formData.value.ownerUserId = useUserStore().getUser.id formData.value.ownerUserId = getCurrentUserId()
} }
// customerId contractId // customerId contractId
if (customerId) { if (customerId) {

View File

@ -0,0 +1,98 @@
<template>
<!--
通用右键菜单
useImUiStore.openContextMenu(position, items, onSelect) 触发全局单例展示
调用方在 @contextmenu.prevent 事件里调 openContextMenu 即可不需要自己挂组件
-->
<teleport to="body">
<div
v-if="contextMenu.show"
class="fixed inset-0 z-9999"
@click.stop="handleClose"
@contextmenu.prevent="handleClose"
>
<div
class="fixed min-w-30 py-1 bg-[var(--el-bg-color-overlay)] rounded-md shadow-lg"
:style="{ left: adjustedPosition.x + 'px', top: adjustedPosition.y + 'px' }"
>
<template v-for="(item, index) in contextMenu.items" :key="item.key">
<!-- divided 项上方插一条分割线首项跳过避免空白 -->
<div
v-if="item.divided && index > 0"
class="my-1 mx-2 h-[1px] bg-[var(--el-border-color-lighter)]"
></div>
<div
class="flex gap-2 items-center px-4 py-2 text-13px text-left cursor-pointer transition-colors hover:bg-[var(--el-fill-color)]"
:class="[
item.disabled
? '!text-[var(--el-text-color-disabled)] cursor-not-allowed hover:!bg-transparent'
: item.danger
? 'text-[#f56c6c]'
: 'text-[var(--el-text-color-primary)]'
]"
@click.stop="handleSelect(item)"
>
<Icon v-if="item.icon" :icon="item.icon" :size="14" />
<span>{{ item.name }}</span>
</div>
</template>
</div>
</div>
</teleport>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { useImUiStore } from '../store/uiStore'
defineOptions({ name: 'ImContextMenu' })
const uiStore = useImUiStore()
const contextMenu = computed(() => uiStore.contextMenu)
/**
* 计算菜单实际渲染坐标靠近视口右 / 下边缘时回弹避免菜单被裁剪
*
* itemHeight / menuWidth 是和模板里 px-4 py-2 + text-13px / min-w-30 配套的实际尺寸
* dividerHeight = 9pxmy-1 上下各 4 + 1px border仅非首项的 divided 计入
* menuHeight 额外加 8 是外层 py-1 的上下 padding 之和4px × 2
*/
const adjustedPosition = computed(() => {
const items = contextMenu.value.items
const itemHeight = 34
const dividerCount = items.filter((it, i) => it.divided && i > 0).length
const menuHeight = items.length * itemHeight + dividerCount * 9 + 8
const menuWidth = 120
let x = contextMenu.value.position.x
let y = contextMenu.value.position.y
// SSR window
if (typeof window !== 'undefined') {
if (y + menuHeight > window.innerHeight) {
y = window.innerHeight - menuHeight
}
if (x + menuWidth > window.innerWidth) {
x = window.innerWidth - menuWidth
}
}
// / / 0
return { x: Math.max(0, x), y: Math.max(0, y) }
})
type MenuItem = (typeof contextMenu.value.items)[number]
/** 选中菜单项disabled 项忽略;正常项调 onSelect 回调后关闭菜单 */
function handleSelect(item: MenuItem) {
if (item.disabled) {
return
}
uiStore.contextMenu.onSelect?.(item)
uiStore.closeContextMenu()
}
/** 关闭菜单:点遮罩 / 在遮罩上再次右键都会触发 */
function handleClose() {
uiStore.closeContextMenu()
}
</script>

View File

@ -0,0 +1,110 @@
<template>
<!--
分页增量滚动容器
- 滚到底部自动 page++直到全部渲染完
- 通过 slot 暴露每一项让调用方自己决定渲染
-->
<el-scrollbar ref="scrollbarRef" class="w-full h-full">
<slot
v-for="(item, idx) in displayItems"
:item="item"
:index="idx"
:key="resolveItemKey(item, idx)"
></slot>
<div
v-if="showFooter"
class="py-3 text-xs text-center text-[var(--el-text-color-secondary)]"
>
已到底部
</div>
</el-scrollbar>
</template>
<script lang="ts" setup generic="T">
import { computed, onMounted, onBeforeUnmount, ref, useTemplateRef, watch } from 'vue'
import { ElScrollbar } from 'element-plus'
defineOptions({ name: 'ImPagedScroller' })
const props = withDefaults(
defineProps<{
items: T[] //
pageSize?: number //
threshold?: number // px
itemKey?: string // 业务 id 字段名(如 'userId' / 'id'不传 / 字段值非 string|number 时回退 idx
}>(),
{
pageSize: 30,
threshold: 30
}
)
/** 解析每条 item 的 :keycaller 传 itemKey 则按字段取,无效 / 缺失回退索引,避免传错字段时全表 undefined key */
function resolveItemKey(item: T, idx: number): string | number {
if (!props.itemKey || item == null || typeof item !== 'object') {
return idx
}
const value = (item as Record<string, unknown>)[props.itemKey]
return typeof value === 'string' || typeof value === 'number' ? value : idx
}
const scrollbarRef = useTemplateRef<InstanceType<typeof ElScrollbar>>('scrollbarRef')
const page = ref(1)
const displayItems = computed(() => {
const limit = Math.min(page.value * props.pageSize, props.items.length)
return props.items.slice(0, limit)
})
const allLoaded = computed(() => displayItems.value.length >= props.items.length)
/** 仅当超过一页时才显示「已到底部」,避免短列表也出现这条提示 */
const showFooter = computed(() => allLoaded.value && props.items.length > props.pageSize)
// el-scrollbar overflow:hidden .el-scrollbar__wrap
let wrapEl: HTMLElement | null = null
onMounted(() => {
wrapEl = scrollbarRef.value?.$el?.querySelector('.el-scrollbar__wrap') ?? null
wrapEl?.addEventListener('scroll', onScroll)
})
onBeforeUnmount(() => {
wrapEl?.removeEventListener('scroll', onScroll)
})
/** 切换数据源(如切会话)时重置分页:避免新列表沿用旧 page首屏出现空段 */
watch(
() => props.items,
() => {
page.value = 1
}
)
/** 滚到距底 threshold 内时自增 page扩出下一段切片 */
function onScroll(e: Event) {
const el = e.target as HTMLElement
if (el.scrollTop + el.clientHeight < el.scrollHeight - props.threshold) {
return
}
if (allLoaded.value) {
return
}
page.value++
}
defineExpose({
/** 手动滚到顶部 */
scrollTop: () => {
if (wrapEl) {
wrapEl.scrollTop = 0
}
},
/** 手动滚到底部 */
scrollBottom: () => {
if (wrapEl) {
wrapEl.scrollTop = wrapEl.scrollHeight
}
}
})
</script>

View File

@ -0,0 +1,111 @@
<template>
<!--
可拖拽宽度的左侧 Aside
- 使用 localStorage 记住用户上次调整的宽度storageKey 必填
- 拖拽区在右边缘鼠标变 col-resize
-->
<aside
class="relative flex flex-col shrink-0 bg-[var(--el-fill-color-light)] border-r border-r-solid border-[var(--el-border-color-lighter)] shadow-[2px_0_8px_rgba(0,0,0,0.05)]"
:style="{ width: asideWidth + 'px' }"
>
<slot></slot>
<div
class="im-resizable-aside__handle absolute top-0 right--0.75 z-10 flex items-center justify-center w-1.5 h-full cursor-col-resize transition-colors"
:class="{ 'is-resizing': isResizing }"
title="拖拽调整宽度"
@mousedown="startResize"
>
<div class="im-resizable-aside__line w-0.5 h-full rounded-0.5 bg-transparent transition-all"></div>
</div>
</aside>
</template>
<script lang="ts" setup>
import { onMounted, onBeforeUnmount, ref } from 'vue'
defineOptions({ name: 'ImResizableAside' })
const props = withDefaults(
defineProps<{
defaultWidth?: number //
minWidth?: number //
maxWidth?: number //
storageKey: string // localStorage key StorageKeys.localStorage.asideWidth
}>(),
{
defaultWidth: 260,
minWidth: 200,
maxWidth: 500
}
)
const asideWidth = ref<number>(props.defaultWidth)
const isResizing = ref(false)
let startX = 0
let startWidth = 0
onMounted(() => {
const saved = localStorage.getItem(props.storageKey)
if (saved) {
const w = parseInt(saved, 10)
if (!Number.isNaN(w)) {
asideWidth.value = clamp(w)
}
}
document.addEventListener('mousemove', handleResize)
document.addEventListener('mouseup', stopResize)
})
onBeforeUnmount(() => {
// stopResize body cursor/userSelect
if (isResizing.value) {
stopResize()
}
document.removeEventListener('mousemove', handleResize)
document.removeEventListener('mouseup', stopResize)
})
/** 把宽度夹到 [minWidth, maxWidth] 区间,恢复 / 拖拽路径都走它兜底 */
function clamp(w: number) {
return Math.max(props.minWidth, Math.min(props.maxWidth, w))
}
/** 按下拖拽手柄:记录起始位置 + 锁定 body cursor/userSelect避免拖拽中误选文本 */
function startResize(e: MouseEvent) {
isResizing.value = true
startX = e.clientX
startWidth = asideWidth.value
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
e.preventDefault()
}
/** 拖拽中:按鼠标位移计算新宽度并 clamp 到允许区间 */
function handleResize(e: MouseEvent) {
if (!isResizing.value) {
return
}
const deltaX = e.clientX - startX
asideWidth.value = clamp(startWidth + deltaX)
}
/** 松开鼠标:解锁 body 全局态并把当前宽度写入 localStorage 持久化 */
function stopResize() {
if (!isResizing.value) {
return
}
isResizing.value = false
document.body.style.cursor = ''
document.body.style.userSelect = ''
localStorage.setItem(props.storageKey, String(asideWidth.value))
}
</script>
<style scoped>
/* hover / 拖拽中 把内部 line 加粗变深,提示手柄可拖;状态在父 handle 上 → 通过后代选择器联动子 line */
.im-resizable-aside__handle:hover .im-resizable-aside__line,
.im-resizable-aside__handle.is-resizing .im-resizable-aside__line {
width: 3px;
background-color: #d0d4db;
}
</style>

View File

@ -0,0 +1,112 @@
<template>
<!--
ToolBarIM 左侧工具栏
布局顶部头像 中间三 Tab消息/好友/群聊 底部设置
-->
<div
class="flex flex-col items-center w-14 pt-4 pb-3 gap-2 flex-shrink-0 select-none bg-[#2b2b2b]"
>
<!-- 顶部用户头像点击跳个人中心 UserAvatar 统一首字 / 哈希配色规则 -->
<div class="mb-2 cursor-pointer" @click="goProfile">
<UserAvatar
:url="userStore.getUser?.avatar"
:name="userStore.getUser?.nickname"
:size="36"
:clickable="false"
/>
</div>
<!-- 中间三 Tab -->
<div class="flex flex-col items-center gap-2 flex-1 w-full">
<div
v-for="item in tabs"
:key="item.name"
class="flex items-center justify-center w-10 h-10 rounded-lg text-[#a0a0a0] cursor-pointer transition-all hover:text-white hover:bg-white/10"
:class="{ 'bg-white/15 text-white': isActive(item.name) }"
@click="goTab(item.name)"
>
<el-badge
v-if="item.name === 'ImHomeConversation' && totalUnread > 0"
:value="totalUnread"
:max="99"
class="tool-bar__badge"
>
<Icon :icon="item.icon" :size="22" />
</el-badge>
<el-badge
v-else-if="item.name === 'ImHomeContact' && unhandledRequestCount > 0"
:value="unhandledRequestCount"
:max="99"
class="tool-bar__badge"
>
<Icon :icon="item.icon" :size="22" />
</el-badge>
<Icon v-else :icon="item.icon" :size="22" />
</div>
</div>
<!-- 底部设置按钮点击跳个人中心 -->
<div class="flex flex-col items-center gap-2 w-full">
<div
class="flex items-center justify-center w-10 h-10 rounded-lg text-[#a0a0a0] cursor-pointer transition-all hover:text-white hover:bg-white/10"
@click="goProfile"
>
<Icon icon="ant-design:setting-outlined" :size="22" />
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Icon from '@/components/Icon/src/Icon.vue'
import { useUserStore } from '@/store/modules/user'
import { useConversationStore } from '../store/conversationStore'
import { useFriendStore } from '../store/friendStore'
import { useImUiStore } from '../store/uiStore'
import UserAvatar from './user/UserAvatar.vue'
defineOptions({ name: 'ImToolBar' })
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const conversationStore = useConversationStore()
const friendStore = useFriendStore()
const uiStore = useImUiStore()
const totalUnread = computed(() => conversationStore.getTotalUnreadCount) // Tab
const unhandledRequestCount = computed(() => friendStore.getUnhandledRequestCount) // Tab =
const tabs = [
{ name: 'ImHomeConversation', icon: 'ep:chat-round' },
{ name: 'ImHomeContact', icon: 'mingcute:contacts-line' }
] // 两个主 Tab用路由 name 而非 path避免前缀 / 嵌套调整后失效
/** 当前路由是否命中 Tab直接比对 route.name */
const isActive = (name: string) => route.name === name
/** 切换 Tab当前已选中时消息 Tab 触发"滚动到下一个未读"(对齐微信 PC其它 Tab 无动作 */
const goTab = (name: string) => {
if (route.name === name) {
if (name === 'ImHomeConversation') {
uiStore.requestNextUnreadJump()
}
return
}
router.push({ name })
}
/** 跳转个人中心(路由 name=Profile */
const goProfile = () => router.push({ name: 'Profile' })
</script>
<style scoped>
/* :deep 穿透 el-badge 子组件内部 .el-badge__content右上角红点位置 + 去掉描边 */
.tool-bar__badge :deep(.el-badge__content) {
top: 4px;
right: 8px;
border: none;
}
</style>

View File

@ -0,0 +1,67 @@
<template>
<!--
名片消息气泡 / 名片预览卡240px用户名片 + 群名片通用
- 头像 + 名字 + 群成员数副标题仅群名片+ 底部分隔条群名片 / 个人名片
- 用户名片把 :id 传给 UserAvatar 让点击 avatar UserInfoCard群名片不传 id
- 整卡 click 由调用方监听@click组件不内嵌业务逻辑
-->
<div
class="flex flex-col w-[240px] rounded-md overflow-hidden bg-[var(--el-bg-color)] border border-solid border-[var(--el-border-color-lighter)]"
:class="{ 'cursor-pointer': clickable }"
>
<div class="flex gap-2.5 items-center px-3 py-2.5">
<UserAvatar
:id="isUser ? card.targetId : undefined"
:url="card.avatar"
:name="card.name"
:size="40"
:clickable="false"
/>
<div class="flex flex-col flex-1 min-w-0">
<div class="text-sm font-medium truncate text-[var(--el-text-color-primary)]">
{{ card.name }}
</div>
<div
v-if="!isUser && card.memberCount"
class="text-12px truncate text-[var(--el-text-color-placeholder)]"
>
{{ card.memberCount }} 人群聊
</div>
</div>
</div>
<div
class="px-3 py-1 text-12px border-t border-t-solid text-[var(--el-text-color-placeholder)] border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-lighter)]"
>
{{ labelInfo.label }}
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import UserAvatar from '../user/UserAvatar.vue'
import { isPrivateConversation } from '@/views/im/utils/constants'
import {
getCardLabelInfo,
type CardMessage,
type CardTarget
} from '@/views/im/utils/message'
defineOptions({ name: 'ImCardBubble' })
const props = withDefaults(
defineProps<{
/** 名片数据CardMessage接收侧消息体或 CardTarget发送侧预览共用结构 */
card: CardMessage | CardTarget
/** 是否显示 cursor: pointer调用方负责绑 @click 监听 */
clickable?: boolean
}>(),
{ clickable: false }
)
/** 是否用户名片:决定 UserAvatar 是否带 id 触发 UserInfoCard */
const isUser = computed(() => isPrivateConversation(props.card.targetType))
/** 名片标签信息 */
const labelInfo = computed(() => getCardLabelInfo(props.card))
</script>

View File

@ -0,0 +1,28 @@
<template>
<!-- 名片单行 inline label[icon] 群名片 / 个人名片xx列表摘要 / 引用预览 / 后台预览复用 -->
<span class="inline-flex gap-1.5 items-center">
<Icon :icon="labelInfo.icon" :size="iconSize" />
<span>{{ labelInfo.label }}{{ card?.name || '' }}</span>
</span>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { getCardLabelInfo } from '@/views/im/utils/message'
defineOptions({ name: 'ImCardLineLabel' })
const props = withDefaults(
defineProps<{
/** 名片数据;只读 targetType / name 派生标签 + 显示,结构性类型兼容 CardMessage / 引用预览的 partial */
card: { targetType?: number; name?: string } | null | undefined
iconSize?: number
}>(),
{ iconSize: 14 }
)
/** 标签 + 图标按 targetType 二分;兜底「个人名片」避免 null 时 UI 空白 */
const labelInfo = computed(() => getCardLabelInfo(props.card))
</script>

View File

@ -0,0 +1,286 @@
<template>
<!--
添加好友对话框双层流程
- 第一层 search按昵称搜索用户列表
- 第二层 apply选中用户后展开申请添加朋友表单申请理由 + 备注
-->
<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 条)"
clearable
@keyup.enter="handleSearch"
>
<template #suffix>
<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="visibleUsers.length === 0"
class="py-10 text-13px text-center text-[var(--el-text-color-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(--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.isActiveFriend(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
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>
<script lang="ts" setup>
import { computed, ref } 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'
import { getCurrentUserId } from '@/utils/auth'
import { ImFriendAddSource } from '../../../utils/constants'
import { getGenderColor, getGenderIcon } from '../../../utils/user'
import { getSimpleUserListByNickname, type UserVO } from '@/api/system/user'
defineOptions({ name: 'ImFriendAddDialog' })
const visible = ref(false)
/** 预填目标用户:非 null 时跳过搜索步骤,直接进入申请表单(群成员加好友 / 名片加好友等场景) */
const presetUser = ref<UserVO | null>(null)
/** 添加来源;参见 ImFriendAddSourceEnum默认 SEARCH */
const addSource = ref<number>(ImFriendAddSource.SEARCH)
/** 来源附带信息addSource=ImFriendAddSource.GROUP 时传群名,话术拼为「我是 XX 群的 YY」 */
const addSourceExtra = ref<string>('')
defineExpose({
/** 打开加好友弹窗reset → 灌参 → visible=true不传 opts 走搜索模式 */
open(opts?: { presetUser?: UserVO | null; addSource?: number; addSourceExtra?: string }) {
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()
const message = useMessage()
/** 当前登录用户编号;用 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<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(() => !!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.getUser?.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: 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
}
/** 提交好友申请:返回 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>

View File

@ -0,0 +1,78 @@
<template>
<!--
好友单行项
- 头像 + 昵称
- 选中态 active
- 右键菜单发消息 / 删除好友由全局 ContextMenu 承接
-->
<div
class="relative flex items-center gap-2.5 px-4 py-3 cursor-pointer transition-colors hover:bg-[var(--el-fill-color)]"
:class="{ '!bg-[#d9ecff] dark:!bg-[var(--el-color-primary-light-8)]': active }"
@click="$emit('click', friend)"
@contextmenu.prevent="handleContextMenu"
>
<!-- prefix slot放在头像前给选择类弹窗的 checkbox / 圆点用不传则不渲染 -->
<slot name="prefix"></slot>
<!-- 头像 -->
<UserAvatar
:id="friend.id"
:url="friend.avatar"
:name="friend.nickname"
:size="42"
:clickable="false"
/>
<!-- 单行展示 displayName 优先昵称仅在好友详情面板展示列表里不重复 -->
<div class="flex flex-1 min-w-0">
<div class="overflow-hidden text-sm truncate text-[var(--el-text-color-primary)]">
{{ friend.displayName || friend.nickname }}
</div>
</div>
<slot></slot>
</div>
</template>
<script lang="ts" setup>
import { useImUiStore } from '../../store/uiStore'
import UserAvatar from '../user/UserAvatar.vue'
import type { FriendLite } from '../../types'
defineOptions({ name: 'ImFriendItem' })
const props = withDefaults(
defineProps<{
friend: FriendLite
active?: boolean
menu?: boolean //
}>(),
{
active: false,
menu: true
}
)
const emit = defineEmits<{
click: [friend: FriendLite]
chat: [friend: FriendLite]
delete: [friend: FriendLite]
}>()
const uiStore = useImUiStore()
/** 右键菜单:发送消息 / 删除好友 */
function handleContextMenu(event: MouseEvent) {
if (!props.menu) {
return
}
uiStore.openContextMenu(
{ x: event.clientX, y: event.clientY },
[
{ key: 'chat', name: '发送消息' },
{ key: 'delete', name: '删除好友' }
],
(item) => {
if (item.key === 'chat') emit('chat', props.friend)
else if (item.key === 'delete') emit('delete', props.friend)
}
)
}
</script>

View File

@ -0,0 +1,122 @@
<template>
<!--
设置群管理员一个弹窗合并增 / 提交时跟当前管理员列表 diff
- dialog 壳本组件持有选择 UI 委托 GroupMemberPickerPanelgrid 形态对齐当前视觉
- 群主从候选里隐藏不能设为管理员
- 对外接口ref + open({ groupId, members, currentAdminIds, hideIds, maxSize }) + emit reload()
-->
<el-dialog
v-model="visible"
title="设置群管理员"
width="700px"
:close-on-click-modal="false"
class="im-picker-dialog"
>
<div class="h-[480px]">
<GroupMemberPickerPanel
v-model:selected-ids="selectedIds"
:members="members"
:hide-ids="hideIds"
:max-size="maxSize"
selected-display="grid"
/>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleOk"></el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { useMessage } from '@/hooks/web/useMessage'
import { addGroupAdmin, removeGroupAdmin } from '@/api/im/group'
import { GROUP_ADMIN_MAX_COUNT } from '@/views/im/utils/config'
import GroupMemberPickerPanel from '../picker/GroupMemberPickerPanel.vue'
import type { GroupMemberLite } from './GroupMember.vue'
defineOptions({ name: 'ImGroupAdminSetDialog' })
const emit = defineEmits<{
/** 管理员变更成功;父侧通常用来 reload 群数据 */
reload: []
}>()
const message = useMessage()
const visible = ref(false)
const submitting = ref(false)
const groupId = ref(0)
const members = ref<GroupMemberLite[]>([])
/** 当前管理员 userId 列表:默认勾选 + 提交时 diff */
const currentAdminIds = ref<number[]>([])
const hideIds = ref<number[]>([])
const maxSize = ref(GROUP_ADMIN_MAX_COUNT)
const selectedIds = ref<number[]>([])
defineExpose({
/** 打开设置管理员弹窗reset → 灌参 → visible=true */
open(opts: {
groupId: number
members: GroupMemberLite[]
/** 当前管理员 userId 列表(默认勾选) */
currentAdminIds: number[]
/** 隐藏 userId群主 */
hideIds?: number[]
/** 已选数上限;不传走 GROUP_ADMIN_MAX_COUNT */
maxSize?: number
}) {
groupId.value = opts.groupId
members.value = opts.members
currentAdminIds.value = [...opts.currentAdminIds]
hideIds.value = opts.hideIds ? [...opts.hideIds] : []
maxSize.value = opts.maxSize ?? GROUP_ADMIN_MAX_COUNT
selectedIds.value = [...opts.currentAdminIds]
submitting.value = false
visible.value = true
}
})
/** 跟当前管理员列表做差集,分别拿到要新增 / 撤销的 userId */
async function handleOk() {
if (!groupId.value) {
return
}
const previousIds = currentAdminIds.value
const previousIdSet = new Set(previousIds)
const nextIds = selectedIds.value
const nextIdSet = new Set(nextIds)
const addedIds = nextIds.filter((id) => !previousIdSet.has(id))
const removedIds = previousIds.filter((id) => !nextIdSet.has(id))
if (addedIds.length === 0 && removedIds.length === 0) {
visible.value = false
return
}
submitting.value = true
try {
if (addedIds.length > 0) {
await addGroupAdmin({ id: groupId.value, userIds: addedIds })
}
if (removedIds.length > 0) {
await removeGroupAdmin({ id: groupId.value, userIds: removedIds })
}
message.success(`已更新群管理员(新增 ${addedIds.length} 位,撤销 ${removedIds.length} 位)`)
emit('reload')
visible.value = false
} finally {
submitting.value = false
}
}
</script>
<style scoped lang="scss">
@use '../picker/picker-dialog' as picker;
/* :deep 穿透 el-dialog 内部类;复用 picker 公共 mixin */
.im-picker-dialog {
@include picker.styles;
}
</style>

View File

@ -0,0 +1,128 @@
<template>
<!-- url 非空走原图url 空时取前 9 个成员头像拼九宫格 dataURL成员未在 store 缓存时走色卡兜底 -->
<UserAvatar
:url="finalUrl"
:name="name"
:size="size"
:radius="radius"
:clickable="clickable"
:previewable="previewable"
:preview-z-index="previewZIndex"
/>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import UserAvatar from '../user/UserAvatar.vue'
import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore'
import {
buildGroupAvatar,
getCachedGroupAvatar,
setCachedGroupAvatar
} from '../../../utils/group'
import { getMemberDisplayName } from '../../../utils/user'
import type { GroupMember } from '../../types'
defineOptions({ name: 'ImGroupAvatar' })
const props = withDefaults(
defineProps<{
groupId: number // store
url?: string // URL
name?: string //
size?: number // px
radius?: string // CSS
clickable?: boolean // false
previewable?: boolean //
previewZIndex?: number // z-index
}>(),
{
size: 42,
radius: '15%',
clickable: false,
previewable: false,
previewZIndex: 2000
}
)
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const mergedUrl = ref('')
// await
let mergeToken = 0
/** 按容器 size × DPR 算 canvas 实际像素,避免 2x / 3x retina 屏拼图糊DPR 封顶 3 防止超高分辨率画布过大 */
function getTargetSize(size: number): number {
const dpr = Math.min(window.devicePixelRatio || 1, 3)
return Math.max(Math.round(size * dpr), 64)
}
/** store 里整群成员是否「完整加载」过;只在为 true 时才拼图,避免列表场景批量发接口 */
const loadedMembers = computed<GroupMember[] | null>(() => {
const g = groupStore.getGroup(props.groupId)
if (!g?.membersLoaded || !g.members) {
return null
}
return g.members
})
/** 前 9 个成员的拼图入参name 走 getMemberDisplayName 口径(好友备注 > 群昵称 > 真实昵称) */
const memberItems = computed(() => {
const members = loadedMembers.value
if (!members) {
return []
}
return members.slice(0, 9).map((m) => ({
avatar: m.avatar || '',
name: getMemberDisplayName(m, friendStore.getFriend(m.userId))
}))
})
/** 成员快照签名:拼 (avatar, name) 字段,原地修改任一字段都会让 watch 重算 */
const memberSignature = computed(() =>
memberItems.value.map((it) => `${it.avatar}#${it.name}`).join('|')
)
/** 走 buildGroupAvatar 拼图并写回 mergedUrlmergeToken 校验避免老 await 覆盖新结果 */
async function applyMerge(key: string, targetSize: number): Promise<void> {
const myToken = ++mergeToken
const cached = getCachedGroupAvatar(key)
if (cached) {
mergedUrl.value = cached
return
}
const dataUrl = await buildGroupAvatar(memberItems.value, { targetSize })
if (myToken !== mergeToken) {
return
}
if (dataUrl) {
setCachedGroupAvatar(key, dataUrl)
}
mergedUrl.value = dataUrl
}
watch(
() => [props.url, props.groupId, props.size, memberSignature.value] as const,
([url, groupId, size, signature]) => {
if (url) {
mergedUrl.value = ''
return
}
if (!signature) {
mergeToken++
mergedUrl.value = ''
groupStore.loadGroupMemberList(groupId)
return
}
const targetSize = getTargetSize(size)
const key = `${groupId}:${targetSize}:${signature}`
applyMerge(key, targetSize)
},
{ immediate: true }
)
/** 最终展示 url服务端 url 优先 → 拼图 → 空字符串(让 UserAvatar 走色卡) */
const finalUrl = computed(() => props.url || mergedUrl.value)
</script>

View File

@ -0,0 +1,142 @@
<template>
<!--
发起群聊选好友 默认按所选成员名生成群名 createGroup
- dialog 壳本组件持有选择 UI 委托 FriendPickerPanel
- lockedIds 由调用方通过 open({ lockedIds }) 传入私聊侧 +建群锁定对方
- 不再要求先输入群名 / 不再展示进群审批开关对齐微信 PC
- 对外接口ref + open({ lockedIds }) + emit created(groupId)
-->
<el-dialog
v-model="visible"
title="发起群聊"
width="720px"
:close-on-click-modal="false"
class="im-picker-dialog"
>
<div class="h-[480px]">
<FriendPickerPanel
v-model:selected-ids="selectedIds"
:friends="friends"
:locked-ids="lockedIds"
/>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitting" :disabled="!canSubmit" @click="handleOk">
完成
</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { useMessage } from '@/hooks/web/useMessage'
import { createGroup } from '@/api/im/group'
import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore'
import { buildDefaultGroupName } from '../../../utils/group'
import FriendPickerPanel from '../picker/FriendPickerPanel.vue'
import type { FriendLite } from '../../types'
defineOptions({ name: 'ImGroupCreateDialog' })
const emit = defineEmits<{
/** 创建成功,携带新群编号;父侧通常用来跳转到新群会话 */
created: [groupId: number]
}>()
const message = useMessage()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const visible = ref(false)
const submitting = ref(false)
const lockedIds = ref<number[]>([])
const selectedIds = ref<number[]>([])
defineExpose({
/** 打开发起群聊弹窗reset → 灌参 → visible=true */
open(opts?: { lockedIds?: number[] }) {
lockedIds.value = opts?.lockedIds ? [...opts.lockedIds] : []
selectedIds.value = []
submitting.value = false
visible.value = true
}
})
/** 全量好友:直接复用 friendStore Lite 视图(带拼音字段供分桶用) */
const friends = computed<FriendLite[]>(() => friendStore.getActiveFriendLiteList)
/** 完成按钮可点:至少有 1 个非 locked 勾选locked 是入口锁定项,不算"用户主动选择" */
const canSubmit = computed(() => selectedIds.value.length > 0)
/** 拿到所有要进群的好友locked + selected建群默认群名按这批人生成 */
function resolveMembersToInvite(): FriendLite[] {
const seen = new Set<number>()
const result: FriendLite[] = []
const byId = new Map(friends.value.map((f) => [f.id, f]))
for (const id of lockedIds.value) {
if (seen.has(id)) {
continue
}
const friend = byId.get(id)
if (friend) {
seen.add(id)
result.push(friend)
}
}
for (const id of selectedIds.value) {
if (seen.has(id)) {
continue
}
const friend = byId.get(id)
if (friend) {
seen.add(id)
result.push(friend)
}
}
return result
}
/** 创建群聊:建群(同时邀请初始成员)→ upsert groupStore → emit created 让父页跳转新会话 */
async function handleOk() {
const members = resolveMembersToInvite()
if (members.length === 0) {
return
}
submitting.value = true
try {
const memberUserIds = members.map((m) => m.id)
const name = buildDefaultGroupName(members)
const group = await createGroup({ name, memberUserIds, joinApproval: false })
if (!group?.id) {
throw new Error('创建群失败:未返回群编号')
}
// upsert groupStore fetchGroupList VO
groupStore.upsertGroup({
id: group.id,
name: group.name,
avatar: group.avatar,
notice: group.notice,
ownerUserId: group.ownerUserId
})
message.success('群聊创建成功')
emit('created', group.id)
visible.value = false
} finally {
submitting.value = false
}
}
</script>
<style scoped lang="scss">
@use '../picker/picker-dialog' as picker;
/* :deep 穿透 el-dialog 内部类;复用 picker 公共 mixin */
.im-picker-dialog {
@include picker.styles;
}
</style>

View File

@ -0,0 +1,141 @@
<template>
<!--
群信息内容组件 UserInfo 对位
- 头像 + 群名 + 成员数 + 成员宫格 + 动作区纯展示 + 抛事件业务由父级承接
- relation groupStore 缓存推导命中 = member已加群否则 = stranger未加群 id = readonly
- 成员宫格仅 member 时拉取陌生群拉不到所有信息走 props.group 卡片快照
-->
<div class="flex justify-center">
<div class="w-full max-w-[320px] flex flex-col gap-3 items-center">
<GroupAvatar
:group-id="group.id"
:url="group.showImage || group.showImageThumb"
:name="group.showGroupName || group.name"
:size="72"
:previewable="isMember"
/>
<div
class="w-full text-lg font-semibold leading-snug text-[var(--el-text-color-primary)] truncate text-center"
>
{{ group.showGroupName || group.name }}
</div>
<div v-if="memberCountText" class="text-13px text-[var(--el-text-color-secondary)]">
{{ memberCountText }}
</div>
<!-- 成员宫格 member 渲染陌生群拉不到成员 -->
<div v-if="isMember && members.length" class="flex flex-wrap gap-2 justify-center w-full pt-2">
<GroupMemberGrid
v-for="member in members"
:key="member.userId"
:member="member"
:group-name="group.name"
/>
</div>
<!-- 动作区member 进入群聊 / stranger 加入群聊 / readonly 不渲染 -->
<div v-if="isMember" class="mt-4">
<el-button type="primary" @click="emit('chat', group)">进入群聊</el-button>
</div>
<div v-else-if="isStranger" class="mt-4">
<el-button type="primary" @click="emit('apply', group)">加入群聊</el-button>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import GroupAvatar from './GroupAvatar.vue'
import GroupMemberGrid from './GroupMemberGrid.vue'
import { getCurrentUserId } from '@/utils/auth'
import { CommonStatusEnum } from '@/utils/constants'
import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore'
import { getMemberDisplayName } from '../../../utils/user'
import type { Friend, GroupLite, GroupMember } from '../../types'
import type { GroupMemberLite } from './GroupMember.vue'
defineOptions({ name: 'ImGroupInfo' })
const props = defineProps<{
group: GroupLite
}>()
const emit = defineEmits<{
/** member 点「进入群聊」;父级负责切会话 + 关浮层 */
chat: [group: GroupLite]
/** stranger 点「加入群聊」;父级负责弹申请理由 + 调 applyJoinGroup */
apply: [group: GroupLite]
}>()
const groupStore = useGroupStore()
const friendStore = useFriendStore()
const members = ref<GroupMemberLite[]>([])
/**
* 是否已加群基于"自己确实在成员列表里"判断
* - 缓存未命中直接 false陌生群
* - 命中且 members 已拉精准查 self.userId 在不在
* - 命中但 members 未拉fetchGroupList 接口语义即我加入的群命中视为 member拉成员后会自动收敛
*/
const isMember = computed(() => {
if (!props.group?.id) {
return false
}
const cached = groupStore.getGroup(props.group.id)
if (!cached) {
return false
}
if (cached.membersLoaded && cached.members) {
const myId = getCurrentUserId()
return cached.members.some(
(m) => m.userId === myId && m.status === CommonStatusEnum.ENABLE
)
}
return true
})
/** 是否未加群:有 id 但 isMember 不成立;对比 isMember 用于动作区按钮分支 */
const isStranger = computed(() => !!props.group?.id && !isMember.value)
/** 成员数文案member 优先用本地拉到的列表长度stranger 用 props.group.memberCount 卡片快照 */
const memberCountText = computed(() => {
const count = isMember.value
? props.group.memberCount || members.value.length
: props.group.memberCount
return count ? `${count} 位成员` : ''
})
/** member 切群 / 首挂:拉成员;竞态用 group.id 比对丢弃陈旧响应避免上一条群成员错位 */
watch(
() => [props.group?.id, isMember.value] as const,
async ([id, member]) => {
members.value = []
if (!id || !member) {
return
}
const list = await groupStore.fetchGroupMemberList(id)
if (props.group?.id !== id) {
return
}
members.value = list.map((m) =>
convertGroupMemberLite(m, friendStore.getFriend(m.userId))
)
},
{ immediate: true }
)
/** 群成员 → 列表项 */
function convertGroupMemberLite(member: GroupMember, friend: Friend | undefined): GroupMemberLite {
return {
userId: member.userId,
showName: getMemberDisplayName(member, friend),
nickname: member.nickname,
avatar: member.avatar,
status: member.status,
role: member.role
}
}
</script>

View File

@ -0,0 +1,104 @@
<template>
<!--
群信息浮层 UserInfoCard 对位
- 仅承担浮层定位 + 关闭逻辑点遮罩 / Esc群信息视觉走 <GroupInfo>与通讯录详情共用一份组件
- 触发useImUiStore.openGroupInfoCardAtEvent(group, e)
- GroupInfo 内部按 groupStore 缓存推导 member / stranger浮层只负责接 chat / apply 事件做业务
-->
<teleport to="body">
<div v-if="card.show" class="fixed inset-0 z-9998" @click.self="handleClose">
<div
class="fixed w-80 p-4 bg-[var(--el-bg-color-overlay)] rounded-md shadow-xl"
:style="{ left: card.position.x + 'px', top: card.position.y + 'px' }"
@click.stop
>
<GroupInfo v-if="card.group" :group="card.group" @chat="handleChat" @apply="handleApply" />
</div>
</div>
</teleport>
</template>
<script lang="ts" setup>
import { computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import GroupInfo from './GroupInfo.vue'
import { useImUiStore } from '../../store/uiStore'
import { useConversationStore } from '../../store/conversationStore'
import { useGroupStore } from '../../store/groupStore'
import { applyJoinGroup } from '@/api/im/group/request'
import { ImConversationType, ImGroupAddSource } from '../../../utils/constants'
import { getGroupDisplayName } from '../../../utils/user'
import type { GroupLite } from '../../types'
defineOptions({ name: 'ImGroupInfoCard' })
const uiStore = useImUiStore()
const conversationStore = useConversationStore()
const groupStore = useGroupStore()
const router = useRouter()
const card = computed(() => uiStore.groupInfoCard)
/** 关闭浮层 */
function handleClose() {
uiStore.closeGroupInfoCard()
}
/** Esc 关闭 */
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && card.value.show) {
handleClose()
}
}
onMounted(() => window.addEventListener('keydown', handleKeydown))
onUnmounted(() => window.removeEventListener('keydown', handleKeydown))
/** 进入群聊:取本地最新群信息(含 silent / 群备注),新建或激活会话 + 跳路由 */
function handleChat(group: GroupLite) {
const cached = groupStore.getGroup(group.id)
// cached getGroupDisplayName contact / cached showGroupName /
const displayName = cached
? getGroupDisplayName(cached)
: group.showGroupName || group.name || ''
//
conversationStore.openConversation(
group.id,
ImConversationType.GROUP,
displayName,
cached?.avatar || group.showImage || '',
{ silent: !!cached?.silent }
)
// MessagePanel
if (router.currentRoute.value.name !== 'ImHomeConversation') {
router.push({ name: 'ImHomeConversation' })
}
handleClose()
}
/** 加入群聊:先关浮层(避免与 prompt 的 mask 互相遮挡)→ 弹申请理由(可选)→ applyJoinGroup */
async function handleApply(group: GroupLite) {
handleClose()
let applyContent = ''
try {
const result = await ElMessageBox.prompt(`申请加入「${group.name || ''}`, '申请加群', {
confirmButtonText: '发送申请',
cancelButtonText: '取消',
inputPlaceholder: '请填写验证消息(可选)',
inputValue: ''
})
applyContent = (result.value || '').trim()
} catch {
return
}
await applyJoinGroup({
groupId: group.id,
applyContent: applyContent || undefined,
addSource: ImGroupAddSource.SHARE_LINK
})
ElMessage.success('加群申请已发送')
}
</script>

View File

@ -0,0 +1,42 @@
<template>
<!--
群单行项
- 头像 + 群名
- 选中态 active
-->
<div
class="relative flex items-center gap-2.5 px-4 py-3 cursor-pointer transition-colors hover:bg-[var(--el-fill-color)]"
:class="{ '!bg-[#d9ecff] dark:!bg-[var(--el-color-primary-light-8)]': active }"
@click="$emit('click', group)"
>
<GroupAvatar
:group-id="group.id"
:url="group.showImage || group.showImageThumb"
:name="group.showGroupName || group.name"
:size="42"
/>
<div class="flex flex-1 min-w-0">
<!-- 单行展示群名成员数仅在群详情面板展示列表里不重复 -->
<div class="overflow-hidden text-sm truncate text-[var(--el-text-color-primary)]">
{{ group.showGroupName || group.name }}
</div>
</div>
<slot></slot>
</div>
</template>
<script lang="ts" setup>
import GroupAvatar from './GroupAvatar.vue'
import type { GroupLite } from '../../types'
defineOptions({ name: 'ImGroupItem' })
defineProps<{
group: GroupLite
active?: boolean
}>()
defineEmits<{
click: [group: GroupLite]
}>()
</script>

View File

@ -0,0 +1,64 @@
<template>
<!--
群成员单行
跨子域复用@候选 (MentionPicker) / 已读列表 (MessageReadStatus) / 群成员宫格 (ConversationGroupSide)
-->
<div
class="relative flex items-center px-[5px] box-border whitespace-nowrap"
:class="{ 'bg-[#e1eaf7] dark:bg-[var(--el-color-primary-light-9)]': active }"
:style="{ height: height + 'px' }"
>
<UserAvatar
:size="avatarSize"
:name="member.nickname"
:url="member.avatar"
:clickable="clickable"
:id="member.userId"
:add-source="ImFriendAddSource.GROUP"
: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)]"
:style="{ lineHeight: height + 'px' }"
>
{{ member.showName }}
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import UserAvatar from '../user/UserAvatar.vue'
import { ImFriendAddSource } from '../../../utils/constants'
defineOptions({ name: 'ImGroupMember' })
/** 群成员结构(跨多处使用,放这里做窄接口;独立于 types/index.ts */
export interface GroupMemberLite {
userId: number // IM_AT_ALL_USER_ID@
nickname: string // nickname UserAvatar
showName: string // > displayUserName > nickname""@
avatar?: string
status?: number
role?: number // 成员角色,仅在群信息抽屉等需要展示角色标签的场景透传;@候选 / 已读列表等场景可不传
}
const props = withDefaults(
defineProps<{
member: GroupMemberLite
height?: number // px
active?: boolean // @
clickable?: boolean // UserInfoCard@
groupName?: string // 'XX ' YY add_source=GROUP
}>(),
{
height: 50,
active: false,
clickable: false,
groupName: ''
}
)
const avatarSize = computed(() => Math.ceil(props.height * 0.75))
</script>

View File

@ -0,0 +1,164 @@
<template>
<!--
添加群成员选好友邀请入群已在群成员置灰不计入已选
- dialog 壳本组件持有选择 UI 委托 FriendPickerPanel
- 已在群成员通过 disabledIds 传入不再走 checked+disabled "已勾选灰态"
- 对外接口ref + open({ groupId }) + emit reload(friendIds)
-->
<el-dialog
v-model="visible"
title="添加群成员"
width="720px"
:close-on-click-modal="false"
class="im-picker-dialog"
>
<div class="h-[480px]">
<FriendPickerPanel
v-model:selected-ids="selectedIds"
:friends="friends"
:disabled-ids="disabledIds"
/>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitting" :disabled="!canSubmit" @click="handleOk">
添加
</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { useMessage } from '@/hooks/web/useMessage'
import { CommonStatusEnum } from '@/utils/constants'
import { inviteGroupMember } from '@/api/im/group/member'
import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore'
import { getCurrentUserId } from '@/utils/auth'
import { ImGroupMemberRole } from '@/views/im/utils/constants'
import { GROUP_MAX_MEMBER } from '@/views/im/utils/config'
import FriendPickerPanel from '../picker/FriendPickerPanel.vue'
import type { GroupMemberLite } from './GroupMember.vue'
defineOptions({ name: 'ImGroupMemberAddDialog' })
const emit = defineEmits<{
/** 邀请成功,携带被邀请的好友 id 列表;父侧通常用来 reload 群成员 */
reload: [friendIds: number[]]
}>()
const message = useMessage()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const visible = ref(false)
const submitting = ref(false)
const groupId = ref(0)
const selectedIds = ref<number[]>([])
defineExpose({
/** 打开添加群成员弹窗reset → 灌参 → visible=true */
open(opts: { groupId: number }) {
groupId.value = opts.groupId
selectedIds.value = []
submitting.value = false
visible.value = true
}
})
/** 当前群成员列表:从 groupStore 现取,避免随 groupId 变化时父侧 prop 更新延迟 */
const members = computed<GroupMemberLite[]>(() => {
const group = groupStore.getGroup(groupId.value)
return (group?.members || []).map((member) => ({
userId: member.userId,
nickname: member.nickname,
showName: member.displayUserName || member.nickname,
avatar: member.avatar,
status: member.status,
role: member.role
}))
})
/** 全量好友:直接复用 friendStore Lite 视图 */
const friends = computed(() => friendStore.getActiveFriendLiteList)
/** 已在群里的好友 id传给 Panel 的 disabledIds 置灰 + 不计入已选 */
const disabledIds = computed<number[]>(() =>
members.value
.filter((member) => member.status !== CommonStatusEnum.DISABLE)
.map((member) => member.userId)
)
/** 是否走审批分支:群开启 joinApproval + 当前用户是普通成员(群主 / 管理员邀请直进) */
const willGoApproval = computed(() => {
const group = groupStore.getGroup(groupId.value)
if (!group?.joinApproval) {
return false
}
const myId = getCurrentUserId()
if (!myId) {
return false
}
// members admin members
if (group.ownerUserId === myId) {
return false
}
// members admin
const myRole = members.value.find((member) => member.userId === myId)?.role
if (myRole == null) {
return false
}
return myRole !== ImGroupMemberRole.ADMIN
})
/** 当前群已启用成员数DISABLE 即退群 / 被踢不计入),用于上限判定 */
const activeMemberCount = computed(
() => members.value.filter((member) => member.status !== CommonStatusEnum.DISABLE).length
)
/** 邀请后群总人数若超 GROUP_MAX_MEMBER前端先拦activeMemberCount + selectedIds.length 即邀请后的成员数 */
const willExceedLimit = computed(
() => activeMemberCount.value + selectedIds.value.length > GROUP_MAX_MEMBER
)
/** 添加按钮可点:至少有 1 个新邀请的好友 + 不超群人数上限 */
const canSubmit = computed(() => selectedIds.value.length > 0 && !willExceedLimit.value)
/** 邀请入群:调 /im/group/invite成功后 emit reload 让父侧刷新群成员 */
async function handleOk() {
if (!groupId.value) {
return
}
const memberUserIds = [...selectedIds.value]
if (memberUserIds.length === 0) {
return
}
// canSubmit await
if (activeMemberCount.value + memberUserIds.length > GROUP_MAX_MEMBER) {
message.warning(`群成员上限为 ${GROUP_MAX_MEMBER}`)
return
}
submitting.value = true
try {
await inviteGroupMember({ groupId: groupId.value, memberUserIds })
//
message.success(willGoApproval.value ? '邀请已发起,等待群主 / 管理员审批' : '邀请成功')
emit('reload', memberUserIds)
visible.value = false
} finally {
submitting.value = false
}
}
</script>
<style scoped lang="scss">
@use '../picker/picker-dialog' as picker;
/* :deep 穿透 el-dialog 内部类;复用 picker 公共 mixin */
.im-picker-dialog {
@include picker.styles;
}
</style>

View File

@ -0,0 +1,49 @@
<template>
<!--
群成员宫格单元
- 宫格展示的最小单位头像在上名字在下列宽 = size + 16自适应 size 留呼吸空间
- GroupMemberPickerPanel 右侧已选区grid 形态ConversationGroupSide 群成员区循环使用
-->
<div
class="relative flex flex-col items-center px-0.5 py-1"
:style="{ width: `${size! + 16}px` }"
>
<UserAvatar
:id="member.userId"
:url="member.avatar"
:name="member.nickname"
:size="size"
:clickable="clickable"
:add-source="ImFriendAddSource.GROUP"
: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)]"
>
{{ member.showName }}
</div>
<slot></slot>
</div>
</template>
<script lang="ts" setup>
import UserAvatar from '../user/UserAvatar.vue'
import { ImFriendAddSource } from '../../../utils/constants'
import type { GroupMemberLite } from './GroupMember.vue'
defineOptions({ name: 'ImGroupMemberGrid' })
withDefaults(
defineProps<{
member: GroupMemberLite
clickable?: boolean // UserInfoCard
size?: number // 38 50 PC
groupName?: string // 'XX ' YY add_source=GROUP
}>(),
{
clickable: false,
size: 38,
groupName: ''
}
)
</script>

View File

@ -0,0 +1,81 @@
<template>
<!--
群成员行形态
- 横排 hover slot checkbox / 操作按钮等
- GroupMember 的差别 hover + slot 扩展点适合 selector / admin 列表
-->
<div
class="relative flex gap-2.5 items-center mx-px px-4 box-border whitespace-nowrap rounded cursor-pointer transition-colors hover:bg-[var(--el-fill-color)]"
:class="{ '!bg-[#e1eaf7] dark:!bg-[var(--el-color-primary-light-8)]': active }"
:style="{ height: height + 'px' }"
@click="$emit('click', member)"
>
<UserAvatar
:id="member.userId"
:url="member.avatar"
:name="member.nickname"
:size="avatarSize"
:clickable="false"
/>
<div
class="flex-1 h-full pl-1 overflow-hidden text-sm truncate text-[var(--el-text-color-primary)]"
:style="{ lineHeight: height + 'px' }"
>
{{ member.showName }}
</div>
<!-- 角色标签群主 / 管理员普通成员不显示仅在传入 member.role 时生效 -->
<span
v-if="roleLabel"
class="px-1.5 py-px rounded text-xs whitespace-nowrap"
:class="roleLabelClass"
>
{{ roleLabel }}
</span>
<slot></slot>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import UserAvatar from '../user/UserAvatar.vue'
import type { GroupMemberLite } from './GroupMember.vue'
import { ImGroupMemberRole } from '../../../utils/constants'
import { DICT_TYPE, getDictLabel } from '@/utils/dict'
defineOptions({ name: 'ImGroupMemberItem' })
const props = withDefaults(
defineProps<{
member: GroupMemberLite
height?: number
active?: boolean
}>(),
{
height: 50,
active: false
}
)
defineEmits<{
click: [member: GroupMemberLite]
}>()
const avatarSize = computed(() => Math.ceil(props.height * 0.75))
/** 角色标签文案:普通成员不显示,其余取 im_group_member_role 字典 label */
const roleLabel = computed(() => {
if (props.member.role == null || props.member.role === ImGroupMemberRole.NORMAL) {
return ''
}
return getDictLabel(DICT_TYPE.IM_GROUP_MEMBER_ROLE, props.member.role)
})
/** 角色标签样式:群主用主色;管理员用次要色 */
const roleLabelClass = computed(() => {
if (props.member.role === ImGroupMemberRole.OWNER) {
return 'text-[var(--el-color-primary)] bg-[var(--el-color-primary-light-9)]'
}
return 'text-[var(--el-color-info)] bg-[var(--el-fill-color)]'
})
</script>

View File

@ -0,0 +1,104 @@
<template>
<!--
移除群成员选成员 removeGroupMember 批量踢人
- dialog 壳本组件持有选择 UI 委托 GroupMemberPickerPanel
- 群主 / 管理员视角的不可移除成员通过 hideIds 隐藏由调用方传入
- 对外接口ref + open({ groupId, members, hideIds }) + emit reload()
-->
<el-dialog
v-model="visible"
title="移出群成员"
width="700px"
:close-on-click-modal="false"
class="im-picker-dialog"
>
<div class="h-[480px]">
<GroupMemberPickerPanel
v-model:selected-ids="selectedIds"
:members="members"
:hide-ids="hideIds"
:max-size="50"
/>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button
type="primary"
:loading="submitting"
:disabled="selectedIds.length === 0"
@click="handleOk"
>
移出
</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { useMessage } from '@/hooks/web/useMessage'
import { removeGroupMember } from '@/api/im/group/member'
import GroupMemberPickerPanel from '../picker/GroupMemberPickerPanel.vue'
import type { GroupMemberLite } from './GroupMember.vue'
defineOptions({ name: 'ImGroupMemberRemoveDialog' })
const emit = defineEmits<{
/** 移出成功;父侧通常用来 reload 群数据 */
reload: []
}>()
const message = useMessage()
const visible = ref(false)
const submitting = ref(false)
const groupId = ref(0)
const members = ref<GroupMemberLite[]>([])
const hideIds = ref<number[]>([])
const selectedIds = ref<number[]>([])
defineExpose({
/** 打开移除群成员弹窗reset → 灌参 → visible=true */
open(opts: {
groupId: number
members: GroupMemberLite[]
/** 隐藏 userId群主始终隐藏管理员视角额外隐藏其它管理员 */
hideIds?: number[]
}) {
groupId.value = opts.groupId
members.value = opts.members
hideIds.value = opts.hideIds ? [...opts.hideIds] : []
selectedIds.value = []
submitting.value = false
visible.value = true
}
})
/** 一次性批量踢人:选中成员 userId 数组传给后端,比循环调 N 次接口省往返 */
async function handleOk() {
if (!groupId.value || selectedIds.value.length === 0) {
return
}
submitting.value = true
try {
const memberUserIds = [...selectedIds.value]
await removeGroupMember({ groupId: groupId.value, memberUserIds })
message.success(`已移除 ${memberUserIds.length} 位成员`)
emit('reload')
visible.value = false
} finally {
submitting.value = false
}
}
</script>
<style scoped lang="scss">
@use '../picker/picker-dialog' as picker;
/* :deep 穿透 el-dialog 内部类;复用 picker 公共 mixin */
.im-picker-dialog {
@include picker.styles;
}
</style>

View File

@ -0,0 +1,97 @@
<template>
<!-- 禁言时长选择弹窗 -->
<el-dialog v-model="visible" title="设置禁言" width="560px" :close-on-click-modal="false">
<div class="flex flex-col gap-4">
<!-- 成员信息卡 FriendAddDialog user 卡保持一致的浅色背景 -->
<div
class="flex items-center gap-2 px-3 py-2.5 rounded-md bg-[var(--el-fill-color-light)]"
>
<span class="text-13px text-[var(--el-text-color-secondary)]">禁言成员</span>
<span
class="text-sm font-medium text-[var(--el-text-color-primary)] truncate"
>
{{ memberName }}
</span>
</div>
<!-- 禁言时长选项 el-button 平铺选中走 primary gap-2 留间距 -->
<div>
<div class="mb-2 text-13px text-[var(--el-text-color-secondary)]">禁言时长</div>
<div class="grid grid-cols-3 gap-2">
<el-button
v-for="opt in presets"
:key="opt.value"
:type="selected === opt.value ? 'primary' : ''"
class="!ml-0 w-full"
@click="selected = opt.value"
>
{{ opt.label }}
</el-button>
</div>
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleConfirm"></el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { useMessage } from '@/hooks/web/useMessage'
import { muteMember } from '@/api/im/group'
defineOptions({ name: 'ImGroupMuteMemberDialog' })
const emit = defineEmits<{
success: []
}>()
const { success: successMessage } = useMessage()
const visible = ref(false)
const loading = ref(false)
const groupId = ref(0)
const userId = ref(0)
const memberName = ref('')
const selected = ref(600) // 10
const presets = [
{ label: '10 分钟', value: 600 },
{ label: '1 小时', value: 3600 },
{ label: '12 小时', value: 43200 },
{ label: '1 天', value: 86400 },
{ label: '7 天', value: 604800 },
{ label: '30 天', value: 2592000 },
{ label: '永久', value: 0 }
]
/** 打开弹窗 */
function open(gid: number, uid: number, name: string) {
groupId.value = gid
userId.value = uid
memberName.value = name
selected.value = 600
visible.value = true
}
/** 确认禁言 */
async function handleConfirm() {
loading.value = true
try {
await muteMember({
id: groupId.value,
userId: userId.value,
mutedSeconds: selected.value
})
successMessage('禁言成功')
visible.value = false
emit('success')
} finally {
loading.value = false
}
}
defineExpose({ open })
</script>

View File

@ -0,0 +1,123 @@
<template>
<!--
转让群主 1 位新群主 二次确认 transferGroupOwner
- dialog 壳本组件持有选择 UI 委托 GroupMemberPickerPanel
- 当前用户从候选里隐藏不能转给自己
- maxSize=1 限定单选
- 对外接口ref + open({ groupId, members, hideIds }) + emit reload()
-->
<el-dialog
v-model="visible"
title="选择新群主"
width="700px"
:close-on-click-modal="false"
class="im-picker-dialog"
>
<div class="h-[480px]">
<GroupMemberPickerPanel
v-model:selected-ids="selectedIds"
:members="members"
:hide-ids="hideIds"
:max-size="1"
/>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button
type="primary"
:loading="submitting"
:disabled="selectedIds.length === 0"
@click="handleOk"
>
确定
</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { useMessage } from '@/hooks/web/useMessage'
import { transferGroupOwner } from '@/api/im/group'
import GroupMemberPickerPanel from '../picker/GroupMemberPickerPanel.vue'
import type { GroupMemberLite } from './GroupMember.vue'
defineOptions({ name: 'ImGroupOwnerTransferDialog' })
const emit = defineEmits<{
/** 转让成功;父侧通常用来 reload 群数据 */
reload: []
}>()
const message = useMessage()
const visible = ref(false)
const submitting = ref(false)
const groupId = ref(0)
const members = ref<GroupMemberLite[]>([])
const hideIds = ref<number[]>([])
const selectedIds = ref<number[]>([])
defineExpose({
/** 打开转让群主弹窗reset → 灌参 → visible=true */
open(opts: {
groupId: number
members: GroupMemberLite[]
/** 隐藏 userId当前用户不能转给自己 */
hideIds?: number[]
}) {
groupId.value = opts.groupId
members.value = opts.members
hideIds.value = opts.hideIds ? [...opts.hideIds] : []
selectedIds.value = []
submitting.value = false
visible.value = true
}
})
/** 选中的新群主对象(取数组首项) */
const newOwner = computed<GroupMemberLite | undefined>(() => {
if (selectedIds.value.length === 0) {
return undefined
}
return members.value.find((member) => member.userId === selectedIds.value[0])
})
/** 二次确认转让:转让后旧群主降为普通成员,无法撤销 */
async function handleOk() {
if (!groupId.value || !newOwner.value) {
return
}
try {
await message.confirm(
`确定将群主转让给 ${newOwner.value.showName}?转让后你将变为普通成员,无法撤销。`,
'确认转让群主'
)
} catch {
return
}
submitting.value = true
try {
await transferGroupOwner({
id: groupId.value,
newOwnerUserId: newOwner.value.userId
})
message.success('群主转让成功')
emit('reload')
visible.value = false
} finally {
submitting.value = false
}
}
</script>
<style scoped lang="scss">
@use '../picker/picker-dialog' as picker;
/* :deep 穿透 el-dialog 内部类;复用 picker 公共 mixin */
.im-picker-dialog {
@include picker.styles;
}
</style>

View File

@ -0,0 +1,361 @@
<template>
<!--
进群申请列表对话框
- 仅群主 / 管理员入口可达展示当前群下全部申请含已处理
- 顶部最新一条卡片化突出带申请理由其余按 id 倒序紧凑列表
- 同意 / 拒绝走 groupRequestStore action处理后本地更新 handleResult 让按钮转灰态
-->
<el-dialog
v-model="visible"
title="进群申请"
width="560px"
:close-on-click-modal="false"
class="im-group-request-list__dialog"
>
<div v-loading="loading" class="flex flex-col gap-3 max-h-[60vh] overflow-y-auto pr-1">
<!-- 空态 -->
<el-empty v-if="!loading && list.length === 0" description="暂无进群申请" />
<!-- 顶部卡片最新一条 -->
<div
v-if="latest"
class="flex flex-col gap-2.5 p-3.5 rounded-[10px] border border-solid border-[var(--el-border-color-lighter)] bg-[var(--el-bg-color)] shadow-[0_1px_3px_rgba(0,0,0,0.04)]"
>
<div class="flex items-center gap-3">
<UserAvatar
:url="latest.userAvatar"
:name="latest.userNickname"
:size="44"
:clickable="false"
/>
<div class="flex-1 min-w-0">
<div
class="truncate text-sm font-medium leading-[1.4] text-[var(--el-text-color-primary)]"
>
{{ latest.userNickname || `用户 ${latest.userId}` }}
</div>
<div
class="truncate mt-[2px] text-12px leading-[1.5] text-[var(--el-text-color-secondary)]"
>
<template v-if="latest.inviterUserId">
通过
<span class="text-[var(--el-color-primary)]">
{{ latest.inviterNickname || `用户 ${latest.inviterUserId}` }}
</span>
的邀请进群
</template>
<template v-else></template>
</div>
</div>
<span
v-if="latest.handleResult === ImGroupRequestHandleResult.AGREED"
class="flex-shrink-0 text-[13px] text-[var(--el-text-color-placeholder)]"
>
已同意
</span>
<span
v-else-if="latest.handleResult === ImGroupRequestHandleResult.REFUSED"
class="flex-shrink-0 text-[13px] text-[var(--el-text-color-placeholder)]"
>
已拒绝
</span>
<div v-else class="flex gap-1.5 flex-shrink-0">
<button
class="im-group-request-list__btn im-group-request-list__btn--primary"
:disabled="actingId === latest.id"
@click="handleAgree(latest)"
>
确认
</button>
<button
class="im-group-request-list__btn im-group-request-list__btn--ghost"
:disabled="actingId === latest.id"
@click="handleRefuse(latest)"
>
拒绝
</button>
</div>
</div>
<!-- 申请理由邀请场景显示邀请人 + 留言主动申请显示申请人 + 留言 -->
<div
v-if="latest.applyContent"
class="px-3 py-2 rounded-md text-[13px] leading-[1.5] break-all bg-[var(--el-fill-color-light)] text-[var(--el-text-color-regular)]"
>
<span class="text-[var(--el-color-primary)]">
{{
latest.inviterUserId
? latest.inviterNickname || `用户 ${latest.inviterUserId}`
: latest.userNickname || `用户 ${latest.userId}`
}}
</span>
{{ latest.applyContent }}
</div>
</div>
<!-- 分割线仅在有更早申请时出现 -->
<div
v-if="histories.length > 0"
class="flex items-center justify-center mt-1.5 -mb-0.5 text-12px text-[var(--el-text-color-placeholder)]"
>
<span>以下为更早的申请</span>
</div>
<!-- 历史申请列表 -->
<div
v-for="item in histories"
:key="item.id"
class="flex flex-col gap-2.5 px-3.5 py-2.5 rounded-[10px] border border-solid border-[var(--el-border-color-lighter)] bg-[var(--el-bg-color)] shadow-[0_1px_3px_rgba(0,0,0,0.04)]"
>
<div class="flex items-center gap-3">
<UserAvatar
:url="item.userAvatar"
:name="item.userNickname"
:size="40"
:clickable="false"
/>
<div class="flex-1 min-w-0">
<div
class="truncate text-sm font-medium leading-[1.4] text-[var(--el-text-color-primary)]"
>
{{ item.userNickname || `用户 ${item.userId}` }}
</div>
<div
class="truncate mt-[2px] text-12px leading-[1.5] text-[var(--el-text-color-secondary)]"
>
<template v-if="item.inviterUserId">
通过
<span class="text-[var(--el-color-primary)]">
{{ item.inviterNickname || `用户 ${item.inviterUserId}` }}
</span>
的邀请进群
</template>
<template v-else></template>
</div>
</div>
<span
v-if="item.handleResult === ImGroupRequestHandleResult.AGREED"
class="flex-shrink-0 text-[13px] text-[var(--el-text-color-placeholder)]"
>
已同意
</span>
<span
v-else-if="item.handleResult === ImGroupRequestHandleResult.REFUSED"
class="flex-shrink-0 text-[13px] text-[var(--el-text-color-placeholder)]"
>
已拒绝
</span>
<div v-else class="flex gap-1.5 flex-shrink-0">
<button
class="im-group-request-list__btn im-group-request-list__btn--primary"
:disabled="actingId === item.id"
@click="handleAgree(item)"
>
确认
</button>
<button
class="im-group-request-list__btn im-group-request-list__btn--ghost"
:disabled="actingId === item.id"
@click="handleRefuse(item)"
>
拒绝
</button>
</div>
</div>
</div>
</div>
</el-dialog>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import { useMessage } from '@/hooks/web/useMessage'
import { getGroupRequestListByGroupId, type ImGroupRequestRespVO } from '@/api/im/group/request'
import { ImGroupRequestHandleResult } from '@/views/im/utils/constants'
import { useGroupRequestStore } from '../../store/groupRequestStore'
import UserAvatar from '../user/UserAvatar.vue'
defineOptions({ name: 'ImGroupRequestListDialog' })
const message = useMessage()
const groupRequestStore = useGroupRequestStore()
const visible = ref(false)
/** 当前展示的群编号undefined 时走全局未处理列表store.unhandledList */
const groupId = ref<number | undefined>()
const loading = ref(false)
const groupList = ref<ImGroupRequestRespVO[]>([])
const actingId = ref<number | null>(null)
defineExpose({
/** 打开进群申请弹窗reset → 灌参 → visible=true不传 groupId 走全局未处理列表 */
open(opts?: { groupId?: number }) {
groupId.value = opts?.groupId
actingId.value = null
visible.value = true
}
})
/** 数据源:单群模式用 fetch 回来的 groupList全局模式直接读 store.unhandledList处理后 store 自动 reactive 同步 */
const list = computed<ImGroupRequestRespVO[]>(() =>
groupId.value ? groupList.value : groupRequestStore.unhandledList
)
/** 顶部卡片:最新一条;空数组时为 null */
const latest = computed(() => list.value[0] || null)
/** 历史列表:除最新一条外的其余 */
const histories = computed(() => list.value.slice(1))
/** 打开 dialog 时拉数据:单群拉 API全局直接读 store关闭时清掉单群缓存 */
watch(
[visible, groupId],
([isVisible, currentGroupId]) => {
if (isVisible && currentGroupId) {
void fetchList(currentGroupId)
} else if (!isVisible) {
groupList.value = []
}
},
{ immediate: true }
)
/**
* 单群模式下订阅 store 中归属本群的未处理列表变化远端事件WS 1503 新申请 / 其他管理员处理触发时 refetch
* 拿最新 handleResult本端 agree / refuse 期间 actingId 锁住跳过本端动作引发的 store 变化避免冗余 RTT
*
* key 不能只 join id复用旧记录时同一 requestId applyContent / inviterUserId 会刷新但 id 不变必须把内容字段也纳入触发
*/
watch(
() =>
groupId.value && visible.value
? groupRequestStore.unhandledList
.filter((request) => request.groupId === groupId.value)
.map(
(request) =>
`${request.id}:${request.inviterUserId ?? ''}:${request.applyContent ?? ''}`
)
.join(',')
: null,
(current, previous) => {
if (current === null || previous === undefined || current === previous) {
return
}
if (actingId.value !== null) {
return
}
if (groupId.value) {
void fetchList(groupId.value)
}
}
)
let fetchSeq = 0 // WS 1503 fetch
async function fetchList(targetGroupId: number) {
const seq = ++fetchSeq
loading.value = true
try {
const data = (await getGroupRequestListByGroupId(targetGroupId)) || []
// / / fetch
if (seq !== fetchSeq || !visible.value || groupId.value !== targetGroupId) {
return
}
groupList.value = data
} finally {
// finally loading
if (seq === fetchSeq) {
loading.value = false
}
}
}
/** 同意:走 store 同步全局未处理列表 + 本地更新 handleResult 让按钮变灰 */
async function handleAgree(item: ImGroupRequestRespVO) {
if (actingId.value !== null) return
actingId.value = item.id
try {
await groupRequestStore.agreeGroupRequest(item.id)
updateLocalResult(item.id, ImGroupRequestHandleResult.AGREED)
message.success('已同意')
} finally {
actingId.value = null
}
}
/** 拒绝:弹理由输入框;为空则不带 handleContent */
async function handleRefuse(item: ImGroupRequestRespVO) {
if (actingId.value !== null) return
let handleContent = ''
try {
const result = await message.prompt('请输入拒绝理由(可选)', '拒绝申请')
handleContent = result.value || ''
} catch {
return
}
actingId.value = item.id
try {
await groupRequestStore.refuseGroupRequest(item.id, handleContent || undefined)
updateLocalResult(item.id, ImGroupRequestHandleResult.REFUSED)
message.success('已拒绝')
} finally {
actingId.value = null
}
}
/** 单群模式下处理后更新 groupList 里的 handleResult按钮转「已同意 / 已拒绝」灰态;全局模式 store 直接移除该项无需更新 */
function updateLocalResult(id: number, handleResult: number) {
const target = groupList.value.find((r) => r.id === id)
if (target) {
target.handleResult = handleResult
}
}
</script>
<style scoped>
/* 自绘按钮:贴近微信小药丸样式;与 :disabled、:hover:not(:disabled) 等伪类叠加 modifier 类的组合选择器写在 class 里成本高,留 SCSS */
.im-group-request-list__btn {
flex-shrink: 0;
min-width: 56px;
height: 28px;
padding: 0 12px;
font-size: 13px;
border-radius: 4px;
cursor: pointer;
border: 1px solid transparent;
transition:
background-color 0.15s,
border-color 0.15s,
color 0.15s;
}
.im-group-request-list__btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.im-group-request-list__btn--primary {
color: #fff;
background-color: var(--el-color-primary);
border-color: var(--el-color-primary);
}
.im-group-request-list__btn--primary:hover:not(:disabled) {
background-color: var(--el-color-primary-light-3);
border-color: var(--el-color-primary-light-3);
}
.im-group-request-list__btn--ghost {
color: var(--el-text-color-regular);
background-color: var(--el-bg-color);
border-color: var(--el-border-color);
}
.im-group-request-list__btn--ghost:hover:not(:disabled) {
color: var(--el-color-primary);
border-color: var(--el-color-primary);
}
</style>
<style>
/* el-dialog 内部 body 通过 teleport 渲染到 bodyscoped 选不到,留非 scoped 全局覆盖 */
.im-group-request-list__dialog .el-dialog__body {
padding: 12px 20px 8px;
background-color: var(--el-fill-color-light);
}
</style>

View File

@ -0,0 +1,370 @@
<template>
<!--
会话选择面板用于推荐名片 / 转发消息等"选已有会话"场景
- 搜索 + 最近转发横向头像 + 创建聊天入口 + 最近聊天列表圆形勾选
- 已选数标题 + 已选会话列表按点击顺序+ footer slot
- Panel 不带 el-dialog dialog 由业务壳持有
- footer slot 渲染在右栏已选列表下方业务壳放预览卡 / 留言 / 提交按钮
-->
<div class="flex h-full">
<!-- 左栏 -->
<div
class="flex flex-col w-[280px] border-r border-r-solid border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-light)]"
>
<!-- 搜索框 -->
<div class="flex-shrink-0 px-3 py-2">
<el-input v-model="keyword" placeholder="搜索" clearable>
<template #prefix>
<Icon icon="ant-design:search-outlined" />
</template>
</el-input>
</div>
<el-scrollbar class="flex-1">
<!-- 最近转发横向头像区keyword 为空 + 有最近转发数据时展示 -->
<template v-if="showRecentSection">
<div class="flex justify-between items-center pl-3 pr-2 pb-1.5">
<span class="text-13px text-[var(--el-text-color-secondary)]">最近转发</span>
<span
class="px-1 cursor-pointer text-13px text-[var(--el-color-primary)] hover:opacity-80"
@click="recentRemoveMode = !recentRemoveMode"
>
{{ recentRemoveMode ? '完成' : '移除' }}
</span>
</div>
<div
class="flex gap-2 pl-3 pr-2 pt-1 pb-2 overflow-x-auto im-conversation-picker__recent"
>
<div
v-for="conversation in recentForwardConversations"
:key="getConversationKey(conversation)"
class="flex flex-col flex-shrink-0 gap-1 items-center"
:class="{ 'cursor-pointer': !recentRemoveMode }"
@click="handleRecentTileClick(conversation)"
>
<div class="relative">
<GroupAvatar
v-if="conversation.type === ImConversationType.GROUP"
:group-id="conversation.targetId"
:url="conversation.avatar"
:name="conversation.name"
:size="36"
/>
<UserAvatar
v-else
:url="conversation.avatar"
:name="conversation.name"
:size="36"
:clickable="false"
/>
<!-- 移除模式右上角 × 圆角标点击把这条 key recentForwardConversationKeys 删掉 -->
<span
v-if="recentRemoveMode"
class="flex absolute -top-1 -right-1 justify-center items-center w-4 h-4 rounded-full cursor-pointer bg-[var(--el-fill-color-dark)] text-[var(--el-text-color-primary)]"
@click.stop="emit('remove-recent', getConversationKey(conversation))"
>
<Icon icon="ant-design:close-outlined" :size="10" />
</span>
<!-- 非移除模式右上角圆形勾选指示器未选灰空心圈选中绿底白对勾 -->
<span
v-else
class="flex absolute -top-1 -right-1 justify-center items-center w-4 h-4 rounded-full transition-colors"
:class="
isSelected(conversation)
? 'bg-[#07c160] border border-solid border-[#07c160]'
: 'border border-solid border-[var(--el-border-color)] bg-[var(--el-bg-color)]'
"
>
<Icon
v-if="isSelected(conversation)"
icon="ant-design:check-outlined"
:size="10"
color="#fff"
/>
</span>
</div>
<span
class="overflow-hidden max-w-[48px] text-12px truncate text-[var(--el-text-color-primary)]"
>
{{ conversation.name }}
</span>
</div>
</div>
</template>
<!-- 创建聊天入口keyword 为空 + showCreateChat=true 时展示 -->
<div
v-if="showCreateChat && !keyword.trim()"
class="flex gap-2.5 items-center px-3 py-1.5 cursor-pointer hover:bg-[var(--el-fill-color)]"
@click="emit('create-chat')"
>
<span
class="flex flex-shrink-0 justify-center items-center w-8 h-8 rounded-full bg-[var(--el-fill-color)] text-[var(--el-text-color-secondary)]"
>
<Icon icon="ant-design:plus-outlined" :size="16" />
</span>
<span class="text-sm text-[var(--el-text-color-primary)]">创建聊天</span>
</div>
<!-- 最近聊天分组标题 -->
<div class="px-3 pb-1.5 text-13px text-[var(--el-text-color-secondary)]">最近聊天</div>
<!-- 会话列表 -->
<div
v-for="conversation in shownConversations"
:key="getConversationKey(conversation)"
class="flex gap-2.5 items-center px-3 py-1.5 cursor-pointer hover:bg-[var(--el-fill-color)]"
@click="handleToggle(conversation)"
>
<!-- 圆形勾选指示器未选灰色空心圆选中实心微信绿 + 白对勾 -->
<span
class="flex flex-shrink-0 justify-center items-center w-5 h-5 rounded-full transition-colors"
:class="
isSelected(conversation)
? 'bg-[#07c160] border border-solid border-[#07c160]'
: 'border border-solid border-[var(--el-border-color)] bg-[var(--el-bg-color)]'
"
>
<Icon
v-if="isSelected(conversation)"
icon="ant-design:check-outlined"
:size="12"
color="#fff"
/>
</span>
<GroupAvatar
v-if="conversation.type === ImConversationType.GROUP"
:group-id="conversation.targetId"
:url="conversation.avatar"
:name="conversation.name"
:size="32"
/>
<UserAvatar
v-else
:url="conversation.avatar"
:name="conversation.name"
:size="32"
:clickable="false"
/>
<span
class="flex-1 min-w-0 overflow-hidden text-sm truncate text-[var(--el-text-color-primary)]"
>
{{ conversation.name }}
</span>
</div>
<!-- 空态 -->
<div
v-if="shownConversations.length === 0"
class="py-10 text-13px text-center text-[var(--el-text-color-disabled)]"
>
{{ keyword ? '没有满足条件的会话' : '暂无会话' }}
</div>
</el-scrollbar>
</div>
<!-- 右栏 -->
<div class="flex flex-col flex-1 min-w-0">
<!-- 标题 0/1发送给多个分别发送给与微信文案一致 -->
<div
class="flex-shrink-0 px-4 py-3 border-b border-b-solid text-13px text-[var(--el-text-color-secondary)] border-[var(--el-border-color-lighter)]"
>
{{ sendTitle }}
</div>
<!-- 已选预览 selectedKeys 数组顺序点击顺序展示 -->
<el-scrollbar class="flex-1">
<div
v-for="conversation in selectedConversations"
:key="getConversationKey(conversation)"
class="flex gap-2.5 items-center px-4 py-2"
>
<GroupAvatar
v-if="conversation.type === ImConversationType.GROUP"
:group-id="conversation.targetId"
:url="conversation.avatar"
:name="conversation.name"
:size="32"
/>
<UserAvatar
v-else
:url="conversation.avatar"
:name="conversation.name"
:size="32"
:clickable="false"
/>
<span
class="flex-1 min-w-0 overflow-hidden text-sm truncate text-[var(--el-text-color-primary)]"
>
{{ conversation.name }}
</span>
<Icon
icon="ant-design:close-outlined"
:size="14"
class="flex-shrink-0 cursor-pointer transition-colors text-[var(--el-text-color-placeholder)] hover:text-[var(--el-color-danger)]"
@click="handleToggle(conversation)"
/>
</div>
<div
v-if="selectedConversations.length === 0"
class="py-10 text-13px text-center text-[var(--el-text-color-disabled)]"
>
从左侧选择好友或群聊
</div>
</el-scrollbar>
<!-- 业务壳塞预览卡 / 留言 / 提交按钮的位置 -->
<div
v-if="$slots.footer"
class="flex-shrink-0 border-t border-t-solid border-[var(--el-border-color-lighter)]"
>
<slot name="footer"></slot>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { useMessage } from '@/hooks/web/useMessage'
import UserAvatar from '../user/UserAvatar.vue'
import GroupAvatar from '../group/GroupAvatar.vue'
import { filterConversationsByKeyword, getConversationKey } from '../../../utils/conversation'
import { ImConversationType } from '../../../utils/constants'
import type { Conversation } from '../../types'
defineOptions({ name: 'ImConversationPickerPanel' })
const props = withDefaults(
defineProps<{
/** 全量会话列表 */
conversations: Conversation[]
/** 已选会话 keyv-modelkey 由 getConversationKey 生成 */
selectedKeys: string[]
/** 最近转发会话 key 列表;展示在左栏顶部横向头像区 */
recentForwardConversationKeys?: string[]
/** 隐藏 key从候选 / 已选 / 最近转发里都剔除(不能转发回自己、推荐名片自身的会话等) */
hideKeys?: string[]
/** 已选数上限;不传或 <=0 时不限 */
maxSize?: number
/** 是否展示「创建聊天」入口 */
showCreateChat?: boolean
}>(),
{
recentForwardConversationKeys: () => [],
hideKeys: () => [],
maxSize: 0,
showCreateChat: false
}
)
const emit = defineEmits<{
'update:selectedKeys': [value: string[]]
'create-chat': []
/** 用户在「最近转发」段进入移除模式后点 ×;业务壳收到后调 conversationStore.removeRecentForwardConversationKey 落盘 */
'remove-recent': [key: string]
}>()
const message = useMessage()
const keyword = ref('')
/** 「最近转发」段是否处于移除模式true 时头像右上角变 × 不再切勾选 */
const recentRemoveMode = ref(false)
/** 全量会话的 key→Conversation 映射,已选 / 最近转发反查共用,避免每次 O(N) 扫 */
const byKey = computed(() => {
const map = new Map<string, Conversation>()
for (const conversation of props.conversations) {
map.set(getConversationKey(conversation), conversation)
}
return map
})
/** 隐藏集合:每次过滤复用 */
const hideSet = computed(() => new Set(props.hideKeys))
/** 已选集合:圆形指示器 isSelected 走 set 快查 */
const selectedSet = computed(() => new Set(props.selectedKeys))
/** 候选会话:剔除 hideKeys */
const candidateConversations = computed(() =>
props.conversations.filter((c) => !hideSet.value.has(getConversationKey(c)))
)
/** 左栏展示列表:在候选基础上按 keyword 过滤 */
const shownConversations = computed(() =>
filterConversationsByKeyword(candidateConversations.value, keyword.value)
)
/** 最近转发的会话对象列表:从 recentForwardConversationKeys 反查;剔除 hide / 不存在的 key */
const recentForwardConversations = computed(() =>
props.recentForwardConversationKeys
.map((key) => byKey.value.get(key))
.filter((c): c is Conversation => c != null && !hideSet.value.has(getConversationKey(c)))
)
/** 是否展示「最近转发」段keyword 为空 + 有数据时才展示,搜索时让位 */
const showRecentSection = computed(
() => !keyword.value.trim() && recentForwardConversations.value.length > 0
)
/** 已选会话列表:按 selectedKeys 数组顺序(即点击顺序)反查;过滤 hideSet 避免父组件动态隐藏的会话仍在右侧渲染 / 提交 */
const selectedConversations = computed(() =>
props.selectedKeys
.map((key) => byKey.value.get(key))
.filter(
(conversation): conversation is Conversation =>
conversation != null && !hideSet.value.has(getConversationKey(conversation))
)
)
/** 右栏标题文案:单选「发送给」、多选「分别发送给」 */
const sendTitle = computed(() => (props.selectedKeys.length > 1 ? '分别发送给' : '发送给'))
/** 是否已选中:左栏圆形指示器 / 最近转发头像角标共用 */
function isSelected(conversation: Conversation): boolean {
return selectedSet.value.has(getConversationKey(conversation))
}
/** 「最近转发」头像点击:移除模式下不切勾选(移除由 × 角标处理) */
function handleRecentTileClick(conversation: Conversation) {
if (recentRemoveMode.value) {
return
}
handleToggle(conversation)
}
/** 切换选中态:左栏 row / 最近转发头像 / 右栏 × 移除都走这里 */
function handleToggle(conversation: Conversation) {
const key = getConversationKey(conversation)
const next = [...props.selectedKeys]
const index = next.indexOf(key)
if (index >= 0) {
next.splice(index, 1)
} else {
// 便
if (hideSet.value.has(key)) {
return
}
if (props.maxSize > 0 && next.length >= props.maxSize) {
message.error(`最多选择 ${props.maxSize} 个会话`)
return
}
next.push(key)
}
emit('update:selectedKeys', next)
}
</script>
<style scoped>
/* 横向滚动条做窄一点避免占视觉;走 ::-webkit-scrollbar 浏览器伪元素 */
.im-conversation-picker__recent::-webkit-scrollbar {
height: 4px;
}
.im-conversation-picker__recent::-webkit-scrollbar-thumb {
background-color: var(--el-border-color);
border-radius: 2px;
}
</style>

View File

@ -0,0 +1,244 @@
<template>
<!--
好友选择面板用于新建群聊 / 邀请好友 / 推荐时创建聊天等好友选择场景
- 搜索 + 好友列表圆形勾选
- 已选数标题 + 已选好友列表按点击顺序
- Panel 不带 el-dialog dialog 由业务壳持有
- 三态语义hide > locked > disabled详见 contract
-->
<div class="flex h-full">
<!-- 左栏 -->
<div
class="flex flex-col flex-1 min-w-0 border-r border-r-solid border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-light)]"
>
<!-- 搜索框 -->
<div class="flex-shrink-0 px-3 py-2">
<el-input v-model="keyword" placeholder="搜索好友" clearable>
<template #prefix>
<Icon icon="ant-design:search-outlined" />
</template>
</el-input>
</div>
<div class="flex-1 min-h-0">
<PagedScroller v-if="filtered.length > 0" :items="filtered" :page-size="30" item-key="id">
<template #default="{ item }">
<div
:key="(item as FriendLite).id"
class="flex gap-2.5 items-center px-3 py-2 cursor-pointer hover:bg-[var(--el-fill-color)]"
:class="{
'opacity-60 cursor-not-allowed hover:bg-transparent': isDisabled(item as FriendLite)
}"
@click="handleToggle(item as FriendLite)"
>
<!-- 圆形勾选指示器未选灰色空心圆选中实心微信绿 + 白对勾锁定 / 禁用走灰底 -->
<span
class="flex flex-shrink-0 justify-center items-center w-5 h-5 rounded-full transition-colors"
:class="getCheckClass(item as FriendLite)"
>
<Icon
v-if="isSelected(item as FriendLite) || isLocked(item as FriendLite)"
icon="ant-design:check-outlined"
:size="12"
color="#fff"
/>
</span>
<UserAvatar
:id="(item as FriendLite).id"
:url="(item as FriendLite).avatar"
:name="(item as FriendLite).nickname"
:size="36"
:clickable="false"
/>
<!-- 行内名字备注优先列表里不重复展示昵称 -->
<span
class="flex-1 min-w-0 overflow-hidden text-sm truncate text-[var(--el-text-color-primary)]"
>
{{ (item as FriendLite).displayName || (item as FriendLite).nickname }}
</span>
</div>
</template>
</PagedScroller>
<div v-else class="py-10 text-13px text-center text-[var(--el-text-color-disabled)]">
{{ keyword ? '没有匹配的好友' : '暂无好友' }}
</div>
</div>
</div>
<!-- 右栏 -->
<div class="flex flex-col flex-1 min-w-0">
<!-- 标题已选数高度对齐左侧 input default32px保证两侧第一项起点同水平 -->
<div
class="flex-shrink-0 h-12 px-4 leading-[3rem] border-b border-b-solid text-13px text-[var(--el-text-color-secondary)] border-[var(--el-border-color-lighter)]"
>
已选择 {{ selectedCount }} 个好友
</div>
<!-- 已选预览 selectedIds + lockedIds 数组顺序点击顺序展示 -->
<el-scrollbar class="flex-1">
<div
v-for="friend in selectedFriends"
:key="friend.id"
class="flex gap-2.5 items-center px-4 py-2"
>
<UserAvatar
:id="friend.id"
:url="friend.avatar"
:name="friend.nickname"
:size="36"
:clickable="false"
/>
<span
class="flex-1 min-w-0 overflow-hidden text-sm truncate text-[var(--el-text-color-primary)]"
>
{{ friend.displayName || friend.nickname }}
</span>
<!-- 锁定项不渲染 ×避免误以为可移除 -->
<Icon
v-if="!isLocked(friend)"
icon="ant-design:close-outlined"
:size="14"
class="flex-shrink-0 cursor-pointer transition-colors text-[var(--el-text-color-placeholder)] hover:text-[var(--el-color-danger)]"
@click="handleToggle(friend)"
/>
</div>
<div
v-if="selectedFriends.length === 0"
class="py-10 text-13px text-center text-[var(--el-text-color-disabled)]"
>
请从左侧选择好友
</div>
</el-scrollbar>
<!-- 业务壳塞额外内容的位置FriendPickerPanel 主流场景不需要 footer -->
<div
v-if="$slots.footer"
class="flex-shrink-0 border-t border-t-solid border-[var(--el-border-color-lighter)]"
>
<slot name="footer"></slot>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { useMessage } from '@/hooks/web/useMessage'
import UserAvatar from '../user/UserAvatar.vue'
import PagedScroller from '../PagedScroller.vue'
import { useFriendBuckets } from '../../composables/useFriendBuckets'
import { useSelectedItems } from '../../composables/useSelectedItems'
import type { FriendLite } from '../../types'
defineOptions({ name: 'ImFriendPickerPanel' })
const props = withDefaults(
defineProps<{
/** 全量好友列表 */
friends: FriendLite[]
/** 已选好友 idv-model按数组顺序即为点击顺序 */
selectedIds: number[]
/** 锁定 id默认勾选、不可取消、计入已选数典型私聊侧 +建群锁定对方) */
lockedIds?: number[]
/** 禁用 id列表里展示置灰、不可勾选、不计入已选数典型邀请入群时已在群成员 */
disabledIds?: number[]
/** 隐藏 id不展示hide > locked > disabled */
hideIds?: number[]
/** 已选数上限;不传或 <=0 时不限 */
maxSize?: number
}>(),
{
lockedIds: () => [],
disabledIds: () => [],
hideIds: () => [],
maxSize: 0
}
)
const emit = defineEmits<{
'update:selectedIds': [value: number[]]
}>()
const message = useMessage()
const keyword = ref('')
/** id → friend 映射,已选反查 / 三态判定共用,避免每次 O(N) 扫 */
const byId = computed(() => {
const map = new Map<number, FriendLite>()
for (const friend of props.friends) {
map.set(friend.id, friend)
}
return map
})
/** 三态 id 集合:每次过滤复用 */
const hideSet = computed(() => new Set(props.hideIds))
const lockedSet = computed(() => new Set(props.lockedIds))
const disabledSet = computed(() => new Set(props.disabledIds))
const selectedSet = computed(() => new Set(props.selectedIds))
/** 候选好友:剔除 hideIdshide 优先级最高) */
const candidates = computed(() =>
props.friends.filter((friend) => !hideSet.value.has(friend.id))
)
/** 委托 useFriendBuckets搜索规则复用左侧列表按滚动分页渲染 */
const { filtered } = useFriendBuckets(candidates, keyword)
/** 已选数 + 已选好友列表:三态优先级 + 顺序拼接由 useSelectedItems 统一承担 */
const { selectedCount, selectedItems: selectedFriends } = useSelectedItems<FriendLite>(
() => props.selectedIds,
() => props.lockedIds,
() => props.disabledIds,
() => props.hideIds,
byId
)
/** 是否被锁定 */
function isLocked(friend: FriendLite): boolean {
return lockedSet.value.has(friend.id)
}
/** 是否被禁用locked / hide 已被前置过滤,剩下的才算 disabled */
function isDisabled(friend: FriendLite): boolean {
return !lockedSet.value.has(friend.id) && disabledSet.value.has(friend.id)
}
/** 是否选中locked 视为永远选中 */
function isSelected(friend: FriendLite): boolean {
return selectedSet.value.has(friend.id)
}
/** 圆形勾选指示器的 class选中 / 锁定走绿底,禁用灰底,未选空心圆 */
function getCheckClass(friend: FriendLite): string {
if (isLocked(friend) || isSelected(friend)) {
return 'bg-[#07c160] border border-solid border-[#07c160]'
}
if (isDisabled(friend)) {
return 'bg-[var(--el-fill-color)] border border-solid border-[var(--el-border-color)]'
}
return 'border border-solid border-[var(--el-border-color)] bg-[var(--el-bg-color)]'
}
/** 切换选中态locked / disabled 不响应;右栏 × 移除 / 行 click 都走这里 */
function handleToggle(friend: FriendLite) {
if (isLocked(friend) || isDisabled(friend)) {
return
}
const next = [...props.selectedIds]
const index = next.indexOf(friend.id)
if (index >= 0) {
next.splice(index, 1)
} else {
if (props.maxSize > 0 && selectedCount.value >= props.maxSize) {
message.error(`最多选择 ${props.maxSize} 位好友`)
return
}
next.push(friend.id)
}
emit('update:selectedIds', next)
}
</script>

View File

@ -0,0 +1,243 @@
<template>
<!--
群成员选择面板用于移除成员 / 设置管理员 / 转让群主 / 禁言成员等场景
- 搜索 + 群成员列表圆形勾选
- 已选数标题 + 已选成员list / grid 两种形态可配默认 list 对齐微信新视觉
- Panel 不带 el-dialog dialog 由业务壳持有
- 三态语义hide > locked > disabled
-->
<div class="flex h-full">
<!-- 左栏 -->
<div
class="flex flex-col flex-1 min-w-0 border-r border-r-solid border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-light)]"
>
<!-- 搜索框 -->
<div class="flex-shrink-0 px-3 py-2">
<el-input v-model="keyword" placeholder="搜索成员" clearable>
<template #prefix>
<Icon icon="ant-design:search-outlined" />
</template>
</el-input>
</div>
<div class="flex-1 min-h-0">
<PagedScroller :items="shownMembers" :page-size="30" item-key="userId">
<template #default="{ item }">
<GroupMemberItem
:member="(item as GroupMemberLite)"
:height="46"
@click="handleToggle(item as GroupMemberLite)"
>
<span
class="flex flex-shrink-0 justify-center items-center w-5 h-5 rounded-full transition-colors"
:class="getCheckClass(item as GroupMemberLite)"
>
<Icon
v-if="isSelected(item as GroupMemberLite) || isLocked(item as GroupMemberLite)"
icon="ant-design:check-outlined"
:size="12"
color="#fff"
/>
</span>
</GroupMemberItem>
</template>
</PagedScroller>
</div>
</div>
<!-- 右栏 -->
<div class="flex flex-col flex-1 min-w-0">
<!-- 标题已选数高度对齐左侧 input default32px -->
<div
class="flex-shrink-0 h-12 px-4 leading-[3rem] border-b border-b-solid text-13px text-[var(--el-text-color-secondary)] border-[var(--el-border-color-lighter)]"
>
已选择 {{ selectedCount }} 位成员
</div>
<el-scrollbar class="flex-1">
<!-- list 形态纵向行每行带 × 移除locked 不渲染 × -->
<template v-if="selectedDisplay === 'list'">
<div
v-for="member in selectedMembers"
:key="member.userId"
class="flex gap-2.5 items-center px-4 py-2"
>
<UserAvatar
:id="member.userId"
:url="member.avatar"
:name="member.nickname"
:size="36"
:clickable="false"
/>
<span
class="flex-1 min-w-0 overflow-hidden text-sm truncate text-[var(--el-text-color-primary)]"
>
{{ member.showName }}
</span>
<Icon
v-if="!isLocked(member)"
icon="ant-design:close-outlined"
:size="14"
class="flex-shrink-0 cursor-pointer transition-colors text-[var(--el-text-color-placeholder)] hover:text-[var(--el-color-danger)]"
@click="handleToggle(member)"
/>
</div>
</template>
<!-- grid 形态宫格预览 locked 成员右上角叠加 × 移除locked 不渲染 -->
<div v-else class="flex flex-wrap p-2.5">
<GroupMemberGrid
v-for="member in selectedMembers"
:key="member.userId"
:member="member"
>
<Icon
v-if="!isLocked(member)"
icon="ant-design:close-circle-filled"
:size="16"
class="absolute top-0 right-0 cursor-pointer transition-colors text-[var(--el-text-color-placeholder)] hover:text-[var(--el-color-danger)]"
@click="handleToggle(member)"
/>
</GroupMemberGrid>
</div>
<div
v-if="selectedMembers.length === 0"
class="py-10 text-13px text-center text-[var(--el-text-color-disabled)]"
>
请从左侧选择成员
</div>
</el-scrollbar>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { useMessage } from '@/hooks/web/useMessage'
import { CommonStatusEnum } from '@/utils/constants'
import GroupMemberItem from '../group/GroupMemberItem.vue'
import GroupMemberGrid from '../group/GroupMemberGrid.vue'
import UserAvatar from '../user/UserAvatar.vue'
import PagedScroller from '../PagedScroller.vue'
import { useSelectedItems } from '../../composables/useSelectedItems'
import type { GroupMemberLite } from '../group/GroupMember.vue'
defineOptions({ name: 'ImGroupMemberPickerPanel' })
const props = withDefaults(
defineProps<{
/** 群成员列表 */
members: GroupMemberLite[]
/** 已选 userIdv-model按数组顺序即为点击顺序 */
selectedIds: number[]
/** 锁定 userId默认勾选、不可取消、计入已选数 */
lockedIds?: number[]
/** 禁用 userId列表里展示置灰、不可勾选、不计入已选数 */
disabledIds?: number[]
/** 隐藏 userId不展示hide > locked > disabled */
hideIds?: number[]
/** 已选数上限;不传或 <=0 时不限 */
maxSize?: number
/** 已选区展示形态:默认 list 对齐微信新视觉 */
selectedDisplay?: 'list' | 'grid'
}>(),
{
lockedIds: () => [],
disabledIds: () => [],
hideIds: () => [],
maxSize: 0,
selectedDisplay: 'list'
}
)
const emit = defineEmits<{
'update:selectedIds': [value: number[]]
}>()
const message = useMessage()
const keyword = ref('')
/** userId → member 映射,已选反查 / 三态判定共用 */
const byId = computed(() => {
const map = new Map<number, GroupMemberLite>()
for (const member of props.members) {
map.set(member.userId, member)
}
return map
})
const hideSet = computed(() => new Set(props.hideIds))
const lockedSet = computed(() => new Set(props.lockedIds))
const disabledSet = computed(() => new Set(props.disabledIds))
const selectedSet = computed(() => new Set(props.selectedIds))
/** 当前展示的成员:剔除 hideIds、剔除已退群DISABLE、按关键字大小写无关过滤 */
const shownMembers = computed(() => {
const keywordLower = keyword.value.trim().toLowerCase()
return props.members.filter(
(member) =>
!hideSet.value.has(member.userId) &&
member.status !== CommonStatusEnum.DISABLE &&
(!keywordLower || member.showName.toLowerCase().includes(keywordLower))
)
})
/** 已选数 + 已选成员列表:三态优先级 + 顺序拼接由 useSelectedItems 统一承担 */
const { selectedCount, selectedItems: selectedMembers } = useSelectedItems<GroupMemberLite>(
() => props.selectedIds,
() => props.lockedIds,
() => props.disabledIds,
() => props.hideIds,
byId
)
/** 是否被锁定 */
function isLocked(member: GroupMemberLite): boolean {
return lockedSet.value.has(member.userId)
}
/** 是否被禁用locked / hide 已被前置过滤,剩下的才算 disabled */
function isDisabled(member: GroupMemberLite): boolean {
return !lockedSet.value.has(member.userId) && disabledSet.value.has(member.userId)
}
/** 是否选中locked 视为永远选中 */
function isSelected(member: GroupMemberLite): boolean {
return selectedSet.value.has(member.userId)
}
/** 圆形勾选指示器的 class */
function getCheckClass(member: GroupMemberLite): string {
if (isLocked(member) || isSelected(member)) {
return 'bg-[#07c160] border border-solid border-[#07c160]'
}
if (isDisabled(member)) {
return 'bg-[var(--el-fill-color)] border border-solid border-[var(--el-border-color)]'
}
return 'border border-solid border-[var(--el-border-color)] bg-[var(--el-bg-color)]'
}
/** 切换选中态locked / disabled 不响应;右栏 × / 行 click 都走这里 */
function handleToggle(member: GroupMemberLite) {
if (isLocked(member) || isDisabled(member)) {
return
}
const next = [...props.selectedIds]
const index = next.indexOf(member.userId)
if (index >= 0) {
next.splice(index, 1)
} else {
if (props.maxSize > 0 && selectedCount.value >= props.maxSize) {
message.error(`最多选择 ${props.maxSize} 位成员`)
return
}
next.push(member.userId)
}
emit('update:selectedIds', next)
}
</script>

View File

@ -0,0 +1,13 @@
// IM mixin
// <style scoped lang="scss"> @use + @include
@mixin styles {
:deep(.el-dialog__body) {
padding: 0;
border-top: 1px solid var(--el-border-color-lighter);
}
:deep(.el-dialog__header) {
margin-right: 0;
padding-bottom: 16px;
}
}

View File

@ -0,0 +1,488 @@
<template>
<!-- 通话阶段对应弹窗INVITING / INCOMING / RUNNING 三选一互斥 -->
<template v-if="rtcStore.isActive">
<!-- 主叫端等待对方接听 -->
<RtcCallInviting
v-if="rtcStore.stage === ImRtcCallStage.INVITING && rtcStore.call"
:peer-nickname="rtcStore.peerNickname"
:peer-avatar="rtcStore.peerAvatar"
:is-group="isGroup"
:is-video="isVideo"
:mic-enabled="lk.micEnabled.value"
:camera-enabled="lk.cameraEnabled.value"
:speaker-enabled="lk.speakerEnabled.value"
:local-stream="localStream"
@cancel="handleCancel"
@toggle-mic="toggleMic"
@toggle-camera="toggleCamera"
@toggle-speaker="toggleSpeaker"
/>
<!-- 被叫端来电响铃 -->
<RtcCallIncoming
v-else-if="rtcStore.stage === ImRtcCallStage.INCOMING"
:payload="rtcStore.incomingPayload"
:is-group="isGroup"
:accepting="accepting"
:rejecting="rejecting"
@accept="handleAccept"
@reject="handleReject"
/>
<!-- 通话进行中1v1 视频 / 语音 + 群通话宫格 -->
<RtcCallRunning
v-else-if="rtcStore.stage === ImRtcCallStage.RUNNING && rtcStore.call"
:is-group="isGroup"
:is-video="isVideo"
:mic-enabled="lk.micEnabled.value"
:camera-enabled="lk.cameraEnabled.value"
:speaker-enabled="lk.speakerEnabled.value"
:screen-share-enabled="lk.screenShareEnabled.value"
:reconnecting="lk.reconnecting.value"
:started-at="rtcStore.startedAt"
:participants="participants"
:peer-nickname="rtcStore.peerNickname"
:peer-avatar="rtcStore.peerAvatar"
:local-stream="localStream"
:remote-video-stream="remoteVideoStream"
:remote-audio-stream="remoteAudioStream"
:hanging-up="hangingUp"
@hangup="handleHangup"
@toggle-mic="toggleMic"
@toggle-camera="toggleCamera"
@toggle-speaker="toggleSpeaker"
@toggle-screen-share="handleScreenShare"
@add-member="openAddMember"
/>
</template>
<!-- 通话中添加成员选人弹窗挂在 isActive 避免 stage 切换瞬间弹窗被卸载 -->
<RtcCallMemberPickerDialog ref="memberPickerRef" @success="handleAddMemberSuccess" />
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import { useIntervalFn } from '@vueuse/core'
import { useMessage } from '@/hooks/web/useMessage'
import { useRtcStore } from '../../store/rtcStore'
import { useLiveKitRoom } from '../../composables/useLiveKitRoom'
import {
cancelCall,
rejectCall,
acceptCall,
leaveCall,
inviteCall,
noAnswerCallCheck
} from '@/api/im/rtc'
import {
ImRtcCallMediaType,
ImRtcCallStage,
ImConversationType
} from '@/views/im/utils/constants'
import { RTC_NO_ANSWER_CALL_CHECK_INTERVAL_MS } from '@/views/im/utils/config'
import { getCurrentUserId } from '@/utils/auth'
import { getSenderAvatar, getSenderDisplayName } from '@/views/im/utils/user'
import { Track } from 'livekit-client'
import RtcCallInviting from './RtcCallInviting.vue'
import RtcCallIncoming from './RtcCallIncoming.vue'
import RtcCallRunning from './RtcCallRunning.vue'
import RtcCallMemberPickerDialog from './RtcCallMemberPickerDialog.vue'
import type { CallParticipantVM } from './RtcCallParticipantTile.vue'
defineOptions({ name: 'ImRtcCallContainer' })
const rtcStore = useRtcStore()
const message = useMessage()
const lk = useLiveKitRoom()
const memberPickerRef = ref<InstanceType<typeof RtcCallMemberPickerDialog>>()
const connecting = ref(false)
const accepting = ref(false)
const rejecting = ref(false)
const cancelling = ref(false)
const hangingUp = ref(false)
// ==================== ====================
/** 当前是否视频通话 */
const isVideo = computed(() => {
const t =
rtcStore.call?.mediaType ||
rtcStore.incomingPayload?.mediaType ||
ImRtcCallMediaType.VOICE
return t === ImRtcCallMediaType.VIDEO
})
/** 当前是否群通话;决定浮动窗大小 */
const isGroup = computed(
() =>
(rtcStore.call?.conversationType ??
rtcStore.incomingPayload?.conversationType) === ImConversationType.GROUP
)
/** 初始摄像头是否打开;群通话默认全部关闭,进入后用户主动开 */
const initialCamera = computed(() => {
if (rtcStore.call?.conversationType === ImConversationType.GROUP) {
return false
}
return isVideo.value
})
/** 本端视频流;优先 ScreenShare屏共时也铺底无则 Camera显式订阅 screenShareEnabled / cameraEnabled 触发重算 */
const localStream = computed<MediaStream | null>(() => {
// / computed pickStream Map
void lk.screenShareEnabled.value
void lk.cameraEnabled.value
const lp = lk.localParticipant.value
if (!lp) {
return null
}
return lk.pickStream(lp, Track.Source.ScreenShare) || lk.pickStream(lp, Track.Source.Camera)
})
/** 远端视频流(仅 1v1 用);优先 ScreenShare无则取 Camera */
const remoteVideoStream = computed<MediaStream | null>(() => {
if (isGroup.value) {
return null
}
for (const rp of lk.remoteParticipants.value) {
const screen = lk.pickStream(rp, Track.Source.ScreenShare)
if (screen) {
return screen
}
const camera = lk.pickStream(rp, Track.Source.Camera)
if (camera) {
return camera
}
}
return null
})
/** 远端音频流(仅 1v1 用) */
const remoteAudioStream = computed<MediaStream | null>(() => {
if (isGroup.value) {
return null
}
for (const rp of lk.remoteParticipants.value) {
const stream = lk.pickStream(rp, Track.Source.Microphone)
if (stream) {
return stream
}
}
return null
})
/** 群通话网格用:自己 + 远端在房 + 待加入成员;昵称 / 头像走 user.ts helper 自动处理 self / 群成员 / 好友 / 兜底 */
const participants = computed<CallParticipantVM[]>(() => {
const call = rtcStore.call
if (!call) {
return []
}
const conversationType = call.conversationType
const targetId = call.groupId ?? 0
const myId = getCurrentUserId()
const result: CallParticipantVM[] = []
//
result.push({
userId: myId,
nickname: getSenderDisplayName(myId, conversationType, targetId),
avatar: getSenderAvatar(myId, conversationType, targetId) || undefined,
isLocal: true,
videoStream: localStream.value
})
// Camera
const joined = new Set<number>()
for (const rp of lk.remoteParticipants.value) {
const userId = Number(rp.identity)
if (Number.isNaN(userId)) {
continue
}
joined.add(userId)
result.push({
userId,
nickname: getSenderDisplayName(userId, conversationType, targetId),
avatar: getSenderAvatar(userId, conversationType, targetId) || undefined,
isLocal: false,
videoStream:
lk.pickStream(rp, Track.Source.ScreenShare) || lk.pickStream(rp, Track.Source.Camera),
audioStream: lk.pickStream(rp, Track.Source.Microphone)
})
}
// pending 退 /
if (conversationType === ImConversationType.GROUP) {
const inviteeIds = call.inviteeIds || []
for (const userId of inviteeIds) {
if (userId === myId || joined.has(userId) || rtcStore.isUserLeft(userId)) {
continue
}
result.push({
userId,
nickname: getSenderDisplayName(userId, ImConversationType.GROUP, targetId),
avatar: getSenderAvatar(userId, ImConversationType.GROUP, targetId) || undefined,
isLocal: false,
pending: true
})
}
}
return result
})
// ==================== LiveKit ====================
/** 连入 LiveKit 房间并注册离开回调INVITING 主叫预连和被叫 accept 后连入共用 */
async function connectLiveKit(livekitUrl: string, token: string) {
// lk.connect room.value stage
if (lk.room.value || connecting.value) {
return
}
connecting.value = true
try {
// connect handler
lk.onDisconnected(() => handlePeerDisconnected())
lk.onParticipantConnected(maybeEnterRunning)
lk.onParticipantDisconnected((userId) => rtcStore.markUserLeft(userId))
await lk.connect(livekitUrl, token, { audio: true, video: initialCamera.value })
// connect handler RUNNING
if (lk.remoteParticipants.value.length > 0) {
maybeEnterRunning()
}
} finally {
connecting.value = false
}
}
/** 主叫端:从 INVITING 切到 RUNNING其它阶段不处理 */
function maybeEnterRunning() {
if (rtcStore.stage === ImRtcCallStage.INVITING && rtcStore.call) {
rtcStore.enterRunning(rtcStore.call)
}
}
watch(
() => rtcStore.stage,
async (stage) => {
if (
stage === ImRtcCallStage.INVITING &&
rtcStore.call?.token &&
rtcStore.call?.livekitUrl
) {
try {
await connectLiveKit(rtcStore.call.livekitUrl, rtcStore.call.token)
} catch (e) {
console.error('[Call] connect 失败', { room: rtcStore.call?.room }, e)
message.error('通话连接失败')
await handleCancel()
}
}
if (stage === ImRtcCallStage.IDLE) {
await lk.disconnect()
}
}
)
/** 被叫端 accept 后会拿到 token这里监听 stage + token 变化触发连接 */
watch(
() => [rtcStore.stage, rtcStore.call?.token],
async ([stage, token], [prevStage]) => {
if (
stage === ImRtcCallStage.RUNNING &&
prevStage !== ImRtcCallStage.RUNNING &&
token &&
!lk.isConnected.value &&
rtcStore.call?.livekitUrl
) {
try {
await connectLiveKit(rtcStore.call.livekitUrl, token as string)
} catch (e) {
console.error('[Call] accept connect 失败', { room: rtcStore.call?.room }, e)
message.error('通话连接失败')
// accept JOINED leave 线
if (rtcStore.call?.room) {
leaveCall(rtcStore.call.room).catch(() => undefined)
}
rtcStore.reset()
}
}
}
)
// ==================== ====================
/** 主叫取消邀请 */
async function handleCancel() {
if (cancelling.value) {
return
}
cancelling.value = true
const room = rtcStore.call?.room
try {
if (room) {
await cancelCall(room)
}
await lk.disconnect()
rtcStore.reset()
} finally {
cancelling.value = false
}
}
/** 被叫拒绝来电 */
async function handleReject() {
if (rejecting.value) {
return
}
rejecting.value = true
const payload = rtcStore.incomingPayload
try {
if (payload?.room) {
await rejectCall(payload.room)
// RTC_CALL(REJECTED) store no-op
rtcStore.applyParticipantRejected({
room: payload.room,
conversationType: payload.conversationType,
groupId: payload.groupId,
operatorUserId: getCurrentUserId()
})
}
rtcStore.reset()
} finally {
rejecting.value = false
}
}
/** 被叫接听来电 */
async function handleAccept() {
if (accepting.value) {
return
}
const payload = rtcStore.incomingPayload
if (!payload) return
accepting.value = true
try {
const data = await acceptCall(payload.room)
rtcStore.enterRunning(data)
} finally {
accepting.value = false
}
}
/** 通话中挂断 */
async function handleHangup() {
if (hangingUp.value) {
return
}
hangingUp.value = true
const call = rtcStore.call
try {
if (call?.room) {
await leaveCall(call.room)
// RTC_PARTICIPANT_DISCONNECTED store no-op END
rtcStore.applyParticipantDisconnected({
room: call.room,
userId: getCurrentUserId(),
conversationType: call.conversationType,
groupId: call.groupId
})
}
await lk.disconnect()
rtcStore.reset()
} finally {
hangingUp.value = false
}
}
/** LiveKit Room 异常断开;多见于网络中断 */
function handlePeerDisconnected() {
if (!rtcStore.isActive) {
return
}
const room = rtcStore.call?.room
// RTC_CALL_END WebSocket / endSession RTC_CALL_END
// "" / "" reset toast
setTimeout(() => {
if (!rtcStore.isActive) {
return
}
//
if (room) {
leaveCall(room).catch(() => undefined)
}
//
message.warning('通话已断开')
rtcStore.reset()
}, 100)
}
// ==================== ====================
/** 通话存活期间INVITING / INCOMING / RUNNING周期性触发后端扫该 room 的超时 INVITING保持 timer 是为了 inviteCall 追加新人后也能覆盖;阈值由后端配置决定,前端只负责 trigger */
const { resume: resumeNoAnswerTimer, pause: pauseNoAnswerTimer } = useIntervalFn(
triggerNoAnswerCallCheck, RTC_NO_ANSWER_CALL_CHECK_INTERVAL_MS, { immediate: false }
)
watch(
() => rtcStore.isActive,
(active) => (active ? resumeNoAnswerTimer() : pauseNoAnswerTimer()),
{ immediate: true }
)
/** 本地仍有 pending 才调INVITING / RUNNING 取 call、INCOMING 取 incomingPayload接口静默错误 fire-and-forget */
function triggerNoAnswerCallCheck() {
const source = rtcStore.call ?? rtcStore.incomingPayload
if (!source?.room || !source.inviteeIds?.length) {
return
}
noAnswerCallCheck(source.room).catch(() => undefined)
}
// ==================== ====================
async function toggleMic() {
await lk.setMicEnabled(!lk.micEnabled.value)
}
async function toggleCamera() {
await lk.setCameraEnabled(!lk.cameraEnabled.value)
}
function toggleSpeaker() {
lk.setSpeakerEnabled(!lk.speakerEnabled.value)
}
/** 切屏幕共享浏览器弹原生「选择共享内容」对话框用户取消时会抛错UI 不弹提示 */
async function handleScreenShare() {
const enabled = !lk.screenShareEnabled.value
try {
await lk.setScreenShareEnabled(enabled)
} catch (e: any) {
//
if (e?.name !== 'NotAllowedError' && e?.message !== 'permission denied') {
console.warn('[Call] screenShare 切换失败', { enabled }, e)
}
}
}
// ==================== ====================
/** 打开「添加成员」弹窗;占位群通话 + 接通中状态才允许 */
function openAddMember() {
const call = rtcStore.call
if (!call?.groupId) {
return
}
memberPickerRef.value?.open({
groupId: call.groupId,
mode: 'add',
excludeUserIds: participants.value.map((p) => p.userId)
})
}
/** picker 选完成员;走 invite 追加邀请接口,后端推 RTC_INVITE 给新成员 */
async function handleAddMemberSuccess(userIds: number[]) {
const call = rtcStore.call
if (!call?.room || userIds.length === 0) {
return
}
await inviteCall({ room: call.room, inviteeIds: userIds })
// inviteeIds pending
rtcStore.appendInvitees(userIds)
message.success('已发送邀请')
}
</script>

View File

@ -0,0 +1,108 @@
<template>
<!--
被叫端来电小条参考微信右上角固定悬浮
群聊左头像 + 邀请人 + 文案一行 / 通话成员行+ 右下角拒绝 / 接听
私聊左头像 + 邀请人 + 文案两行+ 右下角拒绝 / 接听
-->
<div
class="fixed top-5 right-5 rounded-2xl overflow-hidden shadow-[0_12px_36px_rgba(0,0,0,0.4)] z-[9999] flex gap-3 items-end p-3 text-white bg-[#2a2a2c]"
:class="isGroup ? 'w-[360px]' : 'w-[340px]'"
>
<!-- 邀请者头像 -->
<UserAvatar
:url="payload?.inviterAvatar"
:name="payload?.inviterNickname"
:size="48"
radius="8px"
:clickable="false"
class="self-start"
/>
<!-- 群聊单行邀请人 + 文案+ 通话成员私聊两行 / 文案 -->
<div class="flex flex-col flex-1 gap-1 self-start min-w-0">
<!-- + 文案群单行内联私聊上下两行 -->
<div v-if="isGroup" class="text-sm truncate">
<span class="font-medium">{{ payload?.inviterNickname || '对方' }}</span>
<span class="ml-1 text-white/60">{{ tipText }}</span>
</div>
<template v-else>
<div class="text-sm font-medium truncate">{{ payload?.inviterNickname || '对方' }}</div>
<div class="text-13px text-white/60 truncate">{{ tipText }}</div>
</template>
<!-- 群通话成员行私聊无接入中的人半透明展示 -->
<template v-if="isGroup && callMembers.length > 0">
<div class="mt-1 text-xs text-white/45">通话成员</div>
<div class="flex flex-wrap gap-1">
<UserAvatar
v-for="member in callMembers"
:key="member.userId"
:url="member.avatar"
:name="member.nickname"
:size="22"
radius="4px"
:clickable="false"
:class="{ 'opacity-50': member.pending }"
:title="member.pending ? `${member.nickname}(接入中)` : member.nickname"
/>
</div>
</template>
</div>
<!-- 右下角拒绝 / 接听 -->
<div class="flex flex-shrink-0 gap-2 items-center">
<button
class="flex flex-shrink-0 justify-center items-center w-10 h-10 text-white rounded-full transition-opacity bg-[#f04a4a] hover:opacity-90"
:class="{ 'opacity-60 cursor-not-allowed': rejectDisabled }"
:disabled="rejectDisabled"
@click="$emit('reject')"
>
<Icon icon="ant-design:phone-outlined" :size="18" class="rotate-[135deg]" />
</button>
<button
class="flex flex-shrink-0 justify-center items-center w-10 h-10 text-white rounded-full transition-opacity bg-[#2ec27e] hover:opacity-90"
:class="{ 'opacity-60 cursor-not-allowed': acceptDisabled }"
:disabled="acceptDisabled"
@click="$emit('accept')"
>
<Icon icon="ant-design:phone-outlined" :size="18" />
</button>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import UserAvatar from '../user/UserAvatar.vue'
import type { ImRtcCallNotification } from '../../store/rtcStore'
import { useGroupCallMembers } from '../../composables/useGroupCallMembers'
import { DICT_TYPE, getDictLabel } from '@/utils/dict'
const props = defineProps<{
payload: ImRtcCallNotification | null
isGroup?: boolean
accepting?: boolean
rejecting?: boolean
}>()
defineEmits<{ accept: []; reject: [] }>()
/** 来电提示文案;区分语音 / 视频 */
const tipText = computed(() => {
if (!props.payload) return ''
return `邀请你${getDictLabel(DICT_TYPE.IM_RTC_CALL_MEDIA_TYPE, props.payload.mediaType)}通话`
})
/** 接听按钮禁用态 */
const acceptDisabled = computed(() => !!props.accepting || !!props.rejecting)
/** 拒绝按钮禁用态 */
const rejectDisabled = computed(() => !!props.rejecting || !!props.accepting)
/** 群通话成员;缓存为空时用 INVITE 载荷里的主叫兜底,避免空白 */
const callMembers = useGroupCallMembers(
computed(() => (props.isGroup ? props.payload?.groupId : undefined)),
computed(() => props.payload?.inviterUserId)
)
</script>

View File

@ -0,0 +1,128 @@
<template>
<!-- 主叫等待对方接听的悬浮窗1v1 私聊 320×540群通话切大窗 720×560 -->
<div
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-xl overflow-hidden shadow-[0_12px_36px_rgba(0,0,0,0.35)] z-[1000] flex flex-col text-white bg-gradient-to-b from-[#2a2a2c] to-[#1a1a1c]"
:class="isGroup ? 'w-[720px] h-[560px]' : 'w-[320px] h-[540px]'"
>
<div class="flex relative flex-1 justify-center items-center">
<!-- 视频呼叫自己摄像头预览铺底对方头像悬浮顶部 -->
<video
v-if="isVideo && localStream"
ref="localVideoRef"
class="absolute inset-0 object-cover w-full h-full scale-x-[-1]"
autoplay
muted
playsinline
></video>
<div
class="flex relative z-[1] flex-col gap-4 items-center"
:class="{ 'self-start mt-16': isVideo }"
>
<UserAvatar
:url="peerAvatar"
:name="peerNickname"
:size="96"
radius="8px"
:clickable="false"
/>
<div class="text-[17px] font-medium">{{ peerNickname || '对方' }}</div>
<div class="text-13px text-white/60">等待对方接受邀请</div>
</div>
</div>
<!-- 底部操作区麦克风 / 取消 / (摄像头 | 扬声器) -->
<div class="flex flex-shrink-0 gap-4 justify-around items-center pt-4 px-5 pb-5">
<div
class="flex flex-col gap-2 items-center cursor-pointer select-none"
@click="$emit('toggle-mic')"
>
<!-- ant-design 系列里 mic audio-muted-outlined 变体speaker / camera 没有 muted 变体off 态借 tabler:*-off 表达斜线 -->
<span
class="flex justify-center items-center w-12 h-12 rounded-full"
:class="micEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'"
>
<Icon
:icon="micEnabled ? 'ant-design:audio-outlined' : 'ant-design:audio-muted-outlined'"
:size="22"
/>
</span>
<span class="text-xs text-white/70 whitespace-nowrap">
{{ micEnabled ? '麦克风已开' : '麦克风已关' }}
</span>
</div>
<div
class="flex flex-col gap-2 items-center cursor-pointer select-none"
@click="$emit('cancel')"
>
<span
class="flex justify-center items-center w-12 h-12 text-white rounded-full bg-[#f04a4a]"
>
<Icon icon="ant-design:phone-outlined" :size="22" class="rotate-[135deg]" />
</span>
<span class="text-xs text-white/70 whitespace-nowrap">取消</span>
</div>
<div
v-if="isVideo"
class="flex flex-col gap-2 items-center cursor-pointer select-none"
@click="$emit('toggle-camera')"
>
<span
class="flex justify-center items-center w-12 h-12 rounded-full"
:class="cameraEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'"
>
<Icon
:icon="cameraEnabled ? 'ant-design:video-camera-outlined' : 'tabler:video-off'"
:size="22"
/>
</span>
<span class="text-xs text-white/70 whitespace-nowrap">
{{ cameraEnabled ? '摄像头已开' : '摄像头已关' }}
</span>
</div>
<div
v-else
class="flex flex-col gap-2 items-center cursor-pointer select-none"
@click="$emit('toggle-speaker')"
>
<span
class="flex justify-center items-center w-12 h-12 rounded-full"
:class="speakerEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'"
>
<Icon
:icon="speakerEnabled ? 'ant-design:sound-outlined' : 'tabler:volume-off'"
:size="22"
/>
</span>
<span class="text-xs text-white/70 whitespace-nowrap">
{{ speakerEnabled ? '扬声器已开' : '扬声器已关' }}
</span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import Icon from '@/components/Icon/src/Icon.vue'
import UserAvatar from '../user/UserAvatar.vue'
import { useMediaStreamElement } from '../../composables/useMediaStreamElement'
const props = defineProps<{
peerNickname?: string
peerAvatar?: string
isGroup?: boolean
isVideo: boolean
micEnabled: boolean
cameraEnabled: boolean
speakerEnabled: boolean
localStream?: MediaStream | null //
}>()
defineEmits<{
cancel: []
'toggle-mic': []
'toggle-camera': []
'toggle-speaker': []
}>()
const localVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localStream)
</script>

View File

@ -0,0 +1,109 @@
<template>
<!--
通话成员选择弹窗发起群通话时选邀请人 / 通话中添加成员
选择 UI 委托 GroupMemberPickerPanel自己 hide已在通话内的成员 disabled
-->
<el-dialog
v-model="visible"
:title="title"
width="720px"
:close-on-click-modal="false"
class="im-picker-dialog"
>
<div class="h-[480px]">
<GroupMemberPickerPanel
v-model:selected-ids="selectedIds"
:members="members"
:disabled-ids="disabledIds"
:hide-ids="hideIds"
/>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!canSubmit" @click="handleOk"></el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { useGroupStore } from '../../store/groupStore'
import { getCurrentUserId } from '@/utils/auth'
import GroupMemberPickerPanel from '../picker/GroupMemberPickerPanel.vue'
import type { GroupMemberLite } from '../group/GroupMember.vue'
defineOptions({ name: 'ImRtcCallMemberPickerDialog' })
type PickerMode = 'invite' | 'add'
const emit = defineEmits<{
/** 选完点完成;携带选中的 userId 列表 */
success: [selectedIds: number[]]
}>()
const groupStore = useGroupStore()
/** 弹窗显隐 */
const visible = ref(false)
/** 当前群编号open 时由调用方传入 */
const groupId = ref(0)
/** 弹窗用途invite=发起群通话选邀请人 / add=通话中追加成员 */
const mode = ref<PickerMode>('invite')
/** 置灰的 userId 列表add 场景把已在通话内的人禁用 */
const excludeUserIds = ref<number[]>([])
/** 当前选中的 userId 列表GroupMemberPickerPanel v-model 绑过来 */
const selectedIds = ref<number[]>([])
/** 标题;按用途切换 */
const title = computed(() => (mode.value === 'add' ? '添加成员' : '选择成员'))
/** 群成员列表;从 groupStore 现取map 成 GroupMemberLite */
const members = computed<GroupMemberLite[]>(() => {
const group = groupStore.getGroup(groupId.value)
return (group?.members || []).map((member) => ({
userId: member.userId,
nickname: member.nickname,
showName: member.displayUserName || member.nickname,
avatar: member.avatar,
status: member.status,
role: member.role
}))
})
/** 自己不出现在选项里 */
const hideIds = computed<number[]>(() => {
const myId = getCurrentUserId()
return myId ? [myId] : []
})
/** 已在通话内的成员置灰 */
const disabledIds = computed<number[]>(() => excludeUserIds.value)
/** 是否可提交:至少选 1 个 */
const canSubmit = computed(() => selectedIds.value.length > 0)
/** 打开弹窗excludeUserIds 用于「添加成员」时把已在通话内的人置灰 */
function open(opts: { groupId: number; mode?: PickerMode; excludeUserIds?: number[] }) {
groupId.value = opts.groupId
mode.value = opts.mode || 'invite'
excludeUserIds.value = opts.excludeUserIds || []
selectedIds.value = []
visible.value = true
}
defineExpose({ open }) // open
/** 点完成emit 选中 ID 列表给父级 + 关闭弹窗;提交按钮 disabled 已保证 selectedIds 非空 */
function handleOk() {
emit('success', [...selectedIds.value])
visible.value = false
}
</script>
<style scoped lang="scss">
@use '../picker/picker-dialog' as picker;
/* :deep 穿透 el-dialog 内部类;复用 picker 公共 mixin */
.im-picker-dialog {
@include picker.styles;
}
</style>

View File

@ -0,0 +1,110 @@
<template>
<!--
群通话宫格里的单个参与者格子
优先渲染远端视频 videoStream 时铺满无视频降级渲染头像 + 名字胶囊
pending=true 时叠加接入中三点动画占位用于被邀请人未接通前的占位渲染
远端音频通过隐藏 audio 播放本端 isLocal 不渲染避免回声
-->
<div
class="flex relative overflow-hidden justify-center items-center w-full h-full rounded-md bg-[#2a2a2c]"
>
<!-- 视频可用渲染 video否则渲染头像或默认占位 -->
<video
v-if="participant.videoStream"
ref="videoRef"
class="object-cover w-full h-full"
autoplay
playsinline
:muted="participant.isLocal"
></video>
<div v-else class="flex justify-center items-center w-full h-full">
<UserAvatar
:url="participant.avatar"
:name="participant.nickname"
:size="64"
radius="50%"
:clickable="false"
/>
</div>
<!-- 远端音频通过 audio 元素播放本端静音避免回声扬声器关闭时整体静音 -->
<audio
v-if="participant.audioStream && !participant.isLocal"
ref="audioRef"
autoplay
:muted="!speakerEnabled"
></audio>
<!-- 左下角名字胶囊 -->
<div
class="flex absolute bottom-3 left-3 gap-1.5 items-center py-[3px] pr-2.5 pl-[3px] text-13px text-white rounded-full bg-black/45 max-w-[calc(100%-60px)]"
>
<UserAvatar
:url="participant.avatar"
:name="participant.nickname"
:size="22"
radius="50%"
:clickable="false"
/>
<span class="truncate min-w-0">{{ participant.nickname }}</span>
</div>
<!-- 等待对方加入三点动画 +接入中文案位置在格子中心下方 60px -->
<div
v-if="participant.pending"
class="flex absolute left-1/2 flex-col gap-1.5 items-center -translate-x-1/2 -translate-y-1/2 top-[calc(50%+60px)]"
>
<div class="flex gap-1.5">
<span
v-for="i in 3"
:key="i"
class="tile-dot w-1.5 h-1.5 rounded-full bg-white/60"
:style="{ animationDelay: `${(i - 1) * 0.2}s` }"
></span>
</div>
<span class="text-xs text-white/70">接入中</span>
</div>
</div>
</template>
<script lang="ts" setup>
import UserAvatar from '../user/UserAvatar.vue'
import { useMediaStreamElement } from '../../composables/useMediaStreamElement'
export interface CallParticipantVM {
userId: number
nickname: string
avatar?: string
isLocal: boolean
videoStream?: MediaStream | null
audioStream?: MediaStream | null
/** 等待加入UI 显示三点动画 */
pending?: boolean
}
const props = defineProps<{
participant: CallParticipantVM
/** 扬声器开关;为 false 时静音该格子的远端音频 */
speakerEnabled: boolean
}>()
const videoRef = useMediaStreamElement<HTMLVideoElement>(() => props.participant.videoStream)
const audioRef = useMediaStreamElement<HTMLAudioElement>(() => props.participant.audioStream)
</script>
<style scoped>
/* 三点淡入淡出动画;@keyframes 必须 CSS 定义 */
.tile-dot {
animation: tile-dot 1.4s infinite ease-in-out both;
}
@keyframes tile-dot {
0%,
80%,
100% {
opacity: 0.3;
}
40% {
opacity: 1;
}
}
</style>

View File

@ -0,0 +1,289 @@
<template>
<!-- 通话进行中的悬浮窗1v1 私聊 320×540群通话切大窗 720×560 -->
<div
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-xl overflow-hidden shadow-[0_12px_36px_rgba(0,0,0,0.35)] z-[1000] flex flex-col text-white bg-[#1a1a1c]"
:class="isGroup ? 'w-[720px] h-[560px]' : 'w-[320px] h-[540px]'"
>
<!-- 重连中横幅网络抖动时显示直到 Reconnected 事件清除 -->
<div
v-if="reconnecting"
class="inline-flex absolute top-3 left-1/2 z-10 gap-2 items-center px-3.5 py-1.5 text-13px text-[#ffd45e] rounded-full -translate-x-1/2 bg-[rgba(255,196,0,0.18)]"
>
<span class="reconnect-dot w-2 h-2 rounded-full bg-[#ffd45e]"></span>
网络不佳正在重连
</div>
<div class="flex relative flex-1 justify-center items-center">
<!-- 群通话网格布局列数随人数自适应 -->
<div
v-if="isGroup"
class="grid gap-1 p-1 w-full h-full content-stretch"
:class="gridColsClass"
>
<RtcCallParticipantTile
v-for="participant in participants"
:key="participant.userId"
:participant="participant"
:speaker-enabled="speakerEnabled"
/>
</div>
<!-- 1v1 视频远端铺底 + 本地缩略 -->
<template v-else-if="isVideo">
<div v-show="hasRemoteVideo" class="absolute inset-0">
<video
ref="remoteVideoRef"
class="object-cover w-full h-full"
autoplay
playsinline
></video>
</div>
<div v-if="!hasRemoteVideo" class="flex z-[1] flex-col gap-4 items-center">
<UserAvatar
:url="peerAvatar"
:name="peerNickname"
:size="96"
radius="8px"
:clickable="false"
/>
<div class="text-[17px] font-medium">{{ peerNickname }}</div>
<div class="text-13px text-white/60">等待对方开启摄像头</div>
</div>
<div
v-if="localStream"
class="absolute top-4 right-4 z-[2] overflow-hidden w-30 rounded-lg aspect-[9/16] bg-[#333]"
>
<video
ref="localVideoRef"
class="object-cover w-full h-full scale-x-[-1]"
autoplay
muted
playsinline
></video>
</div>
</template>
<!-- 1v1 语音头像 + 时长 -->
<template v-else>
<div class="flex z-[1] flex-col gap-4 items-center">
<UserAvatar
:url="peerAvatar"
:name="peerNickname"
:size="96"
radius="8px"
:clickable="false"
/>
<div class="text-[17px] font-medium">{{ peerNickname }}</div>
<div class="text-13px text-white/60">{{ formattedDuration }}</div>
</div>
</template>
<audio
v-if="!isGroup && remoteAudioStream"
ref="remoteAudioRef"
autoplay
:muted="!speakerEnabled"
></audio>
</div>
<!-- 底部操作区麦克风 / 扬声器 / 摄像头 / (群聊共享屏幕 / 添加成员) / 挂断 -->
<div
class="flex flex-shrink-0 gap-3 justify-around items-center pt-4 px-4 pb-5 bg-black/20"
>
<div
class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]"
@click="$emit('toggle-mic')"
>
<span
class="flex justify-center items-center w-[52px] h-[52px] rounded-full"
:class="micEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'"
>
<Icon
:icon="micEnabled ? 'ant-design:audio-outlined' : 'ant-design:audio-muted-outlined'"
:size="22"
/>
</span>
<span class="text-xs text-white/70 whitespace-nowrap">
{{ micEnabled ? '麦克风已开' : '麦克风已关' }}
</span>
</div>
<div
class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]"
@click="$emit('toggle-speaker')"
>
<span
class="flex justify-center items-center w-[52px] h-[52px] rounded-full"
:class="speakerEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'"
>
<Icon
:icon="speakerEnabled ? 'ant-design:sound-outlined' : 'tabler:volume-off'"
:size="22"
/>
</span>
<span class="text-xs text-white/70 whitespace-nowrap">
{{ speakerEnabled ? '扬声器已开' : '扬声器已关' }}
</span>
</div>
<!-- 摄像头按钮私聊视频固定显示群聊不论起始 mediaType 都显示让用户按需开 -->
<div
v-if="isVideo || isGroup"
class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]"
@click="$emit('toggle-camera')"
>
<span
class="flex justify-center items-center w-[52px] h-[52px] rounded-full"
:class="cameraEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'"
>
<Icon
:icon="cameraEnabled ? 'ant-design:video-camera-outlined' : 'tabler:video-off'"
:size="22"
/>
</span>
<span class="text-xs text-white/70 whitespace-nowrap">
{{ cameraEnabled ? '摄像头已开' : '摄像头已关' }}
</span>
</div>
<!-- 群通话才有共享屏幕 / 添加成员共享中按钮高亮 + 文案换成停止共享 -->
<template v-if="isGroup">
<div
class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]"
@click="$emit('toggle-screen-share')"
>
<span
class="flex justify-center items-center w-[52px] h-[52px] rounded-full"
:class="screenShareEnabled ? 'bg-[#07c160] text-white' : 'bg-white/15 text-white'"
>
<Icon
:icon="screenShareEnabled ? 'ant-design:laptop-outlined' : 'tabler:device-laptop-off'"
:size="22"
/>
</span>
<span class="text-xs text-white/70 whitespace-nowrap">
{{ screenShareEnabled ? '停止共享' : '共享屏幕' }}
</span>
</div>
<div
class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]"
@click="$emit('add-member')"
>
<span class="flex justify-center items-center w-[52px] h-[52px] text-white rounded-full bg-white/15">
<Icon icon="ant-design:plus-outlined" :size="22" />
</span>
<span class="text-xs text-white/70 whitespace-nowrap">添加成员</span>
</div>
</template>
<div
class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]"
:class="{ 'opacity-60 pointer-events-none': hangingUp }"
@click="$emit('hangup')"
>
<span class="flex justify-center items-center w-[52px] h-[52px] text-white rounded-full bg-[#f04a4a]">
<Icon icon="ant-design:phone-outlined" :size="22" class="rotate-[135deg]" />
</span>
<span class="text-xs text-white/70 whitespace-nowrap">挂断</span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onUnmounted, ref, watch } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import UserAvatar from '../user/UserAvatar.vue'
import RtcCallParticipantTile, { type CallParticipantVM } from './RtcCallParticipantTile.vue'
import { formatCallDuration } from '@/views/im/utils/time'
import { useMediaStreamElement } from '../../composables/useMediaStreamElement'
const props = defineProps<{
/** 是否群通话;决定网格 / 单点布局 + 浮窗大小 */
isGroup: boolean
isVideo: boolean
micEnabled: boolean
cameraEnabled: boolean
/** 扬声器开关true 时正常播放远端音频false 时所有远端 audio 元素静音 */
speakerEnabled: boolean
/** 是否正在屏幕共享;按钮高亮 + 文案切换 */
screenShareEnabled?: boolean
/** 是否处于网络重连中;显示顶部黄色提示条 */
reconnecting?: boolean
startedAt: number
/** 网格视图用:所有参与者(含自己) */
participants: CallParticipantVM[]
/** 1v1 视图用 */
peerNickname?: string
peerAvatar?: string
localStream?: MediaStream | null
remoteVideoStream?: MediaStream | null
remoteAudioStream?: MediaStream | null
hangingUp?: boolean
}>()
defineEmits<{
hangup: []
'toggle-mic': []
'toggle-camera': []
'toggle-speaker': []
'toggle-screen-share': []
'add-member': []
}>()
/** 网格列数;按人数自适应;返回 UnoCSS class 字面量让 JIT 扫描器静态识别 */
const gridColsClass = computed(() => {
const n = props.participants.length
if (n <= 1) return 'grid-cols-1'
if (n <= 4) return 'grid-cols-2'
return 'grid-cols-3'
})
const localVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localStream)
const remoteVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.remoteVideoStream)
const remoteAudioRef = useMediaStreamElement<HTMLAudioElement>(() => props.remoteAudioStream)
/** 1v1 视频:是否有远端视频流 */
const hasRemoteVideo = computed(() => !props.isGroup && !!props.remoteVideoStream)
/** 通话时长;仅 1v1 语音视图需要展示,其它视图不启 tick */
const now = ref(Date.now())
let tick = 0
watch(
() => props.isGroup || props.isVideo,
(suppressTick) => {
if (suppressTick) {
if (tick) {
clearInterval(tick)
tick = 0
}
return
}
now.value = Date.now()
tick = window.setInterval(() => {
now.value = Date.now()
}, 1000)
},
{ immediate: true }
)
onUnmounted(() => {
if (tick) {
clearInterval(tick)
}
})
/** 通话时长 MM:SS / HH:MM:SS */
const formattedDuration = computed(() =>
formatCallDuration(Math.floor((now.value - props.startedAt) / 1000))
)
</script>
<style scoped>
/* 重连小点淡入淡出;@keyframes 必须 CSS 定义 */
.reconnect-dot {
animation: reconnect-pulse 1s ease-in-out infinite;
}
@keyframes reconnect-pulse {
0%,
100% {
opacity: 0.3;
}
50% {
opacity: 1;
}
}
</style>

View File

@ -0,0 +1,172 @@
<template>
<!-- 仅当该群有活跃通话时显示点击胶囊条展开 popover 看在通话成员 + 加入 -->
<div v-if="activeCall" class="flex-shrink-0 px-4 pb-2 bg-[var(--el-fill-color-light)]">
<el-popover
v-model:visible="popoverVisible"
placement="bottom-start"
:width="280"
trigger="click"
>
<!-- 胶囊条本体电话图标 + 文案含人数+ 右箭头 -->
<template #reference>
<div
class="inline-flex gap-2 items-center px-2.5 py-1 text-13px rounded-full cursor-pointer select-none transition-colors duration-150 bg-[var(--el-color-success-light-9)] text-[var(--el-text-color-primary)] hover:bg-[var(--el-color-success-light-8)]"
>
<span
class="inline-flex flex-shrink-0 justify-center items-center w-[18px] h-[18px] text-white rounded-full bg-[#07c160]"
>
<Icon icon="ant-design:phone-filled" :size="14" />
</span>
<span class="font-medium">{{ pillText }}</span>
<Icon
icon="ant-design:right-outlined"
:size="12"
class="text-[var(--el-text-color-secondary)]"
/>
</div>
</template>
<!-- 展开面板通话成员含接入中头像横排 + 加入按钮 -->
<div class="flex flex-col gap-4 items-center pt-2 pb-1">
<div class="flex flex-wrap gap-1.5 justify-center max-w-[240px]">
<UserAvatar
v-for="member in memberList"
:key="member.userId"
:url="member.avatar"
:name="member.nickname"
:size="40"
radius="6px"
:clickable="false"
:class="{ 'opacity-50': member.pending }"
:title="member.pending ? `${member.nickname}(接入中)` : member.nickname"
/>
<!-- 首次填充时房内可能暂时 0 加入后由 ParticipantConnected 事件追加 -->
<div
v-if="memberList.length === 0"
class="p-3 text-13px text-[var(--el-text-color-secondary)]"
>
暂无成员在通话
</div>
</div>
<!-- 本端在通话内时置灰已在通话中服务端残留我但本端连接断了显示重新加入刷新页面后场景 -->
<button
class="w-[200px] h-9 text-sm font-medium rounded-lg cursor-pointer border-none bg-[#f1f1f3] text-[#1a1a1c] transition-colors duration-150 disabled:cursor-not-allowed disabled:text-[#999] hover:[&:not(:disabled)]:bg-[#e7e7ea]"
:disabled="joinDisabled"
@click="handleJoin"
>
{{ joinLabel }}
</button>
</div>
</el-popover>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import UserAvatar from '../user/UserAvatar.vue'
import { useMessage } from '@/hooks/web/useMessage'
import { useRtcStore } from '../../store/rtcStore'
import { useGroupCallMembers } from '../../composables/useGroupCallMembers'
import { joinCall, getActiveCall } from '@/api/im/rtc'
import { DICT_TYPE, getDictLabel } from '@/utils/dict'
import { getCurrentUserId } from '@/utils/auth'
const props = defineProps<{
groupId: number
}>()
defineOptions({ name: 'ImRtcGroupCallBanner' })
const rtcStore = useRtcStore()
const message = useMessage()
const popoverVisible = ref(false)
/** 当前群的活跃通话rtcStore 维护,参与者加入 / 离开通知增删 joinedUserIds通话结束移除 */
const activeCall = computed(() => rtcStore.getGroupCall(props.groupId))
/** 胶囊条文案;有成员(已加入 + 接入中)则带人数,初始 0 人时只显示媒体类型 */
const pillText = computed(() => {
const media = getDictLabel(DICT_TYPE.IM_RTC_CALL_MEDIA_TYPE, activeCall.value?.mediaType)
const count = memberList.value.length
return count > 0 ? `正在${media}通话(${count} 人)` : `正在${media}通话`
})
/**
* 切到群 / 通话 room 变化时拉一次最新参与者列表
* 两个触发场景1用户切群本端可能没有该群通话的最新缓存2参与者通知首次填充后只含本次加入者缺历史加入者
* [groupId, room] 双源监听 + 已填充守卫避免切群 / 首次填充触发的双次重复拉取
*/
watch(
() => [props.groupId, activeCall.value?.room] as const,
async ([groupId, room], oldValues) => {
if (!groupId) {
return
}
// / room room >= 2
const groupChanged = !oldValues || oldValues[0] !== groupId
const roomChanged = oldValues && oldValues[1] !== room
const hydrated = (activeCall.value?.joinedUserIds?.length ?? 0) > 1
if (!groupChanged && !roomChanged && hydrated) {
return
}
// store
try {
const data = await getActiveCall(groupId)
if (data) {
rtcStore.setGroupCall(data)
} else {
rtcStore.removeGroupCall(groupId)
}
} catch (e) {
console.warn('[GroupCallBanner] getActiveCall 失败', { groupId }, e)
}
},
{ immediate: true }
)
/** 在通话中的成员(已加入)+ 接入中的成员(已邀请未接通) */
const memberList = useGroupCallMembers(computed(() => props.groupId))
/** 本端是否正在该房间通话(处于 INVITING / RUNNING */
const isInThisCall = computed(
() => rtcStore.isActive && rtcStore.call?.room === activeCall.value?.room
)
/**
* 服务端是否记录我已加入刷新后 LiveKit 连接已断但 webhook 还没把 status 标为 LEFT 时仍为 true
* 用于把按钮文案切到重新加入但不 disable 按钮
*/
const serverSaysJoined = computed(() => {
const myId = getCurrentUserId()
return activeCall.value?.joinedUserIds?.includes(myId) ?? false
})
/** 加入按钮禁用:仅在本端实际持有 LiveKit 连接时禁用 */
const joinDisabled = computed(() => isInThisCall.value)
/** 加入按钮文案;本端连着 → 已在通话中;服务端还残留我但本端断了 → 重新加入;其它 → 加入 */
const joinLabel = computed(() => {
if (isInThisCall.value) return '已在通话中'
if (serverSaysJoined.value) return '重新加入'
return '加入'
})
/** 主动加入:调 invite 命中已有 call 拿 tokenrtcStore 按 status 自动进 RUNNING */
async function handleJoin() {
const call = activeCall.value
if (!call || joinDisabled.value) {
return
}
if (rtcStore.isActive) {
message.warning('您正在通话中')
return
}
popoverVisible.value = false
const data = await joinCall(call.room)
rtcStore.startInviting(data)
}
</script>

View File

@ -0,0 +1,313 @@
<template>
<!--
把名片推荐给朋友用户 / 群通用
- dialog 壳由本组件持有选择 UI 委托 ConversationPickerPanel / FriendPickerPanel
- view='conversation'选已有会话发名片默认视图
- view='contact'创建聊天入口进入选好友建群再发名片业务壳层切视图两个 Panel 互不知道对方
- 1 发送多个走分别发送(n)文案与微信一致
- 失败的消息以 FAILED 状态留在对应会话气泡里供右键重试
- 对外接口ref + open({ target })不再走 v-model
-->
<el-dialog
v-model="visible"
width="720px"
:close-on-click-modal="false"
class="im-picker-dialog im-recommend-dialog"
>
<template #header>
<div class="flex gap-2 items-center">
<Icon
v-if="view === 'contact'"
icon="ant-design:arrow-left-outlined"
:size="16"
class="cursor-pointer text-[var(--el-text-color-secondary)] transition-colors duration-150 hover:text-[var(--el-color-primary)]"
@click="view = 'conversation'"
/>
<span class="text-base text-[var(--el-text-color-primary)]">
{{ headerTitle }}
</span>
</div>
</template>
<div class="h-[480px]">
<!-- 会话视图选已有会话发送 -->
<ConversationPickerPanel
v-if="view === 'conversation'"
v-model:selected-keys="selectedKeys"
:conversations="candidateConversations"
:recent-forward-conversation-keys="conversationStore.recentForwardConversationKeys"
:hide-keys="hideKeys"
:show-create-chat="true"
@create-chat="handleSwitchToContact"
@remove-recent="conversationStore.removeRecentForwardConversationKey"
>
<template #footer>
<div class="flex flex-col gap-3 px-4 py-3">
<!-- 名片预览卡 -->
<CardBubble v-if="target" :card="target" />
<!-- 留言单行右侧表情按钮触发 FacePicker选中 emoji 拼到末尾 -->
<div class="relative">
<el-input v-model="leaveMessage" :maxlength="100" placeholder="给朋友留言">
<template #suffix>
<Icon
icon="ant-design:smile-outlined"
:size="18"
class="cursor-pointer text-[var(--el-text-color-secondary)] hover:text-[var(--el-color-primary)]"
@click.stop="emojiVisible = !emojiVisible"
/>
</template>
</el-input>
<FacePicker
v-model:visible="emojiVisible"
mode="emoji"
class="bottom-full right-0 mb-2"
@select-emoji="handleEmojiSelect"
/>
</div>
<!-- 操作按钮 0/1 显示发送多个显示分别发送(n) -->
<div class="flex gap-2 justify-end">
<el-button @click="visible = false">取消</el-button>
<el-button
type="primary"
:loading="sending"
:disabled="selectedKeys.length === 0"
@click="handleSend"
>
{{ selectedKeys.length > 1 ? `分别发送(${selectedKeys.length}` : '发送' }}
</el-button>
</div>
</div>
</template>
</ConversationPickerPanel>
<!-- 好友视图选好友建群后发送 -->
<FriendPickerPanel v-else v-model:selected-ids="selectedFriendIds" :friends="friends" />
</div>
<!-- 好友视图的 dialog footer建群并发送 -->
<template v-if="view === 'contact'" #footer>
<el-button @click="visible = false">取消</el-button>
<el-button
type="primary"
:loading="sending"
:disabled="selectedFriendIds.length === 0"
@click="handleCreateGroupAndSend"
>
创建群聊并发送
</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { useMessage } from '@/hooks/web/useMessage'
import { createGroup } from '@/api/im/group'
import CardBubble from '@/views/im/home/components/card/CardBubble.vue'
import ConversationPickerPanel from '../picker/ConversationPickerPanel.vue'
import FriendPickerPanel from '../picker/FriendPickerPanel.vue'
import FacePicker from '../../pages/conversation/components/input/FacePicker.vue'
import { useConversationStore } from '../../store/conversationStore'
import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore'
import { useMessageSender } from '../../composables/useMessageSender'
import { ImConversationType, ImMessageType, isGroupConversation } from '../../../utils/constants'
import { getConversationKey } from '../../../utils/conversation'
import { buildDefaultGroupName } from '../../../utils/group'
import { serializeMessage, type CardTarget } from '../../../utils/message'
import type { Conversation, FriendLite } from '../../types'
defineOptions({ name: 'ImRecommendCardDialog' })
const message = useMessage()
const conversationStore = useConversationStore()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const { sendRaw, send } = useMessageSender()
const visible = ref(false)
const target = ref<CardTarget | null>(null)
/** 当前视图:默认会话选择,「创建聊天」入口切到好友选择 */
const view = ref<'conversation' | 'contact'>('conversation')
const selectedKeys = ref<string[]>([])
const selectedFriendIds = ref<number[]>([])
const leaveMessage = ref('')
const sending = ref(false)
/** 表情面板显隐:右侧 smile icon 切换 */
const emojiVisible = ref(false)
defineExpose({
/** 打开推荐弹窗reset → 灌参 → visible=true */
open(opts: { target: CardTarget }) {
target.value = opts.target
view.value = 'conversation'
selectedKeys.value = []
selectedFriendIds.value = []
leaveMessage.value = ''
emojiVisible.value = false
sending.value = false
visible.value = true
}
})
/** 弹窗标题:会话视图按 target 类型分文案;好友视图固定为「选择好友」 */
const headerTitle = computed(() => {
if (view.value === 'contact') {
return '选择好友'
}
return isGroupConversation(target.value?.targetType) ? '把这个群推荐给朋友' : '把他推荐给朋友'
})
/** 候选会话:从 store 拿排序后的列表hide 由 Panel 接 hideKeys 过滤) */
const candidateConversations = computed<Conversation[]>(
() => conversationStore.getSortedConversationList
)
/** 隐藏 key不能把名片推回名片本身的会话用户名片避免自推、群名片避免推回该群 */
const hideKeys = computed<string[]>(() => {
const t = target.value
if (!t) {
return []
}
return [getConversationKey({ type: t.targetType, targetId: t.targetId })]
})
/** 好友视图候选列表:直接复用 friendStore Lite 视图 */
const friends = computed<FriendLite[]>(() => friendStore.getActiveFriendLiteList)
/** 把选中的 emoji 拼到留言末尾FacePicker 自身负责关闭面板 */
function handleEmojiSelect(emoji: string) {
leaveMessage.value = `${leaveMessage.value}${emoji}`
}
/** 切到好友视图:清掉之前在会话视图输入的留言,避免在不可见输入框里把留言静默发到新群 */
function handleSwitchToContact() {
view.value = 'contact'
leaveMessage.value = ''
emojiVisible.value = false
}
/**
* 确认发送会话视图每个选中会话先发 CARDCARD 成功后才发留言保证先看到名片的顺序意图
*
* 文案聚合全部成功已转发全部失败转发失败AB部分失败已转发 XY 失败
* 失败的消息以 FAILED 状态留在对应会话气泡里可右键重试
*/
async function handleSend() {
const card = target.value
if (!card?.targetId || selectedKeys.value.length === 0) {
return
}
const byKey = new Map(candidateConversations.value.map((c) => [getConversationKey(c), c]))
const targets = selectedKeys.value
.map((key) => byKey.get(key))
.filter((c): c is Conversation => c != null)
if (targets.length === 0) {
return
}
const cardContent = serializeMessage({ ...card })
const leaveText = leaveMessage.value.trim()
sending.value = true
try {
const tasks = targets.map(async (conversation) => {
const cardOk = await sendRaw(ImMessageType.CARD, cardContent, { conversation })
if (!cardOk) {
return { conversation, ok: false }
}
const ok = leaveText ? await send(leaveText, { conversation }) : true
return { conversation, ok }
})
const results = await Promise.all(tasks)
const failedNames = results.filter((r) => !r.ok).map((r) => r.conversation.name || '未命名会话')
// ""
conversationStore.pushRecentForwardConversationKeyList(targets.map((c) => getConversationKey(c)))
if (failedNames.length === 0) {
message.success('已转发')
} else if (failedNames.length === targets.length) {
message.error(`转发失败:${failedNames.join('、')}`)
} else {
message.warning(`已转发,但 ${failedNames.join('、')} 失败`)
}
visible.value = false
} finally {
sending.value = false
}
}
/**
* 好友视图发送先建群同时邀请所选好友 给新群发名片 发留言 关弹窗
*
* 跟会话视图的差别先要 createGroup 拿到 groupId之后构造一个 GROUP 类型的 conversation 对象给 sendRaw
* sendRaw 内部会自动 insertMessage 把新群登记进 store最近转发列表也能正常推
*/
async function handleCreateGroupAndSend() {
const card = target.value
if (!card?.targetId || selectedFriendIds.value.length === 0) {
return
}
const byId = new Map(friends.value.map((f) => [f.id, f]))
const members = selectedFriendIds.value
.map((id) => byId.get(id))
.filter((f): f is FriendLite => f != null)
if (members.length === 0) {
return
}
sending.value = true
try {
const memberUserIds = members.map((m) => m.id)
const name = buildDefaultGroupName(members)
const group = await createGroup({ name, memberUserIds, joinApproval: false })
if (!group?.id) {
throw new Error('创建群失败:未返回群编号')
}
// upsert groupStore fetchGroupList
groupStore.upsertGroup({
id: group.id,
name: group.name,
avatar: group.avatar,
notice: group.notice,
ownerUserId: group.ownerUserId
})
// conversation sendRaw sendRaw insertMessage
const newConversation: Conversation = {
type: ImConversationType.GROUP,
targetId: group.id,
name: group.name || name,
avatar: group.avatar || '',
unreadCount: 0,
lastContent: '',
lastSendTime: 0
}
const cardOk = await sendRaw(ImMessageType.CARD, serializeMessage({ ...card }), {
conversation: newConversation
})
if (!cardOk) {
message.warning('群已创建,但名片发送失败,请稍后在群里重试')
visible.value = false
return
}
const leaveText = leaveMessage.value.trim()
if (leaveText) {
await send(leaveText, { conversation: newConversation })
}
conversationStore.pushRecentForwardConversationKeyList([getConversationKey(newConversation)])
message.success('已创建群聊并发送')
visible.value = false
} finally {
sending.value = false
}
}
</script>
<style scoped lang="scss">
@use '../picker/picker-dialog' as picker;
/* :deep 穿透 el-dialog 内部类;复用 picker 公共 mixin */
.im-picker-dialog {
@include picker.styles;
}
</style>

View File

@ -0,0 +1,129 @@
<template>
<!--
通用用户头像组件
- url 时展示图片 url 时展示色卡 + 首字母/首字
- 点击默认触发 UserInfoCardclickable
- previewable=true 时改为点头像直接放大预览用于名片 / 详情页等大头像位
-->
<div
class="relative inline-flex"
:style="{ cursor: clickable && !previewable ? 'pointer' : 'default' }"
v-bind="$attrs"
@click="handleClick"
>
<el-image
v-if="url && previewable"
class="block overflow-hidden"
:src="url"
:preview-src-list="[url]"
:preview-teleported="true"
:z-index="previewZIndex"
:style="imgStyle"
fit="cover"
/>
<img
v-else-if="url"
class="block overflow-hidden object-cover"
:src="url"
:style="imgStyle"
loading="lazy"
:alt="name || 'avatar'"
/>
<div
v-else
class="flex items-center justify-center text-white font-medium select-none"
:style="textStyle"
>
{{ avatarText }}
</div>
<!-- 允许外部插入装饰如群聊角标 -->
<slot></slot>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { useImUiStore } from '../../store/uiStore'
import { ImFriendAddSource } from '../../../utils/constants'
import { getAvatarBgColor, getAvatarText } from '../../../utils/user'
import type { User } from '../../types'
defineOptions({ name: 'ImUserAvatar', inheritAttrs: false })
const props = withDefaults(
defineProps<{
id?: string | number // id
url?: string // URL
name?: string // +
size?: number // px width/height
radius?: string // CSS 15%
clickable?: boolean // UserInfoCard true
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,
addSource: ImFriendAddSource.SEARCH
}
)
const uiStore = useImUiStore()
const imgStyle = computed(() => ({
width: `${props.size}px`,
height: `${props.size}px`,
borderRadius: props.radius
}))
const textStyle = computed(() => ({
width: `${props.size}px`,
height: `${props.size}px`,
fontSize: `${Math.floor(props.size * (avatarText.value.length > 1 ? 0.34 : 0.42))}px`,
background: textColor.value,
borderRadius: props.radius
}))
/** 色卡首字:中文取 1 个字、英文 / 拉丁取前 2 个字母 */
const avatarText = computed(() => getAvatarText(props.name))
/** 色卡底色:按昵称 charCode 哈希取调色板色 */
const textColor = computed(() => getAvatarBgColor(props.name))
/** 头像点击previewable 走 el-image 预览不弹名片;否则按 user / id 任一入参打开名片 */
function handleClick(e: MouseEvent) {
if (props.previewable) {
return
}
if (!props.clickable) {
return
}
// user
if (props.user) {
uiStore.openUserInfoCardAtEvent(props.user, e, props.addSource, props.addSourceExtra)
return
}
// user id + +
const numId = Number(props.id)
if (!numId || numId <= 0) {
return
}
uiStore.openUserInfoCardAtEvent(
{
id: numId,
nickname: props.name,
avatar: props.url
},
e,
props.addSource,
props.addSourceExtra
)
}
</script>

View File

@ -0,0 +1,453 @@
<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(--el-text-color-primary)]"
>
{{ 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(--el-text-color-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">
<el-dropdown trigger="click" placement="bottom-end" popper-class="im-user-info__more-menu">
<div
class="flex items-center justify-center w-7 h-7 rounded cursor-pointer hover:bg-[var(--el-fill-color-light)]"
>
<Icon icon="ep:more-filled" :size="18" class="text-[var(--el-text-color-secondary)]" />
</div>
<template #dropdown>
<el-dropdown-menu>
<!-- 把他推荐给朋友以个人名片消息形式发到选中的会话 -->
<el-dropdown-item @click="handleRecommend"></el-dropdown-item>
<!-- 拉黑 / 移出黑名单 friendInfo.blocked 切换文案 -->
<el-dropdown-item v-if="!isBlocked" divided @click="handleBlock"
>加入黑名单</el-dropdown-item
>
<el-dropdown-item v-else divided @click="handleUnblock"></el-dropdown-item>
<el-dropdown-item divided @click="handleDeleteFriend">
<span class="text-[var(--el-color-danger)]">删除联系人</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<!-- 备注行 friend 态展示未编辑时整行可点编辑态用 el-input 行内替换 value -->
<template v-if="relation === 'friend'">
<div class="my-4 h-px bg-[var(--el-border-color-lighter)]"></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(--el-fill-color-lighter)]'
: ''
"
@click="handleRowClick"
>
<span class="flex-shrink-0 w-16 whitespace-nowrap text-[var(--el-text-color-secondary)]"
>备注</span
>
<el-input
v-if="editingRemark"
ref="remarkInputRef"
v-model="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(--el-text-color-primary)]'
: 'text-[var(--el-text-color-placeholder)]'
"
>
{{ displayName || '添加备注' }}
</span>
<!-- 默认 opacity:0 占位避免 hover 时其它列位移仅父行 hover 时浮现 -->
<Icon
icon="ant-design:edit-outlined"
:size="14"
class="flex-shrink-0 text-[var(--el-text-color-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(--el-border-color-lighter)]"></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(--el-text-color-secondary)]"
>来源</span
>
<span class="flex-1 min-w-0 truncate text-[var(--el-text-color-primary)]">
{{ 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(--el-text-color-secondary)]"
>添加时间</span
>
<span class="flex-1 min-w-0 truncate text-[var(--el-text-color-primary)]">
{{ formatDate(new Date(friendInfo.addTime), 'YYYY-MM-DD') }}
</span>
</div>
</template>
</template>
<!-- 动作区好友 = 3 图标陌生人 = 加为好友按钮自己 = disabledreadonly 不渲染 -->
<template v-if="relation !== 'readonly'">
<div class="my-4 h-px bg-[var(--el-border-color-lighter)]"></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(--el-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(--el-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(--el-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">
<el-button type="primary" disabled>不能和自己聊天</el-button>
</div>
<div v-else class="flex justify-center">
<el-button type="primary" @click="handleAddFriend"></el-button>
</div>
</template>
<!-- 加好友弹窗携带预填用户跳过搜索步骤直接进申请表单理由按 addSource 区分话术 -->
<FriendAddDialog ref="friendAddDialogRef" />
<!-- 把他推荐给朋友弹窗 friend 态下出现入口 -->
<RecommendCardDialog ref="recommendDialogRef" />
</div>
</template>
<script lang="ts" setup>
import { computed, h, nextTick, ref, watch } from 'vue'
import { ElCheckbox, ElMessageBox, type InputInstance } from 'element-plus'
import Icon from '@/components/Icon/src/Icon.vue'
import { useMessage } from '@/hooks/web/useMessage'
import UserAvatar from './UserAvatar.vue'
import FriendAddDialog from '../friend/FriendAddDialog.vue'
import RecommendCardDialog from './RecommendCardDialog.vue'
import { getSimpleUser, type UserVO } from '@/api/system/user'
import { useFriendStore } from '../../store/friendStore'
import { ImFriendAddSource } from '../../../utils/constants'
import { toUserCardTarget } from '../../../utils/message'
import { getGenderColor, getGenderIcon } from '../../../utils/user'
import { DICT_TYPE, getDictLabel } from '@/utils/dict'
import { formatDate } from '@/utils/formatTime'
import type { User } from '../../types'
defineOptions({ name: 'ImUserInfo' })
/**
* 关系态决定备注行 / 右上 "..." 菜单 / 底部动作区的内容
* - friend: 备注可编辑+ "..." 删除菜单 + 3 图标动作
* - stranger: 单按钮"加为好友"
* - self: 单按钮"不能和自己聊天" disabled
* - readonly: 不渲染备注 / 菜单 / 动作区仅展示头部信息
*/
export type UserInfoRelation = 'friend' | 'stranger' | 'self' | 'readonly'
const props = withDefaults(
defineProps<{
/** 起手用户:至少要有 id性别 / 部门由组件按 id 懒拉合并补齐 */
user: User | null
relation?: UserInfoRelation
/** 备注:仅 relation=friend 时有效;空串 → 显示"添加备注"占位 */
displayName?: string
/** UserAvatar 预览层 z-index放在高 z-index 浮层(如 UserInfoCard里需手动抬高 */
previewZIndex?: number
/** 加好友来源;默认 SEARCH参见 ImFriendAddSource */
addSource?: number
/** 来源附带信息addSource=GROUP群聊时传群名用于「我是 XX 群的 YY」预填话术 */
addSourceExtra?: string
}>(),
{
relation: 'readonly',
previewZIndex: 2000,
addSource: ImFriendAddSource.SEARCH
}
)
const emit = defineEmits<{
/** 备注落库成功后通知父级,用于同步父级持有的本地 FriendLite / Friend 副本(如 contact 页的 selection */
saved: [value: string]
/** 用户点"发消息":导航 / 关浮层等场景相关动作由父级承担(比如 UserInfoCard 要 close */
chat: [user: User]
/** 删除联系人成功后通知父级confirm + 调 store 都在本组件内做完):父级关浮层 / 清选中等 */
deleted: [user: User]
}>()
const message = useMessage()
const friendStore = useFriendStore()
/** 起手 user + getSimpleUser 合并后的完整对象(性别 / 部门补齐用) */
const full = ref<User | null>(props.user)
/** 主标题:备注优先(好友场景),其次原昵称 */
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)
/** 备注内联编辑editingRemark 控制输入态user 切换时由下面的 watch 复位避免脏态泄漏 */
const editingRemark = ref(false)
const remarkInput = ref('')
const remarkInputRef = ref<InputInstance | null>(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} 功能开发中`)
}
// ==================== / ====================
/** 加好友弹窗 refhandleAddFriend 调 open({ presetUser, addSource, addSourceExtra }) 触发 */
const friendAddDialogRef = ref<InstanceType<typeof FriendAddDialog>>()
/** 推荐名片弹窗 refhandleRecommend 调用 open({ target }) 打开 */
const recommendDialogRef = ref<InstanceType<typeof RecommendCardDialog>>()
/** 把他推荐给朋友:弹 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: UserVO = {
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 UserVO
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 message.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 ElMessageBox({
title: '删除联系人',
message: () =>
h('div', { class: 'flex flex-col gap-3 text-sm' }, [
h('div', `确定删除好友「${target.nickname || ''}」?`),
h(
ElCheckbox,
{
modelValue: clearConversation.value,
'onUpdate:modelValue': (value: boolean) => {
clearConversation.value = value
}
},
() => '同时清空聊天记录'
)
]),
showCancelButton: true,
confirmButtonText: '删除',
cancelButtonText: '取消',
confirmButtonClass: 'el-button--danger'
})
} catch {
return
}
await friendStore.deleteFriend(target.id, clearConversation.value)
message.success('已删除好友')
emit('deleted', target)
}
</script>
<style>
/*
scopedel-dropdown 的下拉菜单走 teleport bodyscoped 选不到
UserInfoCard 浮层用 z-9998要把这块抬到更高默认 --el-popper-z-index ~2050 会被遮罩压住
*/
.im-user-info__more-menu {
z-index: 10000 !important;
}
</style>

View File

@ -0,0 +1,120 @@
<template>
<!--
用户名片浮层
- 仅承担"浮层定位 + 关闭逻辑(点遮罩 / Esc"名片视觉走 <UserInfo> contact 详情共用一份组件
- 触发useImUiStore.openUserInfoCard(user, position)本组件订阅 store全局只挂一份实例
- 关系态由 isSelf / isActiveFriend 派生 relation prop 透到 UserInfo删除 / 加好友 / 备注落库都在 UserInfo 内闭环
-->
<teleport to="body">
<div v-if="card.show" class="fixed inset-0 z-9998" @click.self="handleClose">
<div
class="fixed w-80 p-4 bg-[var(--el-bg-color-overlay)] rounded-md shadow-xl"
:style="{ left: card.position.x + 'px', top: card.position.y + 'px' }"
@click.stop
>
<UserInfo
:user="user"
:display-name="remark"
:relation="relation"
:preview-z-index="10000"
:add-source="card.addSource"
:add-source-extra="card.addSourceExtra"
@chat="handleSendMessage"
@deleted="handleClose"
/>
</div>
</div>
</teleport>
</template>
<script lang="ts" setup>
import { computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { getCurrentUserId } from '@/utils/auth'
import { useImUiStore } from '../../store/uiStore'
import { useConversationStore } from '../../store/conversationStore'
import { useFriendStore } from '../../store/friendStore'
import { getFriendDisplayName } from '../../../utils/user'
import { ImConversationType } from '../../../utils/constants'
import UserInfo, { type UserInfoRelation } from './UserInfo.vue'
defineOptions({ name: 'ImUserInfoCard' })
const uiStore = useImUiStore()
const conversationStore = useConversationStore()
const friendStore = useFriendStore()
const router = useRouter()
const card = computed(() => uiStore.userInfoCard)
const user = computed(() => card.value.user)
const isSelf = computed(() => {
const myId = getCurrentUserId()
return !!user.value?.id && user.value.id === myId
})
const isActiveFriend = computed(() => {
if (!user.value?.id || isSelf.value) {
return false
}
return friendStore.isActiveFriend(user.value.id)
})
const relation = computed<UserInfoRelation>(() => {
if (!user.value) {
return 'readonly'
}
if (isSelf.value) {
return 'self'
}
if (isActiveFriend.value) {
return 'friend'
}
return 'stranger'
})
const remark = computed(() => {
if (!isActiveFriend.value || !user.value?.id) {
return undefined
}
return friendStore.getFriend(user.value.id)?.displayName || ''
})
/** 关闭名片:点击遮罩 / Esc / UserInfo 抛上来的删除成功事件 */
function handleClose() {
uiStore.closeUserInfoCard()
}
/** 键盘事件Esc 键关闭名片 */
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && card.value.show) {
handleClose()
}
}
onMounted(() => window.addEventListener('keydown', handleKeydown))
onUnmounted(() => window.removeEventListener('keydown', handleKeydown))
/** 发消息UserInfo 抛上来的"发消息",由本组件接力做 openConversation + 跳消息 Tab + 关浮层 */
function handleSendMessage() {
if (!user.value) {
return
}
// friendStore /
const friend = friendStore.getFriend(user.value.id)
const conversationName = friend
? getFriendDisplayName(friend)
: user.value.nickname || ''
conversationStore.openConversation(
user.value.id,
ImConversationType.PRIVATE,
conversationName,
user.value.avatar || '',
{ silent: !!friend?.silent }
)
// Tab
if (router.currentRoute.value.name !== 'ImHomeConversation') {
router.push({ name: 'ImHomeConversation' })
}
uiStore.closeUserInfoCard()
}
</script>

View File

@ -0,0 +1,98 @@
import { computed, type ComputedRef, type Ref } from 'vue'
import type { FriendLite } from '../types'
/** 字母分桶结果letter 取 'A'-'Z' 或兜底 '#'list 桶内按拼音 / 名字自然序 */
export interface FriendBucket {
letter: string
list: FriendLite[]
}
/** 取分桶 / 排序键:备注拼音优先 → 昵称拼音 → 名字本身(兜底英文 / 数字) */
function getSortKey(friend: FriendLite): string {
return (
friend.displayNamePinyin ||
friend.nicknamePinyin ||
(friend.displayName || friend.nickname || '').toLowerCase()
)
}
/** 取分桶字母:拼音首字母大写,非字母(纯符号 / 数字 / 中文)兜底 '#' */
function getBucketLetter(friend: FriendLite): string {
const first = getSortKey(friend).charAt(0)
return /^[a-zA-Z]$/.test(first) ? first.toUpperCase() : '#'
}
/** 拼音首字母拼接「lao zhang」→ 'lz',支持「输 lz 搜老张」 */
function pinyinInitials(pinyin?: string): string {
if (!pinyin) {
return ''
}
return pinyin
.split(' ')
.map((word) => word.charAt(0))
.join('')
}
/**
* +
*
* - / / /
* - A-Z + '#' getSortKey
*
* FriendList FriendPickerPanel
*/
export function useFriendBuckets(
friends: Ref<FriendLite[]> | ComputedRef<FriendLite[]>,
keyword: Ref<string>
): {
filtered: ComputedRef<FriendLite[]>
buckets: ComputedRef<FriendBucket[]>
} {
const filtered = computed(() => {
const keywordLower = keyword.value.trim().toLowerCase()
if (!keywordLower) {
return friends.value
}
return friends.value.filter((friend) => {
const nicknamePinyin = friend.nicknamePinyin || ''
const displayNamePinyin = friend.displayNamePinyin || ''
// 全拼搜索去掉空格让「laozhang」也能命中「lao zhang」
return (
(friend.nickname || '').toLowerCase().includes(keywordLower) ||
(friend.displayName || '').toLowerCase().includes(keywordLower) ||
nicknamePinyin.replace(/\s/g, '').includes(keywordLower) ||
displayNamePinyin.replace(/\s/g, '').includes(keywordLower) ||
pinyinInitials(nicknamePinyin).includes(keywordLower) ||
pinyinInitials(displayNamePinyin).includes(keywordLower)
)
})
})
const buckets = computed<FriendBucket[]>(() => {
const map = new Map<string, FriendLite[]>()
for (const friend of filtered.value) {
const letter = getBucketLetter(friend)
if (!map.has(letter)) {
map.set(letter, [])
}
map.get(letter)!.push(friend)
}
const letters = Array.from(map.keys()).sort((a, b) => {
// '#' 永远排末尾A-Z 走 localeCompare
if (a === '#') {
return 1
}
if (b === '#') {
return -1
}
return a.localeCompare(b)
})
return letters.map((letter) => ({
letter,
list: map.get(letter)!.sort((a, b) => getSortKey(a).localeCompare(getSortKey(b)))
}))
})
return { filtered, buckets }
}

Some files were not shown because too many files have changed in this diff Show More