diff --git a/.gitignore b/.gitignore index 848638a96..9fc0edc7f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ pnpm-debug auto-*.d.ts .idea .history +output/ diff --git a/.vscode/settings.json b/.vscode/settings.json index 74ab52a74..6856b5d10 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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 } diff --git a/package.json b/package.json index e23ff1c8d..c78fc4456 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api/im/channel/material/index.ts b/src/api/im/channel/material/index.ts new file mode 100644 index 000000000..a01ed7e50 --- /dev/null +++ b/src/api/im/channel/material/index.ts @@ -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({ url: '/im/channel/material/get', params: { id } }) +} diff --git a/src/api/im/face/pack/index.ts b/src/api/im/face/pack/index.ts new file mode 100644 index 000000000..713fa580d --- /dev/null +++ b/src/api/im/face/pack/index.ts @@ -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({ url: '/im/face-pack/list' }) +} diff --git a/src/api/im/face/useritem/index.ts b/src/api/im/face/useritem/index.ts new file mode 100644 index 000000000..ab3599689 --- /dev/null +++ b/src/api/im/face/useritem/index.ts @@ -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({ url: '/im/face-user-item/list' }) +} + +// 添加个人表情 +export const createFaceUserItem = (data: ImFaceUserItemSaveReqVO) => { + return request.post({ url: '/im/face-user-item/create', data }) +} + +// 删除个人表情 +export const deleteFaceUserItem = (id: number) => { + return request.delete({ url: '/im/face-user-item/delete', params: { id } }) +} diff --git a/src/api/im/friend/index.ts b/src/api/im/friend/index.ts new file mode 100644 index 000000000..c16e478e8 --- /dev/null +++ b/src/api/im/friend/index.ts @@ -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({ url: '/im/friend/list' }) +} + +// 获得好友详情 +export const getFriend = (friendUserId: number | string) => { + return request.get({ url: '/im/friend/get', params: { friendUserId } }) +} + +// 删除好友(单向软删除) +export const deleteFriend = (friendUserId: number | string, clear: boolean) => { + return request.delete({ + url: '/im/friend/delete', + params: { friendUserId, clear } + }) +} + +// 更新好友信息(备注 / 免打扰 / 联系人置顶) +export const updateFriend = (data: ImFriendUpdateReqVO) => { + return request.put({ url: '/im/friend/update', data }) +} + +// 拉黑好友(必须先是好友;单边屏蔽对方私聊消息) +export const blockFriend = (friendUserId: number | string) => { + return request.put({ url: '/im/friend/block', params: { friendUserId } }) +} + +// 移出黑名单 +export const unblockFriend = (friendUserId: number | string) => { + return request.put({ url: '/im/friend/unblock', params: { friendUserId } }) +} + diff --git a/src/api/im/friend/request/index.ts b/src/api/im/friend/request/index.ts new file mode 100644 index 000000000..63072b57e --- /dev/null +++ b/src/api/im/friend/request/index.ts @@ -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({ url: '/im/friend-request/apply', data }) +} + +// 同意好友申请 +export const agreeFriendRequest = (id: number | string) => { + return request.put({ url: '/im/friend-request/agree', params: { id } }) +} + +// 拒绝好友申请 +export const refuseFriendRequest = (id: number | string, handleContent?: string) => { + return request.put({ + url: '/im/friend-request/refuse', + params: { id, handleContent } + }) +} + +// 查询「我相关」的好友申请列表(游标分页:传 maxId 加载更多) +export const getMyFriendRequestList = (limit: number, maxId?: number) => { + const params: Record = { limit } + if (maxId != null) { + params.maxId = maxId + } + return request.get({ + url: '/im/friend-request/list', + params + }) +} + +// 按 id 单查「我相关」的申请记录(带越权过滤;WebSocket 通知到达后用) +export const getMyFriendRequest = (id: number) => { + return request.get({ + url: '/im/friend-request/get', + params: { id } + }) +} diff --git a/src/api/im/group/index.ts b/src/api/im/group/index.ts new file mode 100644 index 000000000..6aefb7fbd --- /dev/null +++ b/src/api/im/group/index.ts @@ -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({ url: '/im/group/list' }) +} + +// 获得群详情 +export const getGroup = (id: number | string) => { + return request.get({ url: '/im/group/get', params: { id } }) +} + +// 创建群 +export const createGroup = (data: ImGroupCreateReqVO) => { + return request.post({ url: '/im/group/create', data }) +} + +// 更新群 +export const updateGroup = (data: ImGroupUpdateReqVO) => { + return request.put({ url: '/im/group/update', data }) +} + +// 解散群 +export const dissolveGroup = (id: number | string) => { + return request.delete({ url: '/im/group/dissolve', params: { id } }) +} + +// 添加群管理员(仅群主可调) +export const addGroupAdmin = (data: ImGroupAdminReqVO) => { + return request.put({ url: '/im/group/add-admin', data }) +} + +// 撤销群管理员(仅群主可调) +export const removeGroupAdmin = (data: ImGroupAdminReqVO) => { + return request.put({ url: '/im/group/remove-admin', data }) +} + +// 转让群主(仅老群主可调;旧群主转让后降为普通成员) +export const transferGroupOwner = (data: ImGroupTransferOwnerReqVO) => { + return request.put({ url: '/im/group/transfer-owner', data }) +} + +// 置顶群消息(仅群主 / 管理员可调) +export const pinGroupMessage = (data: ImGroupMessagePinReqVO) => { + return request.put({ url: '/im/group/pin-message', data }) +} + +// 取消置顶群消息(仅群主 / 管理员可调) +export const unpinGroupMessage = (data: ImGroupMessagePinReqVO) => { + return request.put({ url: '/im/group/unpin-message', data }) +} + +// 全群禁言 / 取消(仅群主 / 管理员可调) +export const muteAll = (data: ImGroupMuteAllReqVO) => { + return request.put({ url: '/im/group/mute-all', data }) +} + +// 禁言成员 +export const muteMember = (data: ImGroupMuteMemberReqVO) => { + return request.put({ url: '/im/group/mute-member', data }) +} + +// 取消成员禁言 +export const cancelMuteMember = (data: ImGroupCancelMuteMemberReqVO) => { + return request.put({ url: '/im/group/cancel-mute-member', data }) +} diff --git a/src/api/im/group/member/index.ts b/src/api/im/group/member/index.ts new file mode 100644 index 000000000..5ca9be4a6 --- /dev/null +++ b/src/api/im/group/member/index.ts @@ -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({ url: '/im/group/invite', data }) +} + +// 退出群 +export const quitGroup = (groupId: number | string) => { + return request.delete({ url: '/im/group/quit', params: { groupId } }) +} + +// 移除群成员 +export const removeGroupMember = (data: ImGroupMemberRemoveReqVO) => { + return request.delete({ url: '/im/group/kicking', data }) +} + +// 获得群成员详情 +export const getGroupMember = (groupId: number, userId: number) => { + return request.get({ + url: '/im/group-member/get', + params: { groupId, userId } + }) +} + +// 获得指定群的成员列表(聚合 AdminUser 昵称 / 头像) +export const getGroupMemberList = (groupId: number | string) => { + return request.get({ + url: '/im/group-member/list', + params: { groupId } + }) +} + +// 更新群成员 +export const updateGroupMember = (data: ImGroupMemberUpdateReqVO) => { + return request.put({ url: '/im/group-member/update', data }) +} diff --git a/src/api/im/group/request/index.ts b/src/api/im/group/request/index.ts new file mode 100644 index 000000000..14aa41a50 --- /dev/null +++ b/src/api/im/group/request/index.ts @@ -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({ url: '/im/group-request/apply', data }) +} + +// 同意加群申请(群主或管理员) +export const agreeGroupRequest = (id: number | string) => { + return request.put({ url: '/im/group-request/agree', params: { id } }) +} + +// 拒绝加群申请(群主或管理员) +export const refuseGroupRequest = (id: number | string, handleContent?: string) => { + return request.put({ + url: '/im/group-request/refuse', + params: { id, handleContent } + }) +} + +// 查询「我管理的所有群」下的未处理加群申请列表(不分页);前端 store 据此派生横幅红点 + Drawer 列表 +export const getUnhandledRequestList = () => { + return request.get({ + url: '/im/group-request/unhandled-list' + }) +} + +// 查询指定群下的全部加群申请(含已处理);仅群主 / 管理员可查 +export const getGroupRequestListByGroupId = (groupId: number) => { + return request.get({ + url: '/im/group-request/list-by-group', + params: { groupId } + }) +} + +// 按 id 单查申请记录(带越权过滤;WebSocket 通知到达后用) +export const getMyGroupRequest = (id: number) => { + return request.get({ + url: '/im/group-request/get', + params: { id } + }) +} diff --git a/src/api/im/manager/channel/index.ts b/src/api/im/manager/channel/index.ts new file mode 100644 index 000000000..86c0434ca --- /dev/null +++ b/src/api/im/manager/channel/index.ts @@ -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({ url: '/im/manager/channel/simple-list' }) +} diff --git a/src/api/im/manager/channel/material/index.ts b/src/api/im/manager/channel/material/index.ts new file mode 100644 index 000000000..ef207ee7e --- /dev/null +++ b/src/api/im/manager/channel/material/index.ts @@ -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 } }) +} diff --git a/src/api/im/manager/channel/message/index.ts b/src/api/im/manager/channel/message/index.ts new file mode 100644 index 000000000..91c30beb8 --- /dev/null +++ b/src/api/im/manager/channel/message/index.ts @@ -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 }) +} diff --git a/src/api/im/manager/face/item/index.ts b/src/api/im/manager/face/item/index.ts new file mode 100644 index 000000000..7609e9fa4 --- /dev/null +++ b/src/api/im/manager/face/item/index.ts @@ -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(',') } + }) +} diff --git a/src/api/im/manager/face/pack/index.ts b/src/api/im/manager/face/pack/index.ts new file mode 100644 index 000000000..61a7c0175 --- /dev/null +++ b/src/api/im/manager/face/pack/index.ts @@ -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(',') } + }) +} diff --git a/src/api/im/manager/face/userItem/index.ts b/src/api/im/manager/face/userItem/index.ts new file mode 100644 index 000000000..becc8cf68 --- /dev/null +++ b/src/api/im/manager/face/userItem/index.ts @@ -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 } }) +} diff --git a/src/api/im/manager/friend/index.ts b/src/api/im/manager/friend/index.ts new file mode 100644 index 000000000..d8c4423b9 --- /dev/null +++ b/src/api/im/manager/friend/index.ts @@ -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 }) +} diff --git a/src/api/im/manager/friend/request/index.ts b/src/api/im/manager/friend/request/index.ts new file mode 100644 index 000000000..a6cf7e672 --- /dev/null +++ b/src/api/im/manager/friend/request/index.ts @@ -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 }) +} diff --git a/src/api/im/manager/group/index.ts b/src/api/im/manager/group/index.ts new file mode 100644 index 000000000..20114a8b8 --- /dev/null +++ b/src/api/im/manager/group/index.ts @@ -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 } }) +} diff --git a/src/api/im/manager/group/request/index.ts b/src/api/im/manager/group/request/index.ts new file mode 100644 index 000000000..a58bdeb56 --- /dev/null +++ b/src/api/im/manager/group/request/index.ts @@ -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 }) +} diff --git a/src/api/im/manager/message/group/index.ts b/src/api/im/manager/message/group/index.ts new file mode 100644 index 000000000..d56ed13c5 --- /dev/null +++ b/src/api/im/manager/message/group/index.ts @@ -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 } }) +} diff --git a/src/api/im/manager/message/private/index.ts b/src/api/im/manager/message/private/index.ts new file mode 100644 index 000000000..3ccf70b79 --- /dev/null +++ b/src/api/im/manager/message/private/index.ts @@ -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 } }) +} diff --git a/src/api/im/manager/rtc/index.ts b/src/api/im/manager/rtc/index.ts new file mode 100644 index 000000000..d5c4cc7a3 --- /dev/null +++ b/src/api/im/manager/rtc/index.ts @@ -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 } }) +} diff --git a/src/api/im/manager/sensitiveword/index.ts b/src/api/im/manager/sensitiveword/index.ts new file mode 100644 index 000000000..165bc76db --- /dev/null +++ b/src/api/im/manager/sensitiveword/index.ts @@ -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(',') } + }) +} diff --git a/src/api/im/manager/statistics/index.ts b/src/api/im/manager/statistics/index.ts new file mode 100644 index 000000000..10d10fe5e --- /dev/null +++ b/src/api/im/manager/statistics/index.ts @@ -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 +} + +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 => { + return request.get({ url: '/im/manager/statistics/overview' }) +} + +// 获得消息趋势(私聊 + 群聊双线) +export const getMessageTrend = (days: number): Promise => { + return request.get({ url: '/im/manager/statistics/message-trend', params: { days } }) +} + +// 获得用户趋势(新增注册 + 日活双线) +export const getUserTrend = (days: number): Promise => { + return request.get({ url: '/im/manager/statistics/user-trend', params: { days } }) +} + +// 获得消息类型分布(最近 30 天) +export const getMessageTypeDistribution = (): Promise => { + return request.get({ + url: '/im/manager/statistics/message-type-distribution' + }) +} + +// 获得群规模分布 +export const getGroupSizeDistribution = (): Promise => { + return request.get({ + url: '/im/manager/statistics/group-size-distribution' + }) +} + +// 获得消息 TOP 发送者(最近 30 天) +export const getTopSenders = (): Promise => { + return request.get({ url: '/im/manager/statistics/top-senders' }) +} diff --git a/src/api/im/message/channel/index.ts b/src/api/im/message/channel/index.ts new file mode 100644 index 000000000..0760a0670 --- /dev/null +++ b/src/api/im/message/channel/index.ts @@ -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({ + 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 } + }) +} diff --git a/src/api/im/message/group/index.ts b/src/api/im/message/group/index.ts new file mode 100644 index 000000000..e8988e588 --- /dev/null +++ b/src/api/im/message/group/index.ts @@ -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({ url: '/im/message/group/send', data }) +} + +// 拉取群聊消息(增量) +export const pullGroupMessages = ( + params: { minId: number | string; size: number }, + signal?: AbortSignal +) => { + return request.get({ url: '/im/message/group/pull', params, signal }) +} + +// 查询群聊历史消息 +export const getGroupMessageList = (params: ImGroupMessageListReqVO) => { + return request.get({ url: '/im/message/group/list', params }) +} + +// 标记群聊消息已读 +export const readGroupMessages = (groupId: number | string, messageId: number | string) => { + return request.put({ + url: '/im/message/group/read', + params: { groupId, messageId } + }) +} + +// 撤回群聊消息 +export const recallGroupMessage = (id: number | string) => { + return request.delete({ + url: '/im/message/group/recall', + params: { id } + }) +} + +// 获取群消息已读用户列表 +export const getGroupReadUsers = (params: { + groupId: number | string + messageId: number | string +}) => { + return request.get({ url: '/im/message/group/get-read-user-ids', params }) +} diff --git a/src/api/im/message/private/index.ts b/src/api/im/message/private/index.ts new file mode 100644 index 000000000..43ee78ad6 --- /dev/null +++ b/src/api/im/message/private/index.ts @@ -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({ url: '/im/message/private/send', data }) +} + +// 拉取私聊消息(增量) +export const pullPrivateMessages = ( + params: { minId: number | string; size: number }, + signal?: AbortSignal +) => { + return request.get({ url: '/im/message/private/pull', params, signal }) +} + +// 查询私聊历史消息 +export const getPrivateMessageList = (params: ImPrivateMessageListReqVO) => { + return request.get({ url: '/im/message/private/list', params }) +} + +// 标记私聊消息已读 +export const readPrivateMessages = (receiverId: number | string, messageId: number | string) => { + return request.put({ + url: '/im/message/private/read', + params: { receiverId, messageId } + }) +} + +// 查询对方已读到我发的最大消息 id(多端 / 离线后用于补齐已读状态) +export const getPrivateMaxReadMessageId = (peerId: number | string, signal?: AbortSignal) => { + return request.get({ + url: '/im/message/private/max-read-message-id', + params: { peerId }, + signal + }) +} + +// 撤回私聊消息 +export const recallPrivateMessage = (id: number | string) => { + return request.delete({ + url: '/im/message/private/recall', + params: { id } + }) +} diff --git a/src/api/im/rtc/index.ts b/src/api/im/rtc/index.ts new file mode 100644 index 000000000..61b56c923 --- /dev/null +++ b/src/api/im/rtc/index.ts @@ -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({ url: '/im/rtc/create', data }) +} + +// 通话中追加邀请;仅群通话可用 +export const inviteCall = (data: ImRtcCallInviteReqVO) => { + return request.post({ url: '/im/rtc/invite', data }) +} + +// 加入已有群通话;用于胶囊条「加入」按钮 +export const joinCall = (room: string) => { + return request.post({ url: '/im/rtc/join', params: { room } }) +} + +// 接听通话 +export const acceptCall = (room: string) => { + return request.post({ url: '/im/rtc/accept', params: { room } }) +} + +// 拒绝通话 +export const rejectCall = (room: string) => { + return request.post({ url: '/im/rtc/reject', params: { room } }) +} + +// 取消邀请;主叫接通前调用 +export const cancelCall = (room: string) => { + return request.post({ url: '/im/rtc/cancel', params: { room } }) +} + +// 离开通话;接通后调用 +export const leaveCall = (room: string) => { + return request.post({ url: '/im/rtc/leave', params: { room } }) +} + +// 振铃超时检查;RUNNING 端 timer 兜底,触发后端立即扫描该 room 的超时 INVITING(接口静默) +export const noAnswerCallCheck = (room: string) => { + return request.post({ url: '/im/rtc/no-answer-call-check', params: { room } }) +} + +// 查询当前进行中的通话;目前仅群聊场景(胶囊条),返回 null 表示无活跃通话 +export const getActiveCall = (groupId: number) => { + return request.get({ + url: '/im/rtc/get-active-call', + params: { groupId } + }) +} diff --git a/src/api/system/dept/index.ts b/src/api/system/dept/index.ts index c0959f409..0d4baec2a 100644 --- a/src/api/system/dept/index.ts +++ b/src/api/system/dept/index.ts @@ -10,6 +10,7 @@ export interface DeptVO { phone: string email: string createTime: Date + children?: DeptVO[] } // 查询部门(精简)列表 diff --git a/src/api/system/user/index.ts b/src/api/system/user/index.ts index e92a24ae6..8747d205f 100644 --- a/src/api/system/user/index.ts +++ b/src/api/system/user/index.ts @@ -5,6 +5,7 @@ export interface UserVO { username: string nickname: string deptId: number + deptName?: string postIds: string[] email: string mobile: string @@ -15,7 +16,25 @@ export interface UserVO { remark: string loginDate: Date createTime: Date - disabled?: boolean + disabled?: boolean // TODO @AI:是不是可以去掉。。。 +} + +// 获取用户精简信息列表 +export const getSimpleUserList = (): Promise => { + return request.get({ url: '/system/user/simple-list' }) +} + +// 按用户编号查询用户精简信息(点头像弹名片) +export const getSimpleUser = (id: number | string) => { + return request.get({ url: '/system/user/get-simple', params: { id } }) +} + +// 按昵称模糊搜索用户(加好友) +export const getSimpleUserListByNickname = (nickname: string) => { + return request.get({ + url: '/system/user/list-by-nickname', + params: { nickname } + }) } // 查询用户管理列表 @@ -28,6 +47,22 @@ export const getUserList = (ids: number[]) => { return request.get({ url: '/system/user/list', params: { ids: ids.join(',') } }) } +// TODO @AI:是不是可以去掉? +// 查询所有用户列表 +export const getAllUser = () => { + return request.get({ url: '/system/user/simple-list' }) +} + +// TODO @AI:是不是可以去掉? +/** + * 获取部门成员 + * @param id + * @returns + */ +export const getDeptUser = (deptId: number): Promise => { + return request.get({ url: '/system/user/simple-list?deptId=' + deptId }) +} + // 查询用户详情 export const getUser = (id: number) => { return request.get({ url: '/system/user/get?id=' + id }) @@ -80,8 +115,3 @@ export const updateUserStatus = (id: number, status: number) => { } return request.put({ url: '/system/user/update-status', data: data }) } - -// 获取用户精简信息列表 -export const getSimpleUserList = (): Promise => { - return request.get({ url: '/system/user/simple-list' }) -} diff --git a/src/assets/audio/im/message-tip.mp3 b/src/assets/audio/im/message-tip.mp3 new file mode 100644 index 000000000..5f317dcd4 Binary files /dev/null and b/src/assets/audio/im/message-tip.mp3 differ diff --git a/src/components/FormCreate/src/components/useApiSelect.tsx b/src/components/FormCreate/src/components/useApiSelect.tsx index 847eb077c..98466c32b 100644 --- a/src/components/FormCreate/src/components/useApiSelect.tsx +++ b/src/components/FormCreate/src/components/useApiSelect.tsx @@ -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 diff --git a/src/layout/components/Message/src/Message.vue b/src/layout/components/Message/src/Message.vue index d33b3b640..d4fe8e28c 100644 --- a/src/layout/components/Message/src/Message.vue +++ b/src/layout/components/Message/src/Message.vue @@ -89,6 +89,22 @@ onMounted(() => { diff --git a/src/views/im/home/components/ToolBar.vue b/src/views/im/home/components/ToolBar.vue new file mode 100644 index 000000000..b9647465a --- /dev/null +++ b/src/views/im/home/components/ToolBar.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/src/views/im/home/components/card/CardBubble.vue b/src/views/im/home/components/card/CardBubble.vue new file mode 100644 index 000000000..b41e73fb3 --- /dev/null +++ b/src/views/im/home/components/card/CardBubble.vue @@ -0,0 +1,67 @@ + + + diff --git a/src/views/im/home/components/card/CardLineLabel.vue b/src/views/im/home/components/card/CardLineLabel.vue new file mode 100644 index 000000000..eb265f791 --- /dev/null +++ b/src/views/im/home/components/card/CardLineLabel.vue @@ -0,0 +1,28 @@ + + + diff --git a/src/views/im/home/components/friend/FriendAddDialog.vue b/src/views/im/home/components/friend/FriendAddDialog.vue new file mode 100644 index 000000000..d2b6d92fa --- /dev/null +++ b/src/views/im/home/components/friend/FriendAddDialog.vue @@ -0,0 +1,286 @@ + + + diff --git a/src/views/im/home/components/friend/FriendItem.vue b/src/views/im/home/components/friend/FriendItem.vue new file mode 100644 index 000000000..7fdd85b76 --- /dev/null +++ b/src/views/im/home/components/friend/FriendItem.vue @@ -0,0 +1,78 @@ + + + diff --git a/src/views/im/home/components/group/GroupAdminSetDialog.vue b/src/views/im/home/components/group/GroupAdminSetDialog.vue new file mode 100644 index 000000000..93f5e527d --- /dev/null +++ b/src/views/im/home/components/group/GroupAdminSetDialog.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/src/views/im/home/components/group/GroupAvatar.vue b/src/views/im/home/components/group/GroupAvatar.vue new file mode 100644 index 000000000..fd5164ce3 --- /dev/null +++ b/src/views/im/home/components/group/GroupAvatar.vue @@ -0,0 +1,128 @@ + + + diff --git a/src/views/im/home/components/group/GroupCreateDialog.vue b/src/views/im/home/components/group/GroupCreateDialog.vue new file mode 100644 index 000000000..2a9bfeb75 --- /dev/null +++ b/src/views/im/home/components/group/GroupCreateDialog.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/src/views/im/home/components/group/GroupInfo.vue b/src/views/im/home/components/group/GroupInfo.vue new file mode 100644 index 000000000..bb3a17508 --- /dev/null +++ b/src/views/im/home/components/group/GroupInfo.vue @@ -0,0 +1,141 @@ + + + diff --git a/src/views/im/home/components/group/GroupInfoCard.vue b/src/views/im/home/components/group/GroupInfoCard.vue new file mode 100644 index 000000000..5d12afdee --- /dev/null +++ b/src/views/im/home/components/group/GroupInfoCard.vue @@ -0,0 +1,104 @@ + + + diff --git a/src/views/im/home/components/group/GroupItem.vue b/src/views/im/home/components/group/GroupItem.vue new file mode 100644 index 000000000..fc5fe13fe --- /dev/null +++ b/src/views/im/home/components/group/GroupItem.vue @@ -0,0 +1,42 @@ + + + diff --git a/src/views/im/home/components/group/GroupMember.vue b/src/views/im/home/components/group/GroupMember.vue new file mode 100644 index 000000000..874786b2b --- /dev/null +++ b/src/views/im/home/components/group/GroupMember.vue @@ -0,0 +1,64 @@ + + + diff --git a/src/views/im/home/components/group/GroupMemberAddDialog.vue b/src/views/im/home/components/group/GroupMemberAddDialog.vue new file mode 100644 index 000000000..b3b795def --- /dev/null +++ b/src/views/im/home/components/group/GroupMemberAddDialog.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/src/views/im/home/components/group/GroupMemberGrid.vue b/src/views/im/home/components/group/GroupMemberGrid.vue new file mode 100644 index 000000000..e38580e39 --- /dev/null +++ b/src/views/im/home/components/group/GroupMemberGrid.vue @@ -0,0 +1,49 @@ + + + diff --git a/src/views/im/home/components/group/GroupMemberItem.vue b/src/views/im/home/components/group/GroupMemberItem.vue new file mode 100644 index 000000000..9e01eb93b --- /dev/null +++ b/src/views/im/home/components/group/GroupMemberItem.vue @@ -0,0 +1,81 @@ + + + diff --git a/src/views/im/home/components/group/GroupMemberRemoveDialog.vue b/src/views/im/home/components/group/GroupMemberRemoveDialog.vue new file mode 100644 index 000000000..6339349c3 --- /dev/null +++ b/src/views/im/home/components/group/GroupMemberRemoveDialog.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/src/views/im/home/components/group/GroupMuteMemberDialog.vue b/src/views/im/home/components/group/GroupMuteMemberDialog.vue new file mode 100644 index 000000000..35f0b1aac --- /dev/null +++ b/src/views/im/home/components/group/GroupMuteMemberDialog.vue @@ -0,0 +1,97 @@ + + + diff --git a/src/views/im/home/components/group/GroupOwnerTransferDialog.vue b/src/views/im/home/components/group/GroupOwnerTransferDialog.vue new file mode 100644 index 000000000..80caed5a6 --- /dev/null +++ b/src/views/im/home/components/group/GroupOwnerTransferDialog.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/src/views/im/home/components/group/GroupRequestListDialog.vue b/src/views/im/home/components/group/GroupRequestListDialog.vue new file mode 100644 index 000000000..dcb19f6d2 --- /dev/null +++ b/src/views/im/home/components/group/GroupRequestListDialog.vue @@ -0,0 +1,361 @@ + + + + + + + diff --git a/src/views/im/home/components/picker/ConversationPickerPanel.vue b/src/views/im/home/components/picker/ConversationPickerPanel.vue new file mode 100644 index 000000000..86da0cb06 --- /dev/null +++ b/src/views/im/home/components/picker/ConversationPickerPanel.vue @@ -0,0 +1,370 @@ + + + + + diff --git a/src/views/im/home/components/picker/FriendPickerPanel.vue b/src/views/im/home/components/picker/FriendPickerPanel.vue new file mode 100644 index 000000000..f613cd033 --- /dev/null +++ b/src/views/im/home/components/picker/FriendPickerPanel.vue @@ -0,0 +1,244 @@ + + + diff --git a/src/views/im/home/components/picker/GroupMemberPickerPanel.vue b/src/views/im/home/components/picker/GroupMemberPickerPanel.vue new file mode 100644 index 000000000..101dd79b6 --- /dev/null +++ b/src/views/im/home/components/picker/GroupMemberPickerPanel.vue @@ -0,0 +1,243 @@ + + + + diff --git a/src/views/im/home/components/picker/picker-dialog.scss b/src/views/im/home/components/picker/picker-dialog.scss new file mode 100644 index 000000000..7f9943a8e --- /dev/null +++ b/src/views/im/home/components/picker/picker-dialog.scss @@ -0,0 +1,13 @@ +// IM 选择类弹窗的公共样式 mixin +// 每个业务壳在自己的 diff --git a/src/views/im/home/components/rtc/RtcCallParticipantTile.vue b/src/views/im/home/components/rtc/RtcCallParticipantTile.vue new file mode 100644 index 000000000..98ef36ce7 --- /dev/null +++ b/src/views/im/home/components/rtc/RtcCallParticipantTile.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/src/views/im/home/components/rtc/RtcCallRunning.vue b/src/views/im/home/components/rtc/RtcCallRunning.vue new file mode 100644 index 000000000..1b8714071 --- /dev/null +++ b/src/views/im/home/components/rtc/RtcCallRunning.vue @@ -0,0 +1,289 @@ + + + + + diff --git a/src/views/im/home/components/rtc/RtcGroupCallBanner.vue b/src/views/im/home/components/rtc/RtcGroupCallBanner.vue new file mode 100644 index 000000000..1a3509bbb --- /dev/null +++ b/src/views/im/home/components/rtc/RtcGroupCallBanner.vue @@ -0,0 +1,172 @@ + + + diff --git a/src/views/im/home/components/user/RecommendCardDialog.vue b/src/views/im/home/components/user/RecommendCardDialog.vue new file mode 100644 index 000000000..5a35af056 --- /dev/null +++ b/src/views/im/home/components/user/RecommendCardDialog.vue @@ -0,0 +1,313 @@ + + + + + diff --git a/src/views/im/home/components/user/UserAvatar.vue b/src/views/im/home/components/user/UserAvatar.vue new file mode 100644 index 000000000..61b424b06 --- /dev/null +++ b/src/views/im/home/components/user/UserAvatar.vue @@ -0,0 +1,129 @@ + + + diff --git a/src/views/im/home/components/user/UserInfo.vue b/src/views/im/home/components/user/UserInfo.vue new file mode 100644 index 000000000..c12619fc9 --- /dev/null +++ b/src/views/im/home/components/user/UserInfo.vue @@ -0,0 +1,453 @@ + + + + + diff --git a/src/views/im/home/components/user/UserInfoCard.vue b/src/views/im/home/components/user/UserInfoCard.vue new file mode 100644 index 000000000..6d62f9f9d --- /dev/null +++ b/src/views/im/home/components/user/UserInfoCard.vue @@ -0,0 +1,120 @@ + + + diff --git a/src/views/im/home/composables/useFriendBuckets.ts b/src/views/im/home/composables/useFriendBuckets.ts new file mode 100644 index 000000000..2f592e941 --- /dev/null +++ b/src/views/im/home/composables/useFriendBuckets.ts @@ -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 | ComputedRef, + keyword: Ref +): { + filtered: ComputedRef + buckets: ComputedRef +} { + 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(() => { + const map = new Map() + 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 } +} diff --git a/src/views/im/home/composables/useGroupCallMembers.ts b/src/views/im/home/composables/useGroupCallMembers.ts new file mode 100644 index 000000000..78def6a73 --- /dev/null +++ b/src/views/im/home/composables/useGroupCallMembers.ts @@ -0,0 +1,52 @@ +import { computed, type ComputedRef, type Ref } from 'vue' +import { useRtcStore } from '../store/rtcStore' +import { ImConversationType } from '../../utils/constants' +import { getSenderAvatar, getSenderDisplayName } from '../../utils/user' + +/** 群通话成员视图模型:已加入 + 接入中;pending 头像 UI 半透明,joined 不透明 */ +export interface GroupCallMember { + userId: number + nickname: string + avatar?: string + pending: boolean +} + +/** + * 群通话成员列表 computed:joined 在前,未 joined 的 invitee 在后; + * 缓存为空(来电首屏渲染早于 syncGroupActiveCall 回写)时用 fallback 主叫兜底,避免空白 + * + * @param groupId 群编号 + * @param fallbackInviterId 兜底主叫;缓存为空时填一个头像,标记为已加入而非 pending + */ +export function useGroupCallMembers( + groupId: Ref, + fallbackInviterId?: Ref +): ComputedRef { + const rtcStore = useRtcStore() + return computed(() => { + const gid = groupId.value + if (!gid) { + return [] + } + const groupCall = rtcStore.getGroupCall(gid) + const joinedIds = groupCall?.joinedUserIds ?? [] + const inviteeIds = groupCall?.inviteeIds ?? [] + const joinedSet = new Set(joinedIds) + const orderedIds = [...joinedIds, ...inviteeIds.filter((id) => !joinedSet.has(id))] + if (orderedIds.length > 0) { + return orderedIds.map((userId) => toVM(userId, gid, !joinedSet.has(userId))) + } + const fallback = fallbackInviterId?.value + return fallback ? [toVM(fallback, gid, false)] : [] + }) +} + +/** 把 userId 翻译成视图模型,统一走 user.ts helper 解析昵称 / 头像 */ +function toVM(userId: number, groupId: number, pending: boolean): GroupCallMember { + return { + userId, + nickname: getSenderDisplayName(userId, ImConversationType.GROUP, groupId), + avatar: getSenderAvatar(userId, ImConversationType.GROUP, groupId) || undefined, + pending + } +} diff --git a/src/views/im/home/composables/useLiveKitRoom.ts b/src/views/im/home/composables/useLiveKitRoom.ts new file mode 100644 index 000000000..b061ae25d --- /dev/null +++ b/src/views/im/home/composables/useLiveKitRoom.ts @@ -0,0 +1,330 @@ +import { computed, ref, shallowRef } from 'vue' +import { + Room, + RoomEvent, + ConnectionQuality, + Track, + VideoPresets, + type LocalParticipant, + type Participant, + type RemoteParticipant +} from 'livekit-client' + +type ParticipantEventHandler = (userId: number) => void + +/** LiveKit Room 连接 / 设备 / 事件的薄封装;UI 组件只关心响应式状态 */ +export function useLiveKitRoom() { + /** Room 实例;模块内部状态,不对外暴露,避免调用方误写 */ + const _room = shallowRef(null) + /** 只读 room 引用;调用方仅用于幂等判定 */ + const room = computed(() => _room.value) + /** 本地参与者;连接成功后赋值 */ + const localParticipant = shallowRef(null) + /** 远端参与者列表;ParticipantConnected / Disconnected 时刷新;shallowRef 避免 Vue 深度代理 SDK class 内部 */ + const remoteParticipants = shallowRef([]) + /** 连接状态 */ + const isConnected = ref(false) + /** 连接质量 */ + const connectionQuality = ref(ConnectionQuality.Unknown) + /** 麦克风开关 */ + const micEnabled = ref(true) + /** 摄像头开关 */ + const cameraEnabled = ref(false) + /** 扬声器开关;浏览器无系统级 API,通过 audio 元素 muted 属性实现远端音频静音 */ + const speakerEnabled = ref(true) + /** 屏幕共享开关 */ + const screenShareEnabled = ref(false) + /** 当前是否处于「重连中」;瞬断时 UI 显示提示而不强制结束通话 */ + const reconnecting = ref(false) + /** 远端断开订阅者;通话结束时统一清空 */ + const disconnectedHandlers = new Set<() => void>() + /** 房内某人加入订阅者;主叫端用于从 INVITING 切到 RUNNING */ + const participantConnectedHandlers = new Set() + /** 房内某人离开订阅者;用于把 userId 标记为「已退出」从 pending 占位中移除 */ + const participantDisconnectedHandlers = new Set() + + /** 同步远端参与者列表到响应式数组 */ + function syncRemotes(r: Room) { + remoteParticipants.value = Array.from(r.remoteParticipants.values()) + } + + /** 连接 LiveKit Server;audio / video 控制初始默认开关 */ + async function connect(url: string, token: string, opts: { audio?: boolean; video?: boolean }) { + // 新连接前先断开旧 Room;保留本次注册的事件回调 + if (_room.value) { + await disconnectRoom(false) + } + const r = new Room({ + // 按格子尺寸自动选 simulcast 层 + adaptiveStream: true, + // 未订阅的层动态停发,节省上行 + dynacast: true, + // 采集分辨率 720p,确保大格子清晰 + videoCaptureDefaults: { + resolution: VideoPresets.h720.resolution + }, + // 发布编码上限 1.5 Mbps / 30fps;保留默认 simulcast 三层(180p / 360p / 720p) + publishDefaults: { + videoEncoding: { + maxBitrate: 1_500_000, + maxFramerate: 30, + priority: 'high' + }, + // 屏幕共享码率 3 Mbps,文字界面清晰 + screenShareEncoding: { + maxBitrate: 3_000_000, + maxFramerate: 15, + priority: 'medium' + } + } + }) + _room.value = r + + r.on(RoomEvent.ParticipantConnected, (rp) => { + syncRemotes(r) + const userId = parseUserId(rp.identity) + if (userId != null) { + participantConnectedHandlers.forEach((cb) => cb(userId)) + } + }) + .on(RoomEvent.ParticipantDisconnected, (rp) => { + syncRemotes(r) + // 离开的参与者缓存清掉,避免下次同 sid 重连命中失效引用 + for (const key of Array.from(streamCache.keys())) { + if (key.startsWith(`${rp.sid}:`)) { + streamCache.delete(key) + } + } + const userId = parseUserId(rp.identity) + if (userId != null) { + participantDisconnectedHandlers.forEach((cb) => cb(userId)) + } + }) + .on(RoomEvent.TrackSubscribed, () => syncRemotes(r)) + .on(RoomEvent.TrackUnsubscribed, () => syncRemotes(r)) + // mute / unmute 让 pickStream 的 isMuted 短路重算,video 元素能解绑 srcObject 而不是卡最后一帧 + .on(RoomEvent.TrackMuted, () => syncRemotes(r)) + .on(RoomEvent.TrackUnmuted, () => syncRemotes(r)) + .on(RoomEvent.ConnectionQualityChanged, (quality) => { + connectionQuality.value = quality + }) + // 瞬断 → 显示「重连中」;不关通话窗,由 SDK 内部重连机制恢复 + .on(RoomEvent.Reconnecting, () => { + reconnecting.value = true + }) + .on(RoomEvent.Reconnected, () => { + reconnecting.value = false + }) + // 重连失败 / 主动断 / 被踢时触发清理 + .on(RoomEvent.Disconnected, () => { + isConnected.value = false + reconnecting.value = false + disconnectedHandlers.forEach((cb) => cb()) + }) + + // 预热 getUserMedia 与 WebSocket 握手并行,省 100~300ms 串行延迟; + // 拿到的 stream 仅用于触发权限弹窗 + 设备就绪,握手完成后由 LiveKit 内部重新请求设备发布轨 + const warmup = prewarmMedia(opts) + // 建立 WebSocket 信令 + WebRTC 媒体通道;完成后 localParticipant 可用,已在房参与者会通过 ParticipantConnected 事件批量推送 + await r.connect(url, token) + // 期间被外部 disconnect 替换;中止后续 publish,避免摄像头被重新启用 + if (_room.value !== r) { + return + } + localParticipant.value = r.localParticipant + isConnected.value = true + + // 预热结果不直接发布(避免 SDK 与外部 track 生命周期纠缠),仅等待权限就绪后再走标准 setXxxEnabled + await warmup + if (_room.value !== r) { + return + } + // 麦克风与摄像头权限相互独立,并行启用发布 + const inits: Promise[] = [] + if (opts.audio) { + inits.push(r.localParticipant.setMicrophoneEnabled(true)) + } + if (opts.video) { + inits.push(r.localParticipant.setCameraEnabled(true)) + } + if (inits.length > 0) { + await Promise.all(inits) + } + micEnabled.value = !!opts.audio + cameraEnabled.value = !!opts.video + + // 兜底同步一次远端列表:r.connect 期间 ParticipantConnected 事件可能在 handler 绑定前触发被吞,导致首屏漏人 + syncRemotes(r) + } + + /** 提前触发权限弹窗 + 设备唤起,串行延迟在 r.connect 期间一起跑;失败静默(连接后会再试一次) */ + async function prewarmMedia(opts: { audio?: boolean; video?: boolean }): Promise { + if (!opts.audio && !opts.video) { + return + } + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: !!opts.audio, + video: !!opts.video + }) + // 拿到权限即可,立即停掉所有 track 释放设备;正式发布走 SDK 流程重新请求 + stream.getTracks().forEach((t) => t.stop()) + } catch { + // 用户拒绝 / 设备占用等异常,交给后续 setXxxEnabled 再次尝试报错 + } + } + + /** 切麦克风 */ + async function setMicEnabled(enabled: boolean) { + if (!_room.value) { + return + } + await _room.value.localParticipant.setMicrophoneEnabled(enabled) + micEnabled.value = enabled + } + + /** 切摄像头 */ + async function setCameraEnabled(enabled: boolean) { + if (!_room.value) { + return + } + await _room.value.localParticipant.setCameraEnabled(enabled) + cameraEnabled.value = enabled + } + + /** 切扬声器;仅切响应式状态,实际静音由模板上 audio 元素 :muted 绑定生效 */ + function setSpeakerEnabled(enabled: boolean) { + speakerEnabled.value = enabled + } + + /** + * 切屏幕共享; + * + * 浏览器会弹原生「选择共享内容」对话框,用户在弹窗里点取消时 setScreenShareEnabled 会抛错,捕获并把状态复位回 SDK 的实际值 + */ + async function setScreenShareEnabled(enabled: boolean) { + if (!_room.value) return + try { + await _room.value.localParticipant.setScreenShareEnabled(enabled) + screenShareEnabled.value = enabled + } catch (e) { + // 用户在浏览器原生对话框里取消选择,不当作错误 + screenShareEnabled.value = _room.value.localParticipant.isScreenShareEnabled + throw e + } + } + + /** 注册「远端连接异常断开」回调;返回反注册函数 */ + function onDisconnected(cb: () => void): () => void { + disconnectedHandlers.add(cb) + return () => disconnectedHandlers.delete(cb) + } + + /** 注册「房内某人加入」回调;返回反注册函数 */ + function onParticipantConnected(cb: ParticipantEventHandler): () => void { + participantConnectedHandlers.add(cb) + return () => participantConnectedHandlers.delete(cb) + } + + /** 注册「房内某人离开」回调;返回反注册函数 */ + function onParticipantDisconnected(cb: ParticipantEventHandler): () => void { + participantDisconnectedHandlers.add(cb) + return () => participantDisconnectedHandlers.delete(cb) + } + + /** identity 是后端签 token 时塞的 userId 字符串,转 number 返回;非数字(兼容性兜底)返回 null */ + function parseUserId(identity: string): number | null { + const id = Number(identity) + return Number.isNaN(id) ? null : id + } + + /** + * MediaStream 缓存;key 为 `${participantSid}:${source}`,value 为 `{ track, stream }`; + * 同一条 MediaStreamTrack 复用同一个 MediaStream,避免