Merge remote-tracking branch 'origin/master'
commit
8f24a79444
|
|
@ -7,3 +7,4 @@ pnpm-debug
|
|||
auto-*.d.ts
|
||||
.idea
|
||||
.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 |
|
|
@ -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",
|
||||
"editor.tabSize": 2,
|
||||
"prettier.printWidth": 100, // 超过最大值换行
|
||||
|
|
@ -83,54 +85,75 @@
|
|||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.fixAll.stylelint": "explicit"
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"[vue]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.fixAll.stylelint": "explicit"
|
||||
}
|
||||
},
|
||||
"i18n-ally.localesPaths": ["src/locales"],
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"i18n-ally.sortKeys": true,
|
||||
"i18n-ally.namespace": false,
|
||||
"i18n-ally.namespace": true,
|
||||
"i18n-ally.enabledParsers": ["ts"],
|
||||
"i18n-ally.sourceLanguage": "en",
|
||||
"i18n-ally.displayLanguage": "zh-CN",
|
||||
"i18n-ally.enabledFrameworks": ["vue", "react"],
|
||||
"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",
|
||||
"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,
|
||||
|
|
@ -139,8 +162,7 @@
|
|||
"*.ts": "$(capture).test.ts, $(capture).test.tsx",
|
||||
"*.tsx": "$(capture).test.ts, $(capture).test.tsx",
|
||||
"*.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,
|
||||
"nuxt.isNuxtApp": false
|
||||
"terminal.integrated.scrollback": 10000
|
||||
}
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -284,6 +284,19 @@
|
|||
|
||||

|
||||
|
||||
### IM 即时通讯
|
||||
|
||||
演示地址(Vue3 + Element Plus):<http://dashboard-vue3.yudao.iocoder.cn>
|
||||
|
||||
使用文档:<https://doc.iocoder.cn/im-preview/>
|
||||
|
||||
|
||||

|
||||
|
||||
| 聊天界面 | 聊天管理 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
## 🐷 演示图
|
||||
|
||||
### 系统功能
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "yudao-ui-admin-vue3",
|
||||
"version": "2026.04-snapshot",
|
||||
"version": "2026.05-snapshot",
|
||||
"description": "基于vue3、vite4、element-plus、typesScript",
|
||||
"author": "xingyu",
|
||||
"private": false,
|
||||
|
|
@ -56,6 +56,7 @@
|
|||
"jsbarcode": "^3.12.3",
|
||||
"jsencrypt": "^3.5.4",
|
||||
"jsoneditor": "^10.4.3",
|
||||
"livekit-client": "^2.18.9",
|
||||
"lodash-es": "^4.18.1",
|
||||
"markdown-it": "^14.1.1",
|
||||
"markmap-common": "^0.18.9",
|
||||
|
|
|
|||
482
pnpm-lock.yaml
482
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
|
@ -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 } })
|
||||
}
|
||||
|
|
@ -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' })
|
||||
}
|
||||
|
|
@ -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 } })
|
||||
}
|
||||
|
|
@ -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 } })
|
||||
}
|
||||
|
||||
|
|
@ -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 }
|
||||
})
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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 }
|
||||
})
|
||||
}
|
||||
|
|
@ -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' })
|
||||
}
|
||||
|
|
@ -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 } })
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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(',') }
|
||||
})
|
||||
}
|
||||
|
|
@ -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(',') }
|
||||
})
|
||||
}
|
||||
|
|
@ -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 } })
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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 } })
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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 同长度;后端对找不到 / 已删除的成员返回 null,UI 用 `?.[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 } })
|
||||
}
|
||||
|
|
@ -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 } })
|
||||
}
|
||||
|
|
@ -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 } })
|
||||
}
|
||||
|
|
@ -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(',') }
|
||||
})
|
||||
}
|
||||
|
|
@ -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' })
|
||||
}
|
||||
|
|
@ -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 }
|
||||
})
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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 }
|
||||
})
|
||||
}
|
||||
|
|
@ -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[]
|
||||
}
|
||||
|
||||
// 通话会话 VO;create / 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 }
|
||||
})
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ export interface DeptVO {
|
|||
phone: string
|
||||
email: string
|
||||
createTime: Date
|
||||
children?: DeptVO[]
|
||||
}
|
||||
|
||||
// 查询部门(精简)列表
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export interface UserVO {
|
|||
username: string
|
||||
nickname: string
|
||||
deptId: number
|
||||
deptName?: string
|
||||
postIds: string[]
|
||||
email: string
|
||||
mobile: string
|
||||
|
|
@ -15,7 +16,24 @@ export interface UserVO {
|
|||
remark: string
|
||||
loginDate: 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 })
|
||||
}
|
||||
|
||||
// 获取用户精简信息列表
|
||||
export const getSimpleUserList = (): Promise<UserVO[]> => {
|
||||
return request.get({ url: '/system/user/simple-list' })
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -2,7 +2,7 @@ import request from '@/config/axios'
|
|||
import { isEmpty } from '@/utils/is'
|
||||
import { ApiSelectProps } from '@/components/FormCreate/src/type'
|
||||
import { jsonParse } from '@/utils'
|
||||
import { useUserStoreWithOut } from '@/store/modules/user'
|
||||
import { getCurrentUserId } from '@/utils/auth'
|
||||
|
||||
export const useApiSelect = (option: ApiSelectProps) => {
|
||||
return defineComponent({
|
||||
|
|
@ -99,9 +99,7 @@ export const useApiSelect = (option: ApiSelectProps) => {
|
|||
}
|
||||
|
||||
// 获取当前用户 ID
|
||||
const userStore = useUserStoreWithOut()
|
||||
const user = userStore.getUser
|
||||
const currentUserId = user?.id
|
||||
const currentUserId = getCurrentUserId()
|
||||
if (currentUserId) {
|
||||
// 根据多选/单选模式设置默认值
|
||||
const defaultValue = props.multiple ? [currentUserId] : currentUserId
|
||||
|
|
|
|||
|
|
@ -89,6 +89,22 @@ onMounted(() => {
|
|||
</div>
|
||||
</template>
|
||||
<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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="tsx">
|
||||
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 { UserInfo } from '@/layout/components/UserInfo'
|
||||
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 { useAppStore } from '@/store/modules/app'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
import { Icon } from '@/components/Icon'
|
||||
import { checkPermi } from '@/utils/permission'
|
||||
|
||||
const { getPrefixCls, variables } = useDesign()
|
||||
|
|
@ -48,6 +50,14 @@ const hasTenantVisitPermission = computed(
|
|||
() => 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({
|
||||
name: 'ToolHeader',
|
||||
setup() {
|
||||
|
|
@ -73,7 +83,9 @@ export default defineComponent({
|
|||
{screenfull.value ? (
|
||||
<Screenfull class="custom-hover" color="var(--top-header-text-color)"></Screenfull>
|
||||
) : 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 ? (
|
||||
<SizeDropdown class="custom-hover" color="var(--top-header-text-color)"></SizeDropdown>
|
||||
) : undefined}
|
||||
|
|
@ -86,6 +98,10 @@ export default defineComponent({
|
|||
{message.value ? (
|
||||
<Message class="custom-hover" color="var(--top-header-text-color)"></Message>
|
||||
) : 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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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: '通讯录' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
tenantName: string
|
||||
username: string
|
||||
|
|
|
|||
|
|
@ -64,6 +64,15 @@ export const SystemUserSocialTypeEnum = {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户性别枚举(对齐后端 system_user_sex 字典)
|
||||
*/
|
||||
export const SystemUserSexEnum = {
|
||||
UNKNOWN: 0, // 未知
|
||||
MALE: 1, // 男
|
||||
FEMALE: 2 // 女
|
||||
}
|
||||
|
||||
// ========== INFRA 模块 ==========
|
||||
/**
|
||||
* 代码生成模板类型
|
||||
|
|
|
|||
|
|
@ -331,5 +331,25 @@ export enum DICT_TYPE {
|
|||
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_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=外链
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}
|
||||
return text.replace(/[&<>"']/g, (char) => map[char])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 格式
|
||||
*
|
||||
|
|
|
|||
|
|
@ -534,3 +534,15 @@ export const subString = (str: string, start: number, end: number) => {
|
|||
}
|
||||
return str
|
||||
}
|
||||
|
||||
/** HTML 转义函数,防止 XSS */
|
||||
export const escapeHtml = (text: string): string => {
|
||||
const map: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}
|
||||
return text.replace(/[&<>"']/g, (char) => map[char])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
@ -284,7 +284,7 @@ import * as FormApi from '@/api/bpm/form'
|
|||
import { setConfAndFields2 } from '@/utils/formCreate'
|
||||
import { BpmModelFormType } from '@/utils/constants'
|
||||
import { checkPermi } from '@/utils/permission'
|
||||
import { useUserStoreWithOut } from '@/store/modules/user'
|
||||
import { getCurrentUserId } from '@/utils/auth'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
import { cloneDeep, isEqual } from 'lodash-es'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
|
|
@ -333,7 +333,6 @@ const emit = defineEmits(['success'])
|
|||
const message = useMessage() // 消息弹窗
|
||||
const { t } = useI18n() // 国际化
|
||||
const { push } = useRouter() // 路由
|
||||
const userStore = useUserStoreWithOut() // 用户信息缓存
|
||||
const isDark = computed(() => useAppStore().getIsDark) // 是否黑暗模式
|
||||
const router = useRouter() // 路由
|
||||
|
||||
|
|
@ -501,7 +500,7 @@ const handleFormDetail = async (row: any) => {
|
|||
|
||||
/** 判断是否可以操作 */
|
||||
const isManagerUser = (row: any) => {
|
||||
const userId = userStore.getUser.id
|
||||
const userId = getCurrentUserId()
|
||||
return row.managerUserIds && row.managerUserIds.includes(userId)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@
|
|||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useMessage } from '@/hooks/web/useMessage'
|
||||
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 FormApi from '@/api/bpm/form'
|
||||
import { CategoryApi, CategoryVO } from '@/api/bpm/category'
|
||||
|
|
@ -107,7 +107,6 @@ const { delView } = useTagsViewStore() // 视图操作
|
|||
const tagsView = useTagsView()
|
||||
const route = useRoute()
|
||||
const message = useMessage()
|
||||
const userStore = useUserStoreWithOut()
|
||||
|
||||
// 组件引用
|
||||
const basicInfoRef = ref()
|
||||
|
|
@ -237,7 +236,7 @@ const initData = async () => {
|
|||
} else {
|
||||
// 情况三:新增场景
|
||||
formData.value.startUserType = 0 // 全体
|
||||
formData.value.managerUserIds.push(userStore.getUser.id)
|
||||
formData.value.managerUserIds.push(getCurrentUserId())
|
||||
}
|
||||
|
||||
// 获取表单列表
|
||||
|
|
|
|||
|
|
@ -513,7 +513,7 @@
|
|||
<SignDialog ref="signRef" @success="handleSignFinish" />
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { useUserStoreWithOut } from '@/store/modules/user'
|
||||
import { getCurrentUserId } from '@/utils/auth'
|
||||
import { setConfAndFields2 } from '@/utils/formCreate'
|
||||
import * as TaskApi from '@/api/bpm/task'
|
||||
import * as ProcessInstanceApi from '@/api/bpm/processInstance'
|
||||
|
|
@ -536,7 +536,7 @@ defineOptions({ name: 'ProcessInstanceBtnContainer' })
|
|||
const router = useRouter() // 路由
|
||||
const message = useMessage() // 消息弹窗
|
||||
|
||||
const userId = useUserStoreWithOut().getUser.id // 当前登录的编号
|
||||
const userId = getCurrentUserId() // 当前登录的编号
|
||||
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ import * as BusinessApi from '@/api/crm/business'
|
|||
import * as BusinessStatusApi from '@/api/crm/business/status'
|
||||
import * as CustomerApi from '@/api/crm/customer'
|
||||
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 { erpPriceMultiply, erpPriceInputFormatter } from '@/utils'
|
||||
|
||||
|
|
@ -233,7 +233,7 @@ const open = async (type: string, id?: number, customerId?: number, contactId?:
|
|||
userOptions.value = await UserApi.getSimpleUserList()
|
||||
// 默认新建时选中自己
|
||||
if (formType.value === 'create') {
|
||||
formData.value.ownerUserId = useUserStore().getUser.id
|
||||
formData.value.ownerUserId = getCurrentUserId()
|
||||
}
|
||||
}
|
||||
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ import * as ClueApi from '@/api/crm/clue'
|
|||
import * as AreaApi from '@/api/system/area'
|
||||
import { defaultProps } from '@/utils/tree'
|
||||
import * as UserApi from '@/api/system/user'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { getCurrentUserId } from '@/utils/auth'
|
||||
|
||||
const { t } = useI18n() // 国际化
|
||||
const message = useMessage() // 消息弹窗
|
||||
|
|
@ -205,7 +205,7 @@ const open = async (type: string, id?: number) => {
|
|||
userOptions.value = await UserApi.getSimpleUserList()
|
||||
// 默认新建时选中自己
|
||||
if (formType.value === 'create') {
|
||||
formData.value.ownerUserId = useUserStore().getUser.id
|
||||
formData.value.ownerUserId = getCurrentUserId()
|
||||
}
|
||||
}
|
||||
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ import * as UserApi from '@/api/system/user'
|
|||
import * as CustomerApi from '@/api/crm/customer'
|
||||
import * as AreaApi from '@/api/system/area'
|
||||
import { defaultProps } from '@/utils/tree'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { getCurrentUserId } from '@/utils/auth'
|
||||
|
||||
const { t } = useI18n() // 国际化
|
||||
const message = useMessage() // 消息弹窗
|
||||
|
|
@ -252,7 +252,7 @@ const open = async (type: string, id?: number, customerId?: number, businessId?:
|
|||
userOptions.value = await UserApi.getSimpleUserList()
|
||||
// 默认新建时选中自己
|
||||
if (formType.value === 'create') {
|
||||
formData.value.ownerUserId = useUserStore().getUser.id
|
||||
formData.value.ownerUserId = getCurrentUserId()
|
||||
}
|
||||
}
|
||||
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ import * as UserApi from '@/api/system/user'
|
|||
import * as ContactApi from '@/api/crm/contact'
|
||||
import * as BusinessApi from '@/api/crm/business'
|
||||
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'
|
||||
|
||||
const { t } = useI18n() // 国际化
|
||||
|
|
@ -284,7 +284,7 @@ const open = async (type: string, id?: number) => {
|
|||
userOptions.value = await UserApi.getSimpleUserList()
|
||||
// 默认新建时选中自己
|
||||
if (formType.value === 'create') {
|
||||
formData.value.ownerUserId = useUserStore().getUser.id
|
||||
formData.value.ownerUserId = getCurrentUserId()
|
||||
}
|
||||
// 获取联系人
|
||||
contactList.value = await ContactApi.getSimpleContactList()
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ import * as CustomerApi from '@/api/crm/customer'
|
|||
import * as AreaApi from '@/api/system/area'
|
||||
import { defaultProps } from '@/utils/tree'
|
||||
import * as UserApi from '@/api/system/user'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { getCurrentUserId } from '@/utils/auth'
|
||||
|
||||
const { t } = useI18n() // 国际化
|
||||
const message = useMessage() // 消息弹窗
|
||||
|
|
@ -205,7 +205,7 @@ const open = async (type: string, id?: number) => {
|
|||
userOptions.value = await UserApi.getSimpleUserList()
|
||||
// 默认新建时选中自己
|
||||
if (formType.value === 'create') {
|
||||
formData.value.ownerUserId = useUserStore().getUser.id
|
||||
formData.value.ownerUserId = getCurrentUserId()
|
||||
}
|
||||
}
|
||||
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ import * as CustomerApi from '@/api/crm/customer'
|
|||
import download from '@/utils/download'
|
||||
import type { UploadUserFile } from 'element-plus'
|
||||
import * as UserApi from '@/api/system/user'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { getCurrentUserId } from '@/utils/auth'
|
||||
|
||||
defineOptions({ name: 'CrmCustomerImportForm' })
|
||||
|
||||
|
|
@ -74,7 +74,7 @@ const open = async () => {
|
|||
await resetForm()
|
||||
// 获得用户列表
|
||||
userOptions.value = await UserApi.getSimpleUserList()
|
||||
ownerUserId.value = useUserStore().getUser.id
|
||||
ownerUserId.value = getCurrentUserId()
|
||||
}
|
||||
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@
|
|||
import { dateFormatter } from '@/utils/formatTime'
|
||||
import { ElTable } from 'element-plus'
|
||||
import * as PermissionApi from '@/api/crm/permission'
|
||||
import { useUserStoreWithOut } from '@/store/modules/user'
|
||||
import { getCurrentUserId } from '@/utils/auth'
|
||||
import CrmPermissionForm from './PermissionForm.vue'
|
||||
import { DICT_TYPE } from '@/utils/dict'
|
||||
|
||||
|
|
@ -64,7 +64,6 @@ const list = ref<PermissionApi.PermissionVO[]>([]) // 列表的数据
|
|||
const formData = ref({
|
||||
ownerUserId: 0
|
||||
})
|
||||
const userStore = useUserStoreWithOut() // 用户信息缓存
|
||||
|
||||
/** 查询列表 */
|
||||
const getList = async () => {
|
||||
|
|
@ -75,13 +74,14 @@ const getList = async () => {
|
|||
bizId: props.bizId
|
||||
})
|
||||
list.value = data
|
||||
const currentUserId = getCurrentUserId()
|
||||
const permission = list.value.find(
|
||||
(item) =>
|
||||
item.userId === userStore.getUser.id &&
|
||||
item.userId === currentUserId &&
|
||||
item.level === PermissionApi.PermissionLevelEnum.OWNER
|
||||
)
|
||||
if (permission) {
|
||||
formData.value.ownerUserId = userStore.getUser.id
|
||||
formData.value.ownerUserId = currentUserId
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
|
@ -150,7 +150,7 @@ watch(
|
|||
)
|
||||
validateOwnerUser.value = false
|
||||
validateWrite.value = false
|
||||
const userId = userStore.getUser?.id
|
||||
const userId = getCurrentUserId()
|
||||
list.value
|
||||
.filter((item) => item.userId === userId)
|
||||
.forEach((item) => {
|
||||
|
|
@ -176,15 +176,16 @@ const emits = defineEmits<{
|
|||
}>()
|
||||
/** 退出团队 */
|
||||
const handleQuit = async () => {
|
||||
const currentUserId = getCurrentUserId()
|
||||
const permission = list.value.find(
|
||||
(item) =>
|
||||
item.userId === userStore.getUser.id && item.level === PermissionApi.PermissionLevelEnum.OWNER
|
||||
item.userId === currentUserId && item.level === PermissionApi.PermissionLevelEnum.OWNER
|
||||
)
|
||||
if (permission) {
|
||||
message.warning('负责人不能退出团队!')
|
||||
return
|
||||
}
|
||||
const userPermission = list.value.find((item) => item.userId === userStore.getUser.id)
|
||||
const userPermission = list.value.find((item) => item.userId === currentUserId)
|
||||
if (!userPermission) {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ import * as ProductApi from '@/api/crm/product'
|
|||
import * as ProductCategoryApi from '@/api/crm/product/category'
|
||||
import { defaultProps, handleTree } from '@/utils/tree'
|
||||
import { getSimpleUserList, UserVO } from '@/api/system/user'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { getCurrentUserId } from '@/utils/auth'
|
||||
|
||||
defineOptions({ name: 'CrmProductForm' })
|
||||
|
||||
|
|
@ -114,7 +114,7 @@ const dialogVisible = ref(false) // 弹窗的是否展示
|
|||
const dialogTitle = ref('') // 弹窗的标题
|
||||
const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
|
||||
const formType = ref('') // 表单的类型:create - 新增;update - 修改
|
||||
const userId = useUserStore().getUser.id // 当前登录的编号
|
||||
const userId = getCurrentUserId() // 当前登录的编号
|
||||
const formData = ref({
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ import { ReceivableVO } from '@/api/crm/receivable'
|
|||
import * as UserApi from '@/api/system/user'
|
||||
import * as CustomerApi from '@/api/crm/customer'
|
||||
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'
|
||||
|
||||
const { t } = useI18n() // 国际化
|
||||
|
|
@ -200,7 +200,7 @@ const open = async (
|
|||
customerList.value = await CustomerApi.getCustomerSimpleList()
|
||||
// 默认新建时选中自己
|
||||
if (formType.value === 'create') {
|
||||
formData.value.ownerUserId = useUserStore().getUser.id
|
||||
formData.value.ownerUserId = getCurrentUserId()
|
||||
}
|
||||
// 从回款计划创建回款
|
||||
if (receivablePlan) {
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ import * as ReceivablePlanApi from '@/api/crm/receivable/plan'
|
|||
import * as UserApi from '@/api/system/user'
|
||||
import * as CustomerApi from '@/api/crm/customer'
|
||||
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 { cloneDeep } from 'lodash-es'
|
||||
|
||||
|
|
@ -182,7 +182,7 @@ const open = async (type: string, id?: number, customerId?: number, contractId?:
|
|||
customerList.value = await CustomerApi.getCustomerSimpleList()
|
||||
// 默认新建时选中自己
|
||||
if (formType.value === 'create') {
|
||||
formData.value.ownerUserId = useUserStore().getUser.id
|
||||
formData.value.ownerUserId = getCurrentUserId()
|
||||
}
|
||||
// 设置 customerId 和 contractId 默认值
|
||||
if (customerId) {
|
||||
|
|
|
|||
|
|
@ -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 = 9px(my-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>
|
||||
|
|
@ -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 的 :key:caller 传 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
<template>
|
||||
<!--
|
||||
ToolBar:IM 左侧工具栏
|
||||
布局:顶部头像 → 中间三 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
<template>
|
||||
<!--
|
||||
设置群管理员:一个弹窗合并增 / 删,提交时跟当前管理员列表 diff
|
||||
- dialog 壳本组件持有;选择 UI 委托 GroupMemberPickerPanel(grid 形态对齐当前视觉)
|
||||
- 群主从候选里隐藏(不能设为管理员)
|
||||
- 对外接口: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>
|
||||
|
|
@ -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 拼图并写回 mergedUrl;mergeToken 校验避免老 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 渲染到 body,scoped 选不到,留非 scoped 全局覆盖 */
|
||||
.im-group-request-list__dialog .el-dialog__body {
|
||||
padding: 12px 20px 8px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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[]
|
||||
/** 已选会话 key(v-model);key 由 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>
|
||||
|
|
@ -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 default(32px),保证两侧第一项起点同水平 -->
|
||||
<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[]
|
||||
/** 已选好友 id(v-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))
|
||||
|
||||
/** 候选好友:剔除 hideIds(hide 优先级最高) */
|
||||
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>
|
||||
|
|
@ -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 default(32px) -->
|
||||
<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[]
|
||||
/** 已选 userId(v-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>
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 拿 token;rtcStore 按 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>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认发送(会话视图):每个选中会话先发 CARD,CARD 成功后才发留言(保证「先看到名片」的顺序意图)
|
||||
*
|
||||
* 文案聚合:全部成功「已转发」、全部失败「转发失败:A、B」、部分失败「已转发,但 X、Y 失败」;
|
||||
* 失败的消息以 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>
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
<template>
|
||||
<!--
|
||||
通用用户头像组件
|
||||
- 有 url 时展示图片;无 url 时展示色卡 + 首字母/首字
|
||||
- 点击默认触发 UserInfoCard(clickable)
|
||||
- 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>
|
||||
|
|
@ -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 图标;陌生人 = 加为好友按钮;自己 = disabled;readonly 不渲染 -->
|
||||
<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} 功能开发中`)
|
||||
}
|
||||
|
||||
// ==================== 添加好友 / 删除好友 ====================
|
||||
|
||||
/** 加好友弹窗 ref:handleAddFriend 调 open({ presetUser, addSource, addSourceExtra }) 触发 */
|
||||
const friendAddDialogRef = ref<InstanceType<typeof FriendAddDialog>>()
|
||||
|
||||
/** 推荐名片弹窗 ref:handleRecommend 调用 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>
|
||||
/*
|
||||
非 scoped:el-dropdown 的下拉菜单走 teleport 到 body,scoped 选不到。
|
||||
UserInfoCard 浮层用 z-9998,要把这块抬到更高(默认 --el-popper-z-index ~2050 会被遮罩压住)。
|
||||
*/
|
||||
.im-user-info__more-menu {
|
||||
z-index: 10000 !important;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
Loading…
Reference in New Issue