feat(im):初始化 ele 的 im 迁移

pull/367/head
YunaiV 2026-06-18 16:09:40 -07:00
parent 5a4f8b4e2a
commit dfe4c8a040
203 changed files with 34993 additions and 85 deletions

View File

@ -670,6 +670,9 @@ export const useFriendStore = defineStore('imFriendStore', {
const existingIndex = this.friendRequests.findIndex((item) => item.id === payload.requestId) const existingIndex = this.friendRequests.findIndex((item) => item.id === payload.requestId)
if (existingIndex !== -1) { if (existingIndex !== -1) {
const existing = this.friendRequests.splice(existingIndex, 1)[0] const existing = this.friendRequests.splice(existingIndex, 1)[0]
if (!existing) {
return
}
const next = { const next = {
...existing, ...existing,
fromUserId: payload.operatorUserId, fromUserId: payload.operatorUserId,

View File

@ -488,13 +488,17 @@ export const useMessageStore = defineStore('imMessageStore', {
const messages = this.getMessageList(conversationInfo.type, conversationInfo.targetId) const messages = this.getMessageList(conversationInfo.type, conversationInfo.targetId)
const existingIndex = messages.findIndex((existing) => isSameMessage(existing, message)) const existingIndex = messages.findIndex((existing) => isSameMessage(existing, message))
if (existingIndex !== -1) { if (existingIndex !== -1) {
const existing = messages[existingIndex]
if (!existing) {
continue
}
// 1.3 已存在消息合并服务端状态 // 1.3 已存在消息合并服务端状态
applyServerMessageUpdate(messages[existingIndex], message) applyServerMessageUpdate(existing, message)
if (existingIndex === messages.length - 1) { if (existingIndex === messages.length - 1) {
recomputeConversationLast(conversation, messages) recomputeConversationLast(conversation, messages)
syncConversationAtFlags(conversation, message) syncConversationAtFlags(conversation, message)
} }
addChanged(conversation, messages[existingIndex], { addChanged(conversation, existing, {
mergeClientRecord: hasServerClientMessageId mergeClientRecord: hasServerClientMessageId
}) })
continue continue
@ -580,14 +584,18 @@ export const useMessageStore = defineStore('imMessageStore', {
const existingIndex = messages.findIndex((item) => isSameMessage(item, message)) const existingIndex = messages.findIndex((item) => isSameMessage(item, message))
// 3. 已存在消息走覆盖更新 // 3. 已存在消息走覆盖更新
if (existingIndex !== -1) { if (existingIndex !== -1) {
applyServerMessageUpdate(messages[existingIndex], message) const existing = messages[existingIndex]
if (!existing) {
return Promise.resolve()
}
applyServerMessageUpdate(existing, message)
if (existingIndex === messages.length - 1) { if (existingIndex === messages.length - 1) {
recomputeConversationLast(conversation, messages) recomputeConversationLast(conversation, messages)
syncConversationAtFlags(conversation, message) syncConversationAtFlags(conversation, message)
} }
return getDb() return getDb()
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => { .transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
await this.saveMessageRecord(messages[existingIndex], conversationInfo.type, tx, { await this.saveMessageRecord(existing, conversationInfo.type, tx, {
mergeClientRecord: hasIncomingClientMessageId mergeClientRecord: hasIncomingClientMessageId
}) })
await conversationStore.saveConversationRecord(conversation, tx) await conversationStore.saveConversationRecord(conversation, tx)
@ -876,6 +884,9 @@ export const useMessageStore = defineStore('imMessageStore', {
} }
// 2. 从内存移除消息 // 2. 从内存移除消息
const [removed] = messages.splice(index, 1) const [removed] = messages.splice(index, 1)
if (!removed) {
return
}
revokeBlobUrlsInContent(removed.content) revokeBlobUrlsInContent(removed.content)
if (index === messages.length) { if (index === messages.length) {
recomputeConversationLast(conversation, messages) recomputeConversationLast(conversation, messages)

View File

@ -38,8 +38,8 @@ const props = defineProps<{
type?: number; type?: number;
}>(); }>();
const payload = computed<Record<string, any> | undefined>(() => const payload = computed<null | Record<string, any>>(() =>
parseMessage<Record<string, any>>(props.content || ''), parseMessage<Record<string, any>>(props.content ?? ''),
); );
const textContent = computed(() => payload.value?.content || ''); const textContent = computed(() => payload.value?.content || '');

View File

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts'; import type { EChartsOption, EchartsUIType } from '@vben/plugins/echarts';
import { nextTick, onMounted, ref } from 'vue'; import { nextTick, onMounted, ref } from 'vue';
@ -27,7 +27,10 @@ const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef); const { renderEcharts } = useEcharts(chartRef);
/** 获取图表配置 */ /** 获取图表配置 */
function buildOptions(dates: string[], series: Record<string, number[]>) { function buildOptions(
dates: string[],
series: Record<string, number[]>,
): EChartsOption {
if (props.type === 'message') { if (props.type === 'message') {
return { return {
tooltip: { trigger: 'axis' }, tooltip: { trigger: 'axis' },

View File

@ -1,6 +1,6 @@
import { useAccessStore, useUserStore } from '@vben/stores' import { useAccessStore, useUserStore } from '@vben/stores'
// TODO @AI是不是换成 vben 里更合适的方法 // TODO DONE @AI已使用 Vben 的 useUserStore / useAccessStore 获取登录信息
/** 获取当前用户编号 */ /** 获取当前用户编号 */
export function getCurrentUserId(): number { export function getCurrentUserId(): number {

View File

@ -244,7 +244,7 @@ function computeCellRectsSmall(count: number, target: number, divider: number):
function computeCellRectsMedium(count: number, target: number, divider: number): CellRect[] { function computeCellRectsMedium(count: number, target: number, divider: number): CellRect[] {
const s = (target - 4 * divider) / 3 const s = (target - 4 * divider) / 3
const step = s + divider const step = s + divider
const xs = [divider, divider + step, divider + 2 * step] const xs = [divider, divider + step, divider + 2 * step] as const
// 2 行高 + 1 行间 div // 2 行高 + 1 行间 div
const totalH = 2 * s + divider const totalH = 2 * s + divider
const y0 = (target - totalH) / 2 const y0 = (target - totalH) / 2
@ -265,8 +265,8 @@ function computeCellRectsMedium(count: number, target: number, divider: number):
function computeCellRectsLarge(count: number, target: number, divider: number): CellRect[] { function computeCellRectsLarge(count: number, target: number, divider: number): CellRect[] {
const s = (target - 4 * divider) / 3 const s = (target - 4 * divider) / 3
const step = s + divider const step = s + divider
const xs = [divider, divider + step, divider + 2 * step] const xs = [divider, divider + step, divider + 2 * step] as const
const ys = [divider, divider + step, divider + 2 * step] const ys = [divider, divider + step, divider + 2 * step] as const
const rects: CellRect[] = [] const rects: CellRect[] = []
if (count === 7) { if (count === 7) {
// 上 1 居中 + 中 3 + 下 3 // 上 1 居中 + 中 3 + 下 3
@ -280,9 +280,9 @@ function computeCellRectsLarge(count: number, target: number, divider: number):
return rects return rects
} }
// count === 93×3 满铺 // count === 93×3 满铺
for (let row = 0; row < 3; row++) { for (const y of ys) {
for (let col = 0; col < 3; col++) { for (const x of xs) {
rects.push({ x: xs[col], y: ys[row], w: s, h: s }) rects.push({ x, y, w: s, h: s })
} }
} }
return rects return rects

View File

@ -76,6 +76,9 @@ export async function runIncrementalPull<T extends PullRecord>(
} }
// 推进游标到本页最后一条并持久化:下次从这里接着拉 // 推进游标到本页最后一条并持久化:下次从这里接着拉
const last = list[list.length - 1] const last = list[list.length - 1]
if (!last) {
return
}
if (last.updateTime == null) { if (last.updateTime == null) {
return return
} }

View File

@ -58,6 +58,7 @@
"element-plus": "catalog:", "element-plus": "catalog:",
"fast-xml-parser": "catalog:", "fast-xml-parser": "catalog:",
"highlight.js": "catalog:", "highlight.js": "catalog:",
"livekit-client": "catalog:",
"pinia": "catalog:", "pinia": "catalog:",
"steady-xml": "catalog:", "steady-xml": "catalog:",
"tinymce": "catalog:", "tinymce": "catalog:",

View File

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

View File

@ -0,0 +1,25 @@
import { requestClient } from '#/api/request';
export namespace ImConversationReadApi {
/** IM 会话读位置 Response VO */
export interface ConversationReadRespVO {
id: number; // 读位置编号(增量拉取游标用)
conversationType: number; // 会话类型,参见 ImConversationType
targetId: number; // 会话目标编号
messageId: number; // 最大已读消息编号
updateTime?: number; // 最近更新时间(毫秒时间戳,增量拉取游标用)
}
}
/** 增量拉取当前用户的会话读位置(重连 / 离线补偿) */
export function pullMyConversationReadList(params: {
lastId?: number;
lastUpdateTime?: number;
limit: number;
}) {
return requestClient.get<ImConversationReadApi.ConversationReadRespVO[]>(
'/im/conversation-read/pull',
{ params },
);
}

View File

@ -0,0 +1,26 @@
import { requestClient } from '#/api/request';
export namespace ImFacePackApi {
/** 用户端表情包项(精简版) */
export interface FacePackUserItem {
id: number;
url: string;
name?: string;
width: number;
height: number;
}
/** 用户端表情包 + 嵌套 items */
export interface FacePackUser {
id: number;
name: string;
icon?: string;
items: FacePackUserItem[];
}
}
/** 拉取所有启用的系统表情包(含表情列表) */
export function getFacePackList() {
return requestClient.get<ImFacePackApi.FacePackUser[]>('/im/face-pack/list');
}

View File

@ -0,0 +1,38 @@
import { requestClient } from '#/api/request';
export namespace ImFaceUserItemApi {
/** 个人表情 */
export interface FaceUserItem {
id: number;
url: string;
name?: string;
width: number;
height: number;
}
/** 添加个人表情请求 */
export interface FaceUserItemSaveReqVO {
url: string;
name?: string;
width: number;
height: number;
}
}
/** 获取我的个人表情列表 */
export function getFaceUserItemList() {
return requestClient.get<ImFaceUserItemApi.FaceUserItem[]>('/im/face-user-item/list');
}
/** 添加个人表情 */
export function createFaceUserItem(data: ImFaceUserItemApi.FaceUserItemSaveReqVO) {
return requestClient.post<number>('/im/face-user-item/create', data);
}
/** 删除个人表情 */
export function deleteFaceUserItem(id: number) {
return requestClient.delete<boolean>('/im/face-user-item/delete', {
params: { id },
});
}

View File

@ -0,0 +1,78 @@
import { requestClient } from '#/api/request';
export namespace ImFriendApi {
/** IM 好友 Response VO */
export interface FriendRespVO {
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; // 删除好友时间
updateTime?: number; // 最近更新时间(毫秒时间戳,增量拉取游标用)
nickname?: string; // 好友昵称
nicknamePinyin?: string; // 昵称的拼音(小写无空格,前端按首字母分桶 / 拼音搜索)
avatar?: string; // 好友头像
}
/** IM 好友更新 Request VO */
export interface FriendUpdateReqVO {
friendUserId: number; // 好友的用户编号
silent?: boolean; // 是否免打扰
displayName?: string; // 好友展示备注
pinned?: boolean; // 是否置顶联系人
}
}
/** 获得当前登录用户的好友列表 */
export function getMyFriendList() {
return requestClient.get<ImFriendApi.FriendRespVO[]>('/im/friend/list');
}
/** 增量拉取当前用户的好友关系(重连 / 离线补偿) */
export function pullMyFriendList(params: {
lastId?: number;
lastUpdateTime?: number;
limit: number;
}) {
return requestClient.get<ImFriendApi.FriendRespVO[]>('/im/friend/pull', { params });
}
/** 获得好友详情 */
export function getFriend(friendUserId: number | string) {
return requestClient.get<ImFriendApi.FriendRespVO>('/im/friend/get', {
params: { friendUserId },
});
}
/** 删除好友(单向软删除) */
export function deleteFriend(friendUserId: number | string, clear: boolean) {
return requestClient.delete<boolean>('/im/friend/delete', {
params: { friendUserId, clear },
});
}
/** 更新好友信息(备注 / 免打扰 / 联系人置顶) */
export function updateFriend(data: ImFriendApi.FriendUpdateReqVO) {
return requestClient.put<boolean>('/im/friend/update', data);
}
/** 拉黑好友(必须先是好友;单边屏蔽对方私聊消息) */
export function blockFriend(friendUserId: number | string) {
return requestClient.put<boolean>('/im/friend/block', undefined, {
params: { friendUserId },
});
}
/** 移出黑名单 */
export function unblockFriend(friendUserId: number | string) {
return requestClient.put<boolean>('/im/friend/unblock', undefined, {
params: { friendUserId },
});
}

View File

@ -0,0 +1,84 @@
import { requestClient } from '#/api/request';
export namespace ImFriendRequestApi {
/** IM 好友申请 Response VO */
export interface FriendRequestRespVO {
id: number; // 申请编号
fromUserId: number; // 发起方用户编号
toUserId: number; // 接收方用户编号
handleResult: number; // 处理结果0=未处理1=同意2=拒绝
applyContent?: string; // 申请理由
handleContent?: string; // 处理理由(接收方拒绝时可选填)
addSource?: number; // 添加来源;参见 ImFriendAddSourceEnum
handleTime?: string; // 处理时间
createTime: string; // 申请创建时间
updateTime?: number; // 最近更新时间(毫秒时间戳,增量拉取游标用)
fromNickname?: string; // 发起方昵称
fromAvatar?: string; // 发起方头像
toNickname?: string; // 接收方昵称
toAvatar?: string; // 接收方头像
}
/** IM 好友申请发起 Request VO */
export interface FriendRequestApplyReqVO {
toUserId: number; // 接收方用户编号
applyContent?: string; // 申请理由
displayName?: string; // 对接收方的备注(仅自己可见)
addSource?: number; // 添加来源
}
}
/** 发起好友申请 */
export function applyFriendRequest(data: ImFriendRequestApi.FriendRequestApplyReqVO) {
return requestClient.post<null | number>('/im/friend-request/apply', data);
}
/** 同意好友申请 */
export function agreeFriendRequest(id: number | string) {
return requestClient.put<boolean>('/im/friend-request/agree', undefined, {
params: { id },
});
}
/** 拒绝好友申请 */
export function refuseFriendRequest(
id: number | string,
handleContent?: string,
) {
return requestClient.put<boolean>('/im/friend-request/refuse', undefined, {
params: { id, handleContent },
});
}
/** 查询「我相关」的好友申请列表(游标分页:传 maxId 加载更多) */
export function getMyFriendRequestList(limit: number, maxId?: number) {
const params: Record<string, number> = { limit };
if (maxId != null) {
params.maxId = maxId;
}
return requestClient.get<ImFriendRequestApi.FriendRequestRespVO[]>(
'/im/friend-request/list',
{ params },
);
}
/** 增量拉取「我相关」的好友申请变更(重连 / 离线补偿) */
export function pullMyFriendRequestList(params: {
lastId?: number;
lastUpdateTime?: number;
limit: number;
}) {
return requestClient.get<ImFriendRequestApi.FriendRequestRespVO[]>(
'/im/friend-request/pull',
{ params },
);
}
/** 按 id 单查「我相关」的申请记录带越权过滤WebSocket 通知到达后用) */
export function getMyFriendRequest(id: number) {
return requestClient.get<ImFriendRequestApi.FriendRequestRespVO | null>(
'/im/friend-request/get',
{ params: { id } },
);
}

View File

@ -0,0 +1,146 @@
import type { ImGroupMessageApi } from '#/api/im/message/group';
import { requestClient } from '#/api/request';
export namespace ImGroupApi {
/** 群 Response VO */
export interface GroupRespVO {
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?: ImGroupMessageApi.GroupMessageRespVO[]; // 群置顶消息列表(后端关联回填,仅当登录用户是群成员时非空)
joinStatus?: number; // 当前登录用户在该群的成员状态(参见 CommonStatusEnum0 在群 / 1 已退群);历史退群群仍返回,供展示离线消息的群名 / 头像
groupRemark?: string; // 当前登录用户对该群的备注
silent?: boolean; // 当前登录用户是否免打扰
}
/** 群消息置顶 / 取消置顶 Request VO */
export interface GroupMessagePinReqVO {
id: number; // 群编号
messageId: number; // 消息编号
}
/** 群创建 Request VO */
export interface GroupCreateReqVO {
name: string; // 群名称
memberUserIds?: number[]; // 初始成员用户编号列表(建群同时邀请的好友,不含创建者自己)
joinApproval?: boolean; // 进群是否需审批;不传默认 false 自由进群
}
/** 群更新 Request VO */
export interface GroupUpdateReqVO {
id: number; // 群编号
name?: string; // 群名称
avatar?: string; // 群头像
notice?: string; // 群公告
joinApproval?: boolean; // 进群是否需审批
}
/** 添加 / 撤销群管理员 Request VO */
export interface GroupAdminReqVO {
id: number; // 群编号
userIds: number[]; // 目标用户编号列表
}
/** 群主转让 Request VO */
export interface GroupTransferOwnerReqVO {
id: number; // 群编号
newOwnerUserId: number; // 新群主用户编号
}
/** 全群禁言 / 取消 Request VO */
export interface GroupMuteAllReqVO {
id: number; // 群编号
mutedAll: boolean; // 是否全群禁言
}
/** 成员禁言 Request VO */
export interface GroupMuteMemberReqVO {
id: number; // 群编号
userId: number; // 被禁言的用户编号
mutedSeconds: number; // 禁言时长0 表示永久禁言
}
/** 取消成员禁言 Request VO */
export interface GroupCancelMuteMemberReqVO {
id: number; // 群编号
userId: number; // 被取消禁言的用户编号
}
}
/** 获得当前登录用户的群列表 */
export function getMyGroupList() {
return requestClient.get<ImGroupApi.GroupRespVO[]>('/im/group/list');
}
/** 获得群详情 */
export function getGroup(id: number | string) {
return requestClient.get<ImGroupApi.GroupRespVO>('/im/group/get', { params: { id } });
}
/** 创建群 */
export function createGroup(data: ImGroupApi.GroupCreateReqVO) {
return requestClient.post<ImGroupApi.GroupRespVO>('/im/group/create', data);
}
/** 更新群 */
export function updateGroup(data: ImGroupApi.GroupUpdateReqVO) {
return requestClient.put<ImGroupApi.GroupRespVO>('/im/group/update', data);
}
/** 解散群 */
export function dissolveGroup(id: number | string) {
return requestClient.delete<boolean>('/im/group/dissolve', {
params: { id },
});
}
/** 添加群管理员(仅群主可调) */
export function addGroupAdmin(data: ImGroupApi.GroupAdminReqVO) {
return requestClient.put<boolean>('/im/group/add-admin', data);
}
/** 撤销群管理员(仅群主可调) */
export function removeGroupAdmin(data: ImGroupApi.GroupAdminReqVO) {
return requestClient.put<boolean>('/im/group/remove-admin', data);
}
/** 转让群主(仅老群主可调;旧群主转让后降为普通成员) */
export function transferGroupOwner(data: ImGroupApi.GroupTransferOwnerReqVO) {
return requestClient.put<boolean>('/im/group/transfer-owner', data);
}
/** 置顶群消息(仅群主 / 管理员可调) */
export function pinGroupMessage(data: ImGroupApi.GroupMessagePinReqVO) {
return requestClient.put<boolean>('/im/group/pin-message', data);
}
/** 取消置顶群消息(仅群主 / 管理员可调) */
export function unpinGroupMessage(data: ImGroupApi.GroupMessagePinReqVO) {
return requestClient.put<boolean>('/im/group/unpin-message', data);
}
/** 全群禁言 / 取消(仅群主 / 管理员可调) */
export function muteAll(data: ImGroupApi.GroupMuteAllReqVO) {
return requestClient.put<boolean>('/im/group/mute-all', data);
}
/** 禁言成员 */
export function muteMember(data: ImGroupApi.GroupMuteMemberReqVO) {
return requestClient.put<boolean>('/im/group/mute-member', data);
}
/** 取消成员禁言 */
export function cancelMuteMember(data: ImGroupApi.GroupCancelMuteMemberReqVO) {
return requestClient.put<boolean>('/im/group/cancel-mute-member', data);
}

View File

@ -0,0 +1,78 @@
import { requestClient } from '#/api/request';
export namespace ImGroupMemberApi {
/** 群成员 Response VO */
export interface GroupMemberRespVO {
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; // 创建时间
nickname?: string; // 用户昵称
avatar?: string; // 用户头像
}
/** 群成员邀请 Request VO */
export interface GroupMemberInviteReqVO {
groupId: number; // 群编号
memberUserIds: number[]; // 被邀请的用户编号列表
}
/** 群成员移除 Request VO */
export interface GroupMemberRemoveReqVO {
groupId: number; // 群编号
memberUserIds: number[]; // 被移除的用户编号列表
}
/** 群成员更新 Request VO */
export interface GroupMemberUpdateReqVO {
groupId: number; // 群编号
displayUserName?: string; // 群内昵称
groupRemark?: string; // 群备注
silent?: boolean; // 是否免打扰
}
}
/** 邀请用户加入群 */
export function inviteGroupMember(data: ImGroupMemberApi.GroupMemberInviteReqVO) {
return requestClient.post<boolean>('/im/group/invite', data);
}
/** 退出群 */
export function quitGroup(groupId: number | string) {
return requestClient.delete<boolean>('/im/group/quit', {
params: { groupId },
});
}
/** 移除群成员 */
export function removeGroupMember(data: ImGroupMemberApi.GroupMemberRemoveReqVO) {
return requestClient.delete<boolean>('/im/group/kicking', { data });
}
/** 获得群成员详情 */
export function getGroupMember(groupId: number, userId: number) {
return requestClient.get<ImGroupMemberApi.GroupMemberRespVO>('/im/group-member/get', {
params: { groupId, userId },
});
}
/** 获得指定群的成员列表(聚合 AdminUser 昵称 / 头像) */
export function getGroupMemberList(groupId: number | string) {
return requestClient.get<ImGroupMemberApi.GroupMemberRespVO[]>('/im/group-member/list', {
params: { groupId },
});
}
/** 更新群成员 */
export function updateGroupMember(data: ImGroupMemberApi.GroupMemberUpdateReqVO) {
return requestClient.put<boolean>('/im/group-member/update', data);
}

View File

@ -0,0 +1,90 @@
import { requestClient } from '#/api/request';
export namespace ImGroupRequestApi {
/** IM 加群申请 Response VO */
export interface GroupRequestRespVO {
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; // 申请创建时间
updateTime?: number; // 最近更新时间(毫秒时间戳,增量拉取游标用)
userNickname?: string; // 申请人 / 被邀请人昵称
userAvatar?: string; // 申请人 / 被邀请人头像
inviterNickname?: string; // 邀请人昵称
inviterAvatar?: string; // 邀请人头像
groupName?: string; // 群名称
groupAvatar?: string; // 群头像
}
/** IM 加群申请发起 Request VO */
export interface GroupRequestApplyReqVO {
groupId: number; // 群编号
applyContent?: string; // 申请理由
addSource?: number; // 加入来源
}
}
/** 申请加群 */
export function applyJoinGroup(data: ImGroupRequestApi.GroupRequestApplyReqVO) {
return requestClient.post<null | number>('/im/group-request/apply', data);
}
/** 同意加群申请(群主或管理员) */
export function agreeGroupRequest(id: number | string) {
return requestClient.put<boolean>('/im/group-request/agree', undefined, {
params: { id },
});
}
/** 拒绝加群申请(群主或管理员) */
export function refuseGroupRequest(
id: number | string,
handleContent?: string,
) {
return requestClient.put<boolean>('/im/group-request/refuse', undefined, {
params: { id, handleContent },
});
}
/** 查询「我管理的所有群」下的未处理加群申请列表(不分页);前端 store 据此派生横幅红点 + Drawer 列表 */
export function getUnhandledRequestList() {
return requestClient.get<ImGroupRequestApi.GroupRequestRespVO[]>(
'/im/group-request/unhandled-list',
);
}
/** 查询指定群下的全部加群申请(含已处理);仅群主 / 管理员可查 */
export function getGroupRequestListByGroupId(groupId: number) {
return requestClient.get<ImGroupRequestApi.GroupRequestRespVO[]>(
'/im/group-request/list-by-group',
{ params: { groupId } },
);
}
/** 按 id 单查申请记录带越权过滤WebSocket 通知到达后用) */
export function getMyGroupRequest(id: number) {
return requestClient.get<ImGroupRequestApi.GroupRequestRespVO | null>(
'/im/group-request/get',
{ params: { id } },
);
}
/** 增量拉取我管理的所有群下加群申请变更(重连 / 离线补偿) */
export function pullMyGroupRequestList(params: {
lastId?: number;
lastUpdateTime?: number;
limit: number;
}) {
return requestClient.get<ImGroupRequestApi.GroupRequestRespVO[]>(
'/im/group-request/pull',
{ params },
);
}

View File

@ -0,0 +1,56 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace ImManagerChannelApi {
/** 频道 */
export interface Channel {
id: number;
code: string;
name: string;
avatar?: string;
sort: number;
status: number;
createTime?: Date;
}
}
/** 获得频道分页 */
export function getManagerChannelPage(params: PageParam) {
return requestClient.get<PageResult<ImManagerChannelApi.Channel>>(
'/im/manager/channel/page',
{ params },
);
}
/** 获得频道详情 */
export function getManagerChannel(id: number) {
return requestClient.get<ImManagerChannelApi.Channel>('/im/manager/channel/get', {
params: { id },
});
}
/** 新增频道 */
export function createManagerChannel(data: ImManagerChannelApi.Channel) {
return requestClient.post<number>('/im/manager/channel/create', data);
}
/** 修改频道 */
export function updateManagerChannel(data: ImManagerChannelApi.Channel) {
return requestClient.put<boolean>('/im/manager/channel/update', data);
}
/** 删除频道 */
export function deleteManagerChannel(id: number) {
return requestClient.delete<boolean>('/im/manager/channel/delete', {
params: { id },
});
}
/** 获得启用的频道精简列表(表单选择用) */
export function getSimpleChannelList() {
return requestClient.get<ImManagerChannelApi.Channel[]>(
'/im/manager/channel/simple-list',
);
}

View File

@ -0,0 +1,68 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace ImManagerChannelMaterialApi {
/** 频道素材 */
export interface Material {
id: number;
channelId: number;
channelName?: string;
type: number;
title: string;
coverUrl?: string;
summary?: string;
content?: string;
url?: string;
createTime?: Date;
}
}
/** 获得素材分页 */
export function getManagerChannelMaterialPage(params: PageParam) {
return requestClient.get<PageResult<ImManagerChannelMaterialApi.Material>>(
'/im/manager/channel-material/page',
{ params },
);
}
/** 获得指定频道下的素材精简列表 */
export function getSimpleManagerChannelMaterialList(channelId: number) {
return requestClient.get<ImManagerChannelMaterialApi.Material[]>(
'/im/manager/channel-material/simple-list',
{ params: { channelId } },
);
}
/** 获得素材详情 */
export function getManagerChannelMaterial(id: number) {
return requestClient.get<ImManagerChannelMaterialApi.Material>(
'/im/manager/channel-material/get',
{ params: { id } },
);
}
/** 新增素材 */
export function createManagerChannelMaterial(
data: ImManagerChannelMaterialApi.Material,
) {
return requestClient.post<number>('/im/manager/channel-material/create', data);
}
/** 修改素材 */
export function updateManagerChannelMaterial(
data: ImManagerChannelMaterialApi.Material,
) {
return requestClient.put<boolean>(
'/im/manager/channel-material/update',
data,
);
}
/** 删除素材 */
export function deleteManagerChannelMaterial(id: number) {
return requestClient.delete<boolean>('/im/manager/channel-material/delete', {
params: { id },
});
}

View File

@ -0,0 +1,48 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace ImManagerChannelMessageApi {
/** 频道消息 */
export interface ChannelMessage {
id: number;
channelId: number;
channelName?: string;
materialId: number;
materialTitle?: string;
materialCoverUrl?: string;
type: number;
content?: string;
receiverUserIds?: number[];
sendTime?: Date;
}
/** 频道消息发送请求 */
export interface ChannelMessageSendReqVO {
materialId: number;
receiverUserIds?: number[];
}
}
/** 立即推送频道消息 */
export function sendManagerChannelMessage(
data: ImManagerChannelMessageApi.ChannelMessageSendReqVO,
) {
return requestClient.post<number>('/im/manager/channel-message/send', data);
}
/** 删除频道消息 */
export function deleteManagerChannelMessage(id: number) {
return requestClient.delete<boolean>('/im/manager/channel-message/delete', {
params: { id },
});
}
/** 获得频道消息分页 */
export function getManagerChannelMessagePage(params: PageParam) {
return requestClient.get<PageResult<ImManagerChannelMessageApi.ChannelMessage>>(
'/im/manager/channel-message/page',
{ params },
);
}

View File

@ -0,0 +1,63 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace ImManagerFacePackItemApi {
/** 表情项 */
export interface FacePackItem {
id: number;
packId: number;
url: string;
name?: string;
width: number;
height: number;
sort: number;
status: number;
createTime?: Date;
}
}
/** 获得表情分页 */
export function getManagerFacePackItemPage(params: PageParam) {
return requestClient.get<PageResult<ImManagerFacePackItemApi.FacePackItem>>(
'/im/manager/face-pack-item/page',
{ params },
);
}
/** 获得表情详情 */
export function getManagerFacePackItem(id: number) {
return requestClient.get<ImManagerFacePackItemApi.FacePackItem>(
'/im/manager/face-pack-item/get',
{ params: { id } },
);
}
/** 新增表情 */
export function createManagerFacePackItem(data: ImManagerFacePackItemApi.FacePackItem) {
return requestClient.post<number>('/im/manager/face-pack-item/create', data);
}
/** 修改表情 */
export function updateManagerFacePackItem(data: ImManagerFacePackItemApi.FacePackItem) {
return requestClient.put<boolean>(
'/im/manager/face-pack-item/update',
data,
);
}
/** 删除表情 */
export function deleteManagerFacePackItem(id: number) {
return requestClient.delete<boolean>('/im/manager/face-pack-item/delete', {
params: { id },
});
}
/** 批量删除表情 */
export function deleteManagerFacePackItemList(ids: number[]) {
return requestClient.delete<boolean>(
'/im/manager/face-pack-item/delete-list',
{ params: { ids: ids.join(',') } },
);
}

View File

@ -0,0 +1,55 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace ImManagerFacePackApi {
/** 表情包 */
export interface FacePack {
id: number;
name: string;
icon?: string;
sort: number;
status: number;
createTime?: Date;
}
}
/** 获得表情包分页 */
export function getManagerFacePackPage(params: PageParam) {
return requestClient.get<PageResult<ImManagerFacePackApi.FacePack>>(
'/im/manager/face-pack/page',
{ params },
);
}
/** 获得表情包详情 */
export function getManagerFacePack(id: number) {
return requestClient.get<ImManagerFacePackApi.FacePack>('/im/manager/face-pack/get', {
params: { id },
});
}
/** 新增表情包 */
export function createManagerFacePack(data: ImManagerFacePackApi.FacePack) {
return requestClient.post<number>('/im/manager/face-pack/create', data);
}
/** 修改表情包 */
export function updateManagerFacePack(data: ImManagerFacePackApi.FacePack) {
return requestClient.put<boolean>('/im/manager/face-pack/update', data);
}
/** 删除表情包 */
export function deleteManagerFacePack(id: number) {
return requestClient.delete<boolean>('/im/manager/face-pack/delete', {
params: { id },
});
}
/** 批量删除表情包 */
export function deleteManagerFacePackList(ids: number[]) {
return requestClient.delete<boolean>('/im/manager/face-pack/delete-list', {
params: { ids: ids.join(',') },
});
}

View File

@ -0,0 +1,33 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace ImManagerFaceUserItemApi {
/** 用户表情 */
export interface FaceUserItem {
id: number;
userId: number;
userNickname?: string;
url: string;
name?: string;
width?: number;
height?: number;
createTime?: Date;
}
}
/** 获得用户表情分页 */
export function getManagerFaceUserItemPage(params: PageParam) {
return requestClient.get<PageResult<ImManagerFaceUserItemApi.FaceUserItem>>(
'/im/manager/face-user-item/page',
{ params },
);
}
/** 删除用户表情 */
export function deleteManagerFaceUserItem(id: number) {
return requestClient.delete<boolean>('/im/manager/face-user-item/delete', {
params: { id },
});
}

View File

@ -0,0 +1,32 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace ImManagerFriendApi {
/** 好友关系 */
export interface Friend {
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 function getManagerFriendPage(params: PageParam) {
return requestClient.get<PageResult<ImManagerFriendApi.Friend>>(
'/im/manager/friend/page',
{ params },
);
}

View File

@ -0,0 +1,30 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace ImManagerFriendRequestApi {
/** 好友申请 */
export interface FriendRequest {
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 function getManagerFriendRequestPage(params: PageParam) {
return requestClient.get<PageResult<ImManagerFriendRequestApi.FriendRequest>>(
'/im/manager/friend-request/page',
{ params },
);
}

View File

@ -0,0 +1,87 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace ImManagerGroupApi {
/** 群 */
export interface Group {
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 GroupMember {
userId: number;
nickname?: string;
avatar?: string;
displayUserName?: string;
groupRemark?: string;
silent?: boolean;
status: number;
role?: number; // 成员角色,参见 ImGroupMemberRole 枚举
joinTime?: Date;
quitTime?: Date;
muteEndTime?: Date; // 禁言到期时间
}
/** 群封禁请求 */
export interface GroupBanReqVO {
id: number;
reason: string;
}
}
/** 获得群分页 */
export function getManagerGroupPage(params: PageParam) {
return requestClient.get<PageResult<ImManagerGroupApi.Group>>(
'/im/manager/group/page',
{ params },
);
}
/** 获得群详情 */
export function getManagerGroup(id: number) {
return requestClient.get<ImManagerGroupApi.Group>('/im/manager/group/get', {
params: { id },
});
}
/** 封禁群 */
export function banManagerGroup(data: ImManagerGroupApi.GroupBanReqVO) {
return requestClient.put<boolean>('/im/manager/group/ban', data);
}
/** 解封群 */
export function unbanManagerGroup(id: number) {
return requestClient.put<boolean>('/im/manager/group/unban', undefined, {
params: { id },
});
}
/** 解散群 */
export function dissolveManagerGroup(id: number) {
return requestClient.delete<boolean>('/im/manager/group/dissolve', {
params: { id },
});
}
/** 获得群成员列表(含已退群成员,由前端按需过滤) */
export function getManagerGroupMemberList(groupId: number) {
return requestClient.get<ImManagerGroupApi.GroupMember[]>(
'/im/manager/group/member/list',
{ params: { groupId } },
);
}

View File

@ -0,0 +1,33 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace ImManagerGroupRequestApi {
/** 加群申请 */
export interface GroupRequest {
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 function getManagerGroupRequestPage(params: PageParam) {
return requestClient.get<PageResult<ImManagerGroupRequestApi.GroupRequest>>(
'/im/manager/group-request/page',
{ params },
);
}

View File

@ -0,0 +1,40 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace ImManagerGroupMessageApi {
/** 群聊消息 */
export interface GroupMessage {
id: number;
clientMessageId?: string;
groupId: number;
groupName?: string;
senderId: number;
senderNickname?: string;
type: number;
content: string;
status: number;
atUserIds?: number[];
atUserNicknames?: (null | string)[];
receiptStatus: number;
sendTime: Date;
createTime: Date;
}
}
/** 获得群聊消息分页 */
export function getManagerGroupMessagePage(params: PageParam) {
return requestClient.get<PageResult<ImManagerGroupMessageApi.GroupMessage>>(
'/im/manager/message/group/page',
{ params },
);
}
/** 获得群聊消息详情 */
export function getManagerGroupMessage(id: number) {
return requestClient.get<ImManagerGroupMessageApi.GroupMessage>(
'/im/manager/message/group/get',
{ params: { id } },
);
}

View File

@ -0,0 +1,38 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace ImManagerPrivateMessageApi {
/** 私聊消息 */
export interface PrivateMessage {
id: number;
clientMessageId?: string;
senderId: number;
senderNickname?: string;
receiverId: number;
receiverNickname?: string;
type: number;
content: string;
status: number;
receiptStatus?: number;
sendTime: Date;
createTime: Date;
}
}
/** 获得私聊消息分页 */
export function getManagerPrivateMessagePage(params: PageParam) {
return requestClient.get<PageResult<ImManagerPrivateMessageApi.PrivateMessage>>(
'/im/manager/message/private/page',
{ params },
);
}
/** 获得私聊消息详情 */
export function getManagerPrivateMessage(id: number) {
return requestClient.get<ImManagerPrivateMessageApi.PrivateMessage>(
'/im/manager/message/private/get',
{ params: { id } },
);
}

View File

@ -0,0 +1,53 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace ImManagerRtcApi {
/** RTC 通话 */
export interface RtcCall {
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;
}
/** RTC 通话参与者 */
export interface RtcParticipant {
id: number;
callId: number;
userId: number;
userNickname?: string;
role: number;
status: number;
inviteTime: Date;
acceptTime?: Date;
leaveTime?: Date;
}
}
/** 获得通话记录分页 */
export function getManagerRtcCallPage(params: PageParam) {
return requestClient.get<PageResult<ImManagerRtcApi.RtcCall>>(
'/im/manager/rtc/page',
{ params },
);
}
/** 获得通话参与者列表 */
export function getManagerRtcCallParticipantList(id: number) {
return requestClient.get<ImManagerRtcApi.RtcParticipant[]>(
'/im/manager/rtc/participant-list',
{ params: { id } },
);
}

View File

@ -0,0 +1,60 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace ImManagerSensitiveWordApi {
/** 敏感词 */
export interface SensitiveWord {
id: number;
word: string;
status: number;
creator?: string;
creatorName?: string;
createTime?: Date;
}
}
/** 获得敏感词分页 */
export function getManagerSensitiveWordPage(params: PageParam) {
return requestClient.get<PageResult<ImManagerSensitiveWordApi.SensitiveWord>>(
'/im/manager/sensitive-word/page',
{ params },
);
}
/** 获得敏感词详情 */
export function getManagerSensitiveWord(id: number) {
return requestClient.get<ImManagerSensitiveWordApi.SensitiveWord>(
'/im/manager/sensitive-word/get',
{ params: { id } },
);
}
/** 新增敏感词 */
export function createManagerSensitiveWord(data: ImManagerSensitiveWordApi.SensitiveWord) {
return requestClient.post<number>('/im/manager/sensitive-word/create', data);
}
/** 修改敏感词 */
export function updateManagerSensitiveWord(data: ImManagerSensitiveWordApi.SensitiveWord) {
return requestClient.put<boolean>(
'/im/manager/sensitive-word/update',
data,
);
}
/** 删除敏感词 */
export function deleteManagerSensitiveWord(id: number) {
return requestClient.delete<boolean>('/im/manager/sensitive-word/delete', {
params: { id },
});
}
/** 批量删除敏感词 */
export function deleteManagerSensitiveWordList(ids: number[]) {
return requestClient.delete<boolean>(
'/im/manager/sensitive-word/delete-list',
{ params: { ids: ids.join(',') } },
);
}

View File

@ -0,0 +1,88 @@
import { requestClient } from '#/api/request';
export namespace ImManagerStatisticsApi {
/** 统计概览 */
export interface Overview {
totalUser: number;
newUserToday: number;
totalGroup: number;
newGroupToday: number;
activeUserDaily: number;
activeUserWeekly: number;
activeUserMonthly: number;
privateMessageToday: number;
groupMessageToday: number;
privateMessageYesterday: number;
groupMessageYesterday: number;
}
/** 趋势数据 */
export interface Trend {
dates: string[];
series: Record<string, number[]>;
}
/** 消息类型分布 */
export interface MessageType {
type: number; // 参见 ImContentTypeEnum 枚举类,由前端按 DICT_TYPE.IM_CONTENT_TYPE 翻译
value: number;
}
/** 群规模分布 */
export interface GroupSize {
range: string;
count: number;
}
/** 消息发送排行 */
export interface TopSender {
userId: number;
nickname: string;
messageCount: number;
}
}
/** 获得 KPI 概览 */
export function getStatisticsOverview() {
return requestClient.get<ImManagerStatisticsApi.Overview>(
'/im/manager/statistics/overview',
);
}
/** 获得消息趋势(私聊 + 群聊双线) */
export function getMessageTrend(days: number) {
return requestClient.get<ImManagerStatisticsApi.Trend>(
'/im/manager/statistics/message-trend',
{ params: { days } },
);
}
/** 获得用户趋势(新增注册 + 日活双线) */
export function getUserTrend(days: number) {
return requestClient.get<ImManagerStatisticsApi.Trend>(
'/im/manager/statistics/user-trend',
{ params: { days } },
);
}
/** 获得内容类型分布(最近 30 天) */
export function getMessageTypeDistribution() {
return requestClient.get<ImManagerStatisticsApi.MessageType[]>(
'/im/manager/statistics/message-type-distribution',
);
}
/** 获得群规模分布 */
export function getGroupSizeDistribution() {
return requestClient.get<ImManagerStatisticsApi.GroupSize[]>(
'/im/manager/statistics/group-size-distribution',
);
}
/** 获得消息 TOP 发送者(最近 30 天) */
export function getTopSenders() {
return requestClient.get<ImManagerStatisticsApi.TopSender[]>(
'/im/manager/statistics/top-senders',
);
}

View File

@ -0,0 +1,34 @@
import { requestClient } from '#/api/request';
export namespace ImChannelMessageApi {
/** 频道消息 */
export interface ChannelMessageRespVO {
id: number;
clientMessageId?: string;
channelId: number;
materialId: number;
type: number;
content: string;
receiptStatus?: number;
sendTime: string;
}
}
/** 拉取当前用户应收的频道消息(离线增量);按 minId 游标分页 */
export function pullChannelMessages(
params: { minId: number; size?: number },
signal?: AbortSignal,
) {
return requestClient.get<ImChannelMessageApi.ChannelMessageRespVO[]>(
'/im/channel/message/pull',
{ params, signal },
);
}
/** 上报频道消息已读位置;切到频道会话或拉到新消息后调 */
export function readChannelMessages(channelId: number, messageId: number) {
return requestClient.put<boolean>('/im/channel/message/read', undefined, {
params: { channelId, messageId },
});
}

View File

@ -0,0 +1,92 @@
import { requestClient } from '#/api/request';
export namespace ImGroupMessageApi {
/** 群聊消息 Response VO */
export interface GroupMessageRespVO {
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 GroupMessageSendReqVO {
clientMessageId: string; // 客户端消息编号
groupId: number; // 群编号
type: number; // 内容类型
content: string; // 消息内容JSON 格式)
atUserIds?: number[]; // @ 目标用户编号列表
receipt?: boolean; // 是否需要回执
}
/** 群聊历史消息列表 Request VO */
export interface GroupMessageListReqVO {
groupId: number | string; // 群编号
maxId?: number | string; // 起始消息编号(不含),为空则从最新消息开始
limit: number; // 拉取数量1 ~ 200
}
}
/** 发送群聊消息 */
export function sendGroupMessage(data: ImGroupMessageApi.GroupMessageSendReqVO) {
return requestClient.post<ImGroupMessageApi.GroupMessageRespVO>(
'/im/message/group/send',
data,
);
}
/** 拉取群聊消息(增量) */
export function pullGroupMessages(
params: { minId: number | string; size: number },
signal?: AbortSignal,
) {
return requestClient.get<ImGroupMessageApi.GroupMessageRespVO[]>(
'/im/message/group/pull',
{ params, signal },
);
}
/** 查询群聊历史消息 */
export function getGroupMessageList(params: ImGroupMessageApi.GroupMessageListReqVO) {
return requestClient.get<ImGroupMessageApi.GroupMessageRespVO[]>(
'/im/message/group/list',
{ params },
);
}
/** 标记群聊消息已读 */
export function readGroupMessages(
groupId: number | string,
messageId: number | string,
) {
return requestClient.put<boolean>('/im/message/group/read', undefined, {
params: { groupId, messageId },
});
}
/** 撤回群聊消息 */
export function recallGroupMessage(id: number | string) {
return requestClient.delete<ImGroupMessageApi.GroupMessageRespVO>(
'/im/message/group/recall',
{ params: { id } },
);
}
/** 获取群消息已读用户列表 */
export function getGroupReadUsers(params: {
groupId: number | string;
messageId: number | string;
}) {
return requestClient.get<number[]>('/im/message/group/get-read-user-ids', {
params,
});
}

View File

@ -0,0 +1,89 @@
import { requestClient } from '#/api/request';
export namespace ImPrivateMessageApi {
/** 私聊消息 Response VO */
export interface PrivateMessageRespVO {
id: number; // 消息编号
clientMessageId: string; // 客户端消息编号
senderId: number; // 发送人编号
receiverId: number; // 接收人编号
type: number; // 内容类型
content: string; // 消息内容JSON 格式)
status: number; // 消息状态(正常 / 已撤回)
receiptStatus?: number; // 回执状态(不需要 / 待完成 / 已完成),对齐 ImMessageReceiptStatus
sendTime: string; // 发送时间
}
/** 私聊消息发送 Request VO */
export interface PrivateMessageSendReqVO {
clientMessageId: string; // 客户端消息编号
receiverId: number; // 接收人编号
type: number; // 内容类型
content: string; // 消息内容JSON 格式)
receipt?: boolean; // 是否需要回执;不传后端默认 true普通私聊用户消息
}
/** 私聊历史消息列表 Request VO */
export interface PrivateMessageListReqVO {
receiverId: number | string; // 接收人编号(对方)
maxId?: number | string; // 起始消息编号(不含),为空则从最新消息开始
limit: number; // 拉取数量1 ~ 200
}
}
/** 发送私聊消息 */
export function sendPrivateMessage(data: ImPrivateMessageApi.PrivateMessageSendReqVO) {
return requestClient.post<ImPrivateMessageApi.PrivateMessageRespVO>(
'/im/message/private/send',
data,
);
}
/** 拉取私聊消息(增量) */
export function pullPrivateMessages(
params: { minId: number | string; size: number },
signal?: AbortSignal,
) {
return requestClient.get<ImPrivateMessageApi.PrivateMessageRespVO[]>(
'/im/message/private/pull',
{ params, signal },
);
}
/** 查询私聊历史消息 */
export function getPrivateMessageList(params: ImPrivateMessageApi.PrivateMessageListReqVO) {
return requestClient.get<ImPrivateMessageApi.PrivateMessageRespVO[]>(
'/im/message/private/list',
{ params },
);
}
/** 标记私聊消息已读 */
export function readPrivateMessages(
receiverId: number | string,
messageId: number | string,
) {
return requestClient.put<boolean>('/im/message/private/read', undefined, {
params: { receiverId, messageId },
});
}
/** 查询对方已读到我发的最大消息 id多端 / 离线后用于补齐已读状态) */
export function getPrivateMaxReadMessageId(
peerId: number | string,
signal?: AbortSignal,
) {
return requestClient.get<null | number>(
'/im/message/private/max-read-message-id',
{ params: { peerId }, signal },
);
}
/** 撤回私聊消息 */
export function recallPrivateMessage(id: number | string) {
return requestClient.delete<ImPrivateMessageApi.PrivateMessageRespVO>(
'/im/message/private/recall',
{ params: { id } },
);
}

View File

@ -0,0 +1,105 @@
import { requestClient } from '#/api/request';
export namespace ImRtcApi {
/** 创建新通话请求 VO */
export interface RtcCallCreateReqVO {
conversationType: number;
mediaType: number;
groupId?: number;
inviteeIds: number[]; // 被邀请的用户编号集合;私聊必传 1 个对端,群聊必传至少 1 人
}
/** 通话中追加邀请请求 VO仅群通话可用 */
export interface RtcCallInviteReqVO {
room: string;
inviteeIds: number[];
}
/** 通话会话 VOcreate / join / accept / refreshToken 共用 */
export interface RtcCallRespVO {
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 RtcGroupCallRespVO {
room: string;
groupId: number;
mediaType: number;
inviterId: number;
joinedUserIds?: number[];
inviteeIds?: number[];
}
}
/** 创建新通话;私聊或群聊根据 conversationType 区分 */
export function createCall(data: ImRtcApi.RtcCallCreateReqVO) {
return requestClient.post<ImRtcApi.RtcCallRespVO>('/im/rtc/create', data);
}
/** 通话中追加邀请;仅群通话可用 */
export function inviteCall(data: ImRtcApi.RtcCallInviteReqVO) {
return requestClient.post<boolean>('/im/rtc/invite', data);
}
/** 加入已有群通话;用于胶囊条「加入」按钮 */
export function joinCall(room: string) {
return requestClient.post<ImRtcApi.RtcCallRespVO>('/im/rtc/join', undefined, {
params: { room },
});
}
/** 接听通话 */
export function acceptCall(room: string) {
return requestClient.post<ImRtcApi.RtcCallRespVO>('/im/rtc/accept', undefined, {
params: { room },
});
}
/** 拒绝通话 */
export function rejectCall(room: string) {
return requestClient.post<boolean>('/im/rtc/reject', undefined, {
params: { room },
});
}
/** 取消邀请;主叫接通前调用 */
export function cancelCall(room: string) {
return requestClient.post<boolean>('/im/rtc/cancel', undefined, {
params: { room },
});
}
/** 离开通话;接通后调用 */
export function leaveCall(room: string) {
return requestClient.post<boolean>('/im/rtc/leave', undefined, {
params: { room },
});
}
/** 振铃超时检查RUNNING 端 timer 兜底,触发后端立即扫描该 room 的超时 INVITING接口静默 */
export function noAnswerCallCheck(room: string) {
return requestClient.post<boolean>(
'/im/rtc/no-answer-call-check',
undefined,
{ params: { room } },
);
}
/** 查询当前进行中的通话;目前仅群聊场景(胶囊条),返回 null 表示无活跃通话 */
export function getActiveCall(groupId: number) {
return requestClient.get<ImRtcApi.RtcGroupCallRespVO | null>(
'/im/rtc/get-active-call',
{ params: { groupId } },
);
}

View File

@ -9,16 +9,23 @@ export namespace SystemUserApi {
username: string; username: string;
nickname: string; nickname: string;
deptId: number; deptId: number;
deptName?: string;
postIds: string[]; postIds: string[];
email: string; email: string;
mobile: string; mobile: string;
sex: number; sex: number;
avatar: string; avatar: string;
loginIp: string; loginIp: string;
loginDate?: Date;
status: number; status: number;
remark: string; remark: string;
createTime?: Date; createTime?: Date;
} }
/** 用户精简信息 */
export interface UserSimple extends User {
id: number;
}
} }
/** 查询用户管理列表 */ /** 查询用户管理列表 */
@ -34,6 +41,13 @@ export function getUser(id: number) {
return requestClient.get<SystemUserApi.User>(`/system/user/get?id=${id}`); return requestClient.get<SystemUserApi.User>(`/system/user/get?id=${id}`);
} }
/** 查询用户列表 */
export function getUserList(ids: number[]) {
return requestClient.get<SystemUserApi.User[]>('/system/user/list', {
params: { ids: ids.join(',') },
});
}
/** 新增用户 */ /** 新增用户 */
export function createUser(data: SystemUserApi.User) { export function createUser(data: SystemUserApi.User) {
return requestClient.post('/system/user/create', data); return requestClient.post('/system/user/create', data);
@ -86,3 +100,20 @@ export function updateUserStatus(id: number, status: number) {
export function getSimpleUserList() { export function getSimpleUserList() {
return requestClient.get<SystemUserApi.User[]>('/system/user/simple-list'); return requestClient.get<SystemUserApi.User[]>('/system/user/simple-list');
} }
/** 按用户编号查询用户精简信息 */
export function getSimpleUser(id: number | string) {
return requestClient.get<SystemUserApi.UserSimple>('/system/user/get-simple', {
params: { id },
});
}
/** 按昵称模糊搜索用户 */
export function getSimpleUserListByNickname(nickname: string) {
return requestClient.get<SystemUserApi.UserSimple[]>(
'/system/user/list-by-nickname',
{
params: { nickname },
},
);
}

Binary file not shown.

View File

@ -12,13 +12,43 @@ interface DictTagProps {
icon?: string; // icon?: string; //
} }
type TagType = '' | 'danger' | 'info' | 'primary' | 'success' | 'warning';
type TagProps = { type?: Exclude<TagType, ''> };
const props = defineProps<DictTagProps>(); const props = defineProps<DictTagProps>();
/** 获取标签类型 */
function getTagType(colorType?: string): TagType {
switch (colorType) {
case 'danger': {
return 'danger';
}
case 'default': {
return '';
}
case 'info': {
return 'info';
}
case 'primary': {
return 'primary';
}
case 'success': {
return 'success';
}
case 'warning': {
return 'warning';
}
default: {
return '';
}
}
}
/** 获取字典标签 */ /** 获取字典标签 */
const dictTag = computed(() => { const dictTag = computed(() => {
const defaultDict = { const defaultDict: { colorType: TagType; label: string } = {
label: '', label: '',
colorType: 'primary', colorType: '',
}; };
// //
if (!props.type || props.value === undefined || props.value === null) { if (!props.type || props.value === undefined || props.value === null) {
@ -31,45 +61,20 @@ const dictTag = computed(() => {
return defaultDict; return defaultDict;
} }
//
let colorType = dict.colorType;
switch (colorType) {
case 'danger': {
colorType = 'danger';
break;
}
case 'info': {
colorType = 'info';
break;
}
case 'primary': {
colorType = 'primary';
break;
}
case 'success': {
colorType = 'success';
break;
}
case 'warning': {
colorType = 'warning';
break;
}
default: {
if (!colorType) {
colorType = 'primary';
}
}
}
return { return {
label: dict.label || '', label: dict.label || '',
colorType, colorType: getTagType(dict.colorType),
}; };
}); });
/** 获取标签属性 */
const tagProps = computed<TagProps>(() =>
dictTag.value.colorType ? { type: dictTag.value.colorType } : {},
);
</script> </script>
<template> <template>
<ElTag v-if="dictTag.label" :type="dictTag.colorType as any"> <ElTag v-if="dictTag.label" v-bind="tagProps">
{{ dictTag.label }} {{ dictTag.label }}
</ElTag> </ElTag>
</template> </template>

View File

@ -13,6 +13,7 @@ import {
AntdProfileOutlined, AntdProfileOutlined,
BookOpenText, BookOpenText,
CircleHelp, CircleHelp,
IconifyIcon,
SvgGithubIcon, SvgGithubIcon,
} from '@vben/icons'; } from '@vben/icons';
import { import {
@ -27,7 +28,7 @@ import { preferences, usePreferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores'; import { useAccessStore, useUserStore } from '@vben/stores';
import { formatDateTime, openWindow } from '@vben/utils'; import { formatDateTime, openWindow } from '@vben/utils';
import { ElMessage } from 'element-plus'; import { ElMessage, ElTooltip } from 'element-plus';
import { import {
getUnreadNotifyMessageCount, getUnreadNotifyMessageCount,
@ -156,6 +157,12 @@ function handleNotificationOpen(open: boolean) {
handleNotificationGetUnreadCount(); handleNotificationGetUnreadCount();
} }
/** 打开 IM 聊天 */
function handleOpenImHome() {
const { href } = router.resolve({ name: 'ImHome' });
window.open(href, '_blank');
}
// //
const tenants = ref<SystemTenantApi.Tenant[]>([]); const tenants = ref<SystemTenantApi.Tenant[]>([]);
const tenantEnable = computed( const tenantEnable = computed(
@ -276,6 +283,17 @@ watch(
/> />
</div> </div>
</template> </template>
<template #header-right-900>
<ElTooltip content="IM 聊天">
<button
class="hover:bg-accent hover:text-accent-foreground mr-1 inline-flex size-8 items-center justify-center rounded-md transition-colors"
type="button"
@click="handleOpenImHome"
>
<IconifyIcon class="size-4" icon="lucide:message-circle" />
</button>
</ElTooltip>
</template>
<template #extra> <template #extra>
<AuthenticationLoginExpiredModal <AuthenticationLoginExpiredModal
v-model:open="accessStore.loginExpired" v-model:open="accessStore.loginExpired"

View File

@ -0,0 +1,43 @@
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
path: '/im/home',
name: 'ImHome',
component: () => import('#/views/im/home/index.vue'),
redirect: '/im/home/conversation',
meta: {
title: 'IM 即时通讯',
hideInMenu: true,
hideInTab: true,
keepAlive: true,
noBasicLayout: true,
},
children: [
{
path: 'conversation',
name: 'ImHomeConversation',
component: () => import('#/views/im/home/pages/conversation/index.vue'),
meta: {
title: '消息',
activePath: '/im/home/conversation',
hideInMenu: true,
hideInTab: true,
},
},
{
path: 'contact',
name: 'ImHomeContact',
component: () => import('#/views/im/home/pages/contact/index.vue'),
meta: {
title: '通讯录',
activePath: '/im/home/contact',
hideInMenu: true,
hideInTab: true,
},
},
],
},
];
export default routes;

View File

@ -0,0 +1,68 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { isPrivateConversation } from '#/views/im/utils/constants'
import {
type CardMessage,
type CardTarget,
getCardLabelInfo
} from '#/views/im/utils/message'
import { UserAvatar } from '../user'
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>
<template>
<!--
名片消息气泡 / 名片预览卡240px用户名片 + 群名片通用
- 头像 + 名字 + 群成员数副标题仅群名片+ 底部分隔条群名片 / 个人名片
- 用户名片把 :id 传给 UserAvatar 让点击 avatar UserInfoCard群名片不传 id
- 整卡 click 由调用方监听@click组件不内嵌业务逻辑
-->
<div
class="flex flex-col w-[240px] rounded-md overflow-hidden bg-[var(--ant-color-bg-container)] border border-solid border-[var(--im-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(--ant-color-text)]">
{{ card.name }}
</div>
<div
v-if="!isUser && card.memberCount"
class="text-12px truncate text-[var(--ant-color-text-placeholder)]"
>
{{ card.memberCount }} 人群聊
</div>
</div>
</div>
<div
class="px-3 py-1 text-12px border-t border-t-solid text-[var(--ant-color-text-placeholder)] border-[var(--im-border-color-lighter)] bg-[var(--ant-color-fill-tertiary)]"
>
{{ labelInfo.label }}
</div>
</div>
</template>

View File

@ -0,0 +1,29 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { IconifyIcon as Icon } from '@vben/icons'
import { getCardLabelInfo } from '#/views/im/utils/message'
defineOptions({ name: 'ImCardLineLabel' })
const props = withDefaults(
defineProps<{
/** 名片数据;只读 targetType / name 派生标签 + 显示,结构性类型兼容 CardMessage / 引用预览的 partial */
card: null | undefined | { name?: string; targetType?: number; }
iconSize?: number
}>(),
{ iconSize: 14 }
)
/** 标签 + 图标按 targetType 二分;兜底「个人名片」避免 null 时 UI 空白 */
const labelInfo = computed(() => getCardLabelInfo(props.card))
</script>
<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>

View File

@ -0,0 +1,2 @@
export { default as CardBubble } from './card-bubble.vue';
export { default as CardLineLabel } from './card-line-label.vue';

View File

@ -0,0 +1,99 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { IconifyIcon as Icon } from '@vben/icons'
import { useImUiStore } from '../store/uiStore'
defineOptions({ name: 'ImContextMenu' })
const uiStore = useImUiStore()
const contextMenu = computed(() => uiStore.contextMenu)
/**
* 计算菜单实际渲染坐标靠近视口右 / 下边缘时回弹避免菜单被裁剪
*
* itemHeight / menuWidth 是和模板里 px-4 py-2 + text-13px / min-w-30 配套的实际尺寸
* dividerHeight = 9pxmy-1 上下各 4 + 1px border仅非首项的 divided 计入
* menuHeight 额外加 8 是外层 py-1 的上下 padding 之和4px × 2
*/
const adjustedPosition = computed(() => {
const items = contextMenu.value.items
const itemHeight = 34
const dividerCount = items.filter((it, i) => it.divided && i > 0).length
const menuHeight = items.length * itemHeight + dividerCount * 9 + 8
const menuWidth = 120
let x = contextMenu.value.position.x
let y = contextMenu.value.position.y
// SSR window
if (typeof window !== 'undefined') {
if (y + menuHeight > window.innerHeight) {
y = window.innerHeight - menuHeight
}
if (x + menuWidth > window.innerWidth) {
x = window.innerWidth - menuWidth
}
}
// / / 0
return { x: Math.max(0, x), y: Math.max(0, y) }
})
type MenuItem = (typeof contextMenu.value.items)[number]
/** 选中菜单项disabled 项忽略;正常项调 onSelect 回调后关闭菜单 */
function handleSelect(item: MenuItem) {
if (item.disabled) {
return
}
uiStore.contextMenu.onSelect?.(item)
uiStore.closeContextMenu()
}
/** 关闭菜单:点遮罩 / 在遮罩上再次右键都会触发 */
function handleClose() {
uiStore.closeContextMenu()
}
</script>
<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(--ant-color-bg-elevated)] 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(--ant-color-border-secondary)]"
></div>
<div
class="flex gap-2 items-center px-4 py-2 text-13px text-left cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]"
:class="[
item.disabled
? '!text-[var(--ant-color-text-disabled)] cursor-not-allowed hover:!bg-transparent'
: item.danger
? 'text-[#f56c6c]'
: 'text-[var(--ant-color-text)]'
]"
@click.stop="handleSelect(item)"
>
<Icon v-if="item.icon" :icon="item.icon" :size="14" />
<span>{{ item.name }}</span>
</div>
</template>
</div>
</div>
</teleport>
</template>

View File

@ -0,0 +1,289 @@
<script lang="ts" setup>
import type { SystemUserApi } from '#/api/system/user'
import { computed, ref } from 'vue'
import { IconifyIcon as Icon } from '@vben/icons'
import { useUserStore } from '@vben/stores'
import { ElButton, ElDialog, ElInput, ElMessage } from 'element-plus'
import { getSimpleUserListByNickname } from '#/api/system/user'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { ImFriendAddSource } from '../../../utils/constants'
import { getGenderColor, getGenderIcon } from '../../../utils/user'
import { useFriendStore } from '../../store/friendStore'
import { UserAvatar } from '../user'
defineOptions({ name: 'ImFriendAddDialog' })
const visible = ref(false) //
const presetUser = ref<null | SystemUserApi.UserSimple>(null) // 预填目标用户:非 null 时跳过搜索步骤,直接进入申请表单(群成员加好友 / 名片加好友等场景
const addSource = ref<number>(ImFriendAddSource.SEARCH) // ImFriendAddSourceEnum SEARCH
const addSourceExtra = ref<string>('') // addSource=ImFriendAddSource.GROUP XX YY
defineExpose({
/** 打开加好友弹窗reset → 灌参 → visible=true不传 opts 走搜索模式 */
open(opts?: { addSource?: number; addSourceExtra?: string; presetUser?: null | SystemUserApi.UserSimple; }) {
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()
/** 当前登录用户编号;用 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<SystemUserApi.UserSimple[]>([]) //
const searched = ref(false) //
const loading = ref(false) //
const step = ref<'apply' | 'search'>('search') // search=apply=
const targetUser = ref<null | SystemUserApi.UserSimple>(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.userInfo?.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: SystemUserApi.UserSimple) {
targetUser.value = user
const myNickname = userStore.userInfo?.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) {
ElMessage.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)
}
ElMessage.success(requestId ? '申请已发送,等待对方验证' : '已添加为好友')
visible.value = false
} catch {
// / /
visible.value = false
} finally {
submitting.value = false
}
}
</script>
<template>
<!--
添加好友对话框双层流程
- 第一层 search按昵称搜索用户列表
- 第二层 apply选中用户后展开申请添加朋友表单申请理由 + 备注
-->
<ElDialog
v-model="visible"
:content="dialogTitle"
width="480px"
:close-on-click-modal="false"
>
<!-- 第一层搜索 + 用户列表 -->
<template v-if="step === 'search'">
<ElInput
v-model="keyword"
placeholder="输入昵称回车搜索(最多展示 20 条)"
clearable
@keyup.enter="handleSearch"
>
<template #suffix>
<Icon icon="ant-design:search-outlined" class="cursor-pointer" @click="handleSearch" />
</template>
</ElInput>
<div v-loading="loading" class="w-full">
<div class="h-[400px] mt-2.5">
<div
v-if="visibleUsers.length === 0"
class="py-10 text-13px text-center text-[var(--ant-color-text-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(--ant-color-border-secondary)]"
>
<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(--ant-color-text)]"
>
<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(--ant-color-text-secondary)]"
>
{{ user.deptName }}
</div>
</div>
<!-- 已是好友显示已添加否则显示添加点击进入 apply 步骤 -->
<ElButton
v-if="!friendStore.isActiveFriend(user.id)"
type="primary"
size="small"
@click="enterApply(user)"
>
添加
</ElButton>
<ElButton v-else size="small" disabled>已添加</ElButton>
</div>
</div>
</div>
</template>
<!-- 第二层申请表单对齐微信申请添加朋友 -->
<template v-if="step === 'apply' && targetUser">
<!-- 选中的用户卡片 -->
<div
class="flex gap-3 items-center px-2 py-3 mb-4 rounded-md bg-[var(--ant-color-fill-secondary)]"
>
<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(--ant-color-text)] truncate">
{{ targetUser.nickname }}
</div>
<div
v-if="targetUser.deptName"
class="mt-0.5 text-xs truncate text-[var(--ant-color-text-secondary)]"
>
{{ targetUser.deptName }}
</div>
</div>
</div>
<div class="text-13px text-[var(--ant-color-text-secondary)] mb-1.5">发送添加朋友申请</div>
<ElInput
type="textarea"
v-model="applyContent"
:rows="3"
:maxlength="255"
show-count
placeholder="请填写申请理由"
/>
<div class="text-13px text-[var(--ant-color-text-secondary)] mt-3 mb-1.5">备注</div>
<ElInput
v-model="displayName"
:maxlength="16"
placeholder="给对方起个备注(仅自己可见,可不填)"
/>
</template>
<!-- 仅在 apply 步骤显示 footer 操作按钮slot 必须是 el-dialog 直接子节点 -->
<template v-if="step === 'apply'" #footer>
<!-- 预填模式无搜索步骤取消直接关闭弹窗 -->
<ElButton @click="presetMode ? (visible = false) : backToSearch()">取消</ElButton>
<ElButton type="primary" :loading="submitting" @click="handleSubmitApply"> </ElButton>
</template>
</ElDialog>
</template>

View File

@ -0,0 +1,79 @@
<script lang="ts" setup>
import type { FriendLite } from '../../types'
import { useImUiStore } from '../../store/uiStore'
import { UserAvatar } from '../user'
defineOptions({ name: 'ImFriendItem' })
const props = withDefaults(
defineProps<{
active?: boolean
friend: FriendLite
menu?: boolean //
}>(),
{
active: false,
menu: true
}
)
const emit = defineEmits<{
chat: [friend: FriendLite]
click: [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>
<template>
<!--
好友单行项
- 头像 + 昵称
- 选中态 active
- 右键菜单发消息 / 删除好友由全局 ContextMenu 承接
-->
<div
class="relative flex items-center gap-2.5 px-4 py-3 cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]"
:class="{ '!bg-[#d9ecff] dark:!bg-[var(--ant-color-primary-bg-hover)]': 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(--ant-color-text)]">
{{ friend.displayName || friend.nickname }}
</div>
</div>
<slot></slot>
</div>
</template>

View File

@ -0,0 +1,2 @@
export { default as FriendAddDialog } from './friend-add-dialog.vue';
export { default as FriendItem } from './friend-item.vue';

View File

@ -0,0 +1,122 @@
<script lang="ts" setup>
import type { GroupMemberLite } from './group-member.vue'
import { ref } from 'vue'
import { ElButton, ElDialog, ElMessage } from 'element-plus'
import { addGroupAdmin, removeGroupAdmin } from '#/api/im/group'
import { GROUP_ADMIN_MAX_COUNT } from '#/views/im/utils/config'
import { GroupMemberPickerPanel } from '../picker'
defineOptions({ name: 'ImGroupAdminSetDialog' })
const emit = defineEmits<{
/** 管理员变更成功;父侧通常用来 reload 群数据 */
reload: []
}>()
const visible = ref(false)
const submitting = ref(false)
const groupId = ref(0)
const members = ref<GroupMemberLite[]>([])
const currentAdminIds = ref<number[]>([]) // userId + diff
const hideIds = ref<number[]>([])
const maxSize = ref(GROUP_ADMIN_MAX_COUNT)
const selectedIds = ref<number[]>([])
defineExpose({
/** 打开设置管理员弹窗reset → 灌参 → visible=true */
open(opts: {
/** 当前管理员 userId 列表(默认勾选) */
currentAdminIds: number[]
groupId: number
/** 隐藏 userId群主 */
hideIds?: number[]
/** 已选数上限;不传走 GROUP_ADMIN_MAX_COUNT */
maxSize?: number
members: GroupMemberLite[]
}) {
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 })
}
ElMessage.success(`已更新群管理员(新增 ${addedIds.length} 位,撤销 ${removedIds.length} 位)`)
emit('reload')
visible.value = false
} finally {
submitting.value = false
}
}
</script>
<template>
<!--
设置群管理员一个弹窗合并增 / 提交时跟当前管理员列表 diff
- dialog 壳本组件持有选择 UI 委托 GroupMemberPickerPanelgrid 形态对齐当前视觉
- 群主从候选里隐藏不能设为管理员
- 对外接口ref + open({ groupId, members, currentAdminIds, hideIds, maxSize }) + emit reload()
-->
<ElDialog
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>
<ElButton @click="visible = false">取消</ElButton>
<ElButton type="primary" :loading="submitting" @click="handleOk"></ElButton>
</template>
</ElDialog>
</template>
<style scoped lang="scss">
@use '../picker/picker-dialog' as picker;
/* :deep 穿透 el-dialog 内部类;复用 picker 公共 mixin */
.im-picker-dialog {
@include picker.styles;
}
</style>

View File

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

View File

@ -0,0 +1,144 @@
<script lang="ts" setup>
import type { FriendLite } from '../../types'
import { computed, ref } from 'vue'
import { ElButton, ElDialog, ElMessage } from 'element-plus'
import { createGroup } from '#/api/im/group'
import { buildDefaultGroupName } from '../../../utils/group'
import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore'
import { FriendPickerPanel } from '../picker'
defineOptions({ name: 'ImGroupCreateDialog' })
const emit = defineEmits<{
/** 创建成功,携带新群编号;父侧通常用来跳转到新群会话 */
created: [groupId: number]
}>()
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
})
ElMessage.success('群聊创建成功')
emit('created', group.id)
visible.value = false
} finally {
submitting.value = false
}
}
</script>
<template>
<!--
发起群聊选好友 默认按所选成员名生成群名 createGroup
- dialog 壳本组件持有选择 UI 委托 FriendPickerPanel
- lockedIds 由调用方通过 open({ lockedIds }) 传入私聊侧 +建群锁定对方
- 不再要求先输入群名 / 不再展示进群审批开关对齐微信 PC
- 对外接口ref + open({ lockedIds }) + emit created(groupId)
-->
<ElDialog
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>
<ElButton @click="visible = false">取消</ElButton>
<ElButton type="primary" :loading="submitting" :disabled="!canSubmit" @click="handleOk">
完成
</ElButton>
</template>
</ElDialog>
</template>
<style scoped lang="scss">
@use '../picker/picker-dialog' as picker;
/* :deep 穿透 el-dialog 内部类;复用 picker 公共 mixin */
.im-picker-dialog {
@include picker.styles;
}
</style>

View File

@ -0,0 +1,116 @@
<script lang="ts" setup>
import type { GroupLite } from '../../types'
import { computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { prompt } from '@vben/common-ui'
import { ElInput, ElMessage } from 'element-plus'
import { applyJoinGroup } from '#/api/im/group/request'
import { ImConversationType, ImGroupAddSource } from '../../../utils/constants'
import { getGroupDisplayName } from '../../../utils/user'
import { useConversationStore } from '../../store/conversationStore'
import { useGroupStore } from '../../store/groupStore'
import { useImUiStore } from '../../store/uiStore'
import GroupInfo from './group-info.vue'
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: string
try {
const result = await prompt<string>({
cancelText: '取消',
component: ElInput,
componentProps: {
clearable: true,
placeholder: '请填写验证消息(可选)'
},
content: '',
defaultValue: '',
confirmText: '发送申请',
modelPropName: 'value',
title: `申请加入「${group.name || ''}`
})
applyContent = (result || '').trim()
} catch {
return
}
await applyJoinGroup({
groupId: group.id,
applyContent: applyContent || undefined,
addSource: ImGroupAddSource.SHARE_LINK
})
ElMessage.success('加群申请已发送')
}
</script>
<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(--ant-color-bg-elevated)] 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>

View File

@ -0,0 +1,154 @@
<script lang="ts" setup>
import type { Friend, GroupLite, GroupMember } from '../../types'
import type { GroupMemberLite } from './group-member.vue'
import { computed, ref, watch } from 'vue'
import { CommonStatusEnum } from '@vben/constants'
import { ElButton } from 'element-plus'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { getMemberDisplayName, isGroupQuit } from '../../../utils/user'
import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore'
import GroupAvatar from './group-avatar.vue'
import GroupMemberGrid from './group-member-grid.vue'
defineOptions({ name: 'ImGroupInfo' })
const props = defineProps<{
group: GroupLite
}>()
const emit = defineEmits<{
/** stranger 点「加入群聊」;父级负责弹申请理由 + 调 applyJoinGroup */
apply: [group: GroupLite]
/** member 点「进入群聊」;父级负责切会话 + 关浮层 */
chat: [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
}
// 退 false
if (isGroupQuit(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
})
/** 历史退群群:只读,动作区两个按钮都不渲染(既不「进入群聊」也不「加入群聊」) */
const isQuitGroup = computed(() => {
const id = props.group?.id
return id != null && isGroupQuit(groupStore.getGroup(id))
})
/** 是否未加群:有 id、非成员、且非历史退群群只有真·陌生人才给「加入群聊」 */
const isStranger = computed(() => !!props.group?.id && !isMember.value && !isQuitGroup.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, true)
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>
<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(--ant-color-text)] truncate text-center"
>
{{ group.showGroupName || group.name }}
</div>
<div v-if="memberCountText" class="text-13px text-[var(--ant-color-text-secondary)]">
{{ memberCountText }}
</div>
<!-- 成员宫格 member 渲染陌生群拉不到成员 -->
<div
v-if="isMember && members.length > 0"
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">
<ElButton type="primary" @click="emit('chat', group)">进入群聊</ElButton>
</div>
<div v-else-if="isStranger" class="mt-4">
<ElButton type="primary" @click="emit('apply', group)">加入群聊</ElButton>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,43 @@
<script lang="ts" setup>
import type { GroupLite } from '../../types'
import GroupAvatar from './group-avatar.vue'
defineOptions({ name: 'ImGroupItem' })
defineProps<{
active?: boolean
group: GroupLite
}>()
defineEmits<{
click: [group: GroupLite]
}>()
</script>
<template>
<!--
群单行项
- 头像 + 群名
- 选中态 active
-->
<div
class="relative flex items-center gap-2.5 px-4 py-3 cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]"
:class="{ '!bg-[#d9ecff] dark:!bg-[var(--ant-color-primary-bg-hover)]': 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(--ant-color-text)]">
{{ group.showGroupName || group.name }}
</div>
</div>
<slot></slot>
</div>
</template>

View File

@ -0,0 +1,167 @@
<script lang="ts" setup>
import type { GroupMemberLite } from './group-member.vue'
import { computed, ref } from 'vue'
import { CommonStatusEnum } from '@vben/constants'
import { ElButton, ElDialog, ElMessage } from 'element-plus'
import { inviteGroupMember } from '#/api/im/group/member'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { GROUP_MAX_MEMBER } from '#/views/im/utils/config'
import { ImGroupMemberRole } from '#/views/im/utils/constants'
import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore'
import { FriendPickerPanel } from '../picker'
defineOptions({ name: 'ImGroupMemberAddDialog' })
const emit = defineEmits<{
/** 邀请成功,携带被邀请的好友 id 列表;父侧通常用来 reload 群成员 */
reload: [friendIds: number[]]
}>()
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) {
ElMessage.warning(`群成员上限为 ${GROUP_MAX_MEMBER}`)
return
}
submitting.value = true
try {
await inviteGroupMember({ groupId: groupId.value, memberUserIds })
//
ElMessage.success(willGoApproval.value ? '邀请已发起,等待群主 / 管理员审批' : '邀请成功')
emit('reload', memberUserIds)
visible.value = false
} finally {
submitting.value = false
}
}
</script>
<template>
<!--
添加群成员选好友邀请入群已在群成员置灰不计入已选
- dialog 壳本组件持有选择 UI 委托 FriendPickerPanel
- 已在群成员通过 disabledIds 传入不再走 checked+disabled "已勾选灰态"
- 对外接口ref + open({ groupId }) + emit reload(friendIds)
-->
<ElDialog
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>
<ElButton @click="visible = false">取消</ElButton>
<ElButton type="primary" :loading="submitting" :disabled="!canSubmit" @click="handleOk">
添加
</ElButton>
</template>
</ElDialog>
</template>
<style scoped lang="scss">
@use '../picker/picker-dialog' as picker;
/* :deep 穿透 el-dialog 内部类;复用 picker 公共 mixin */
.im-picker-dialog {
@include picker.styles;
}
</style>

View File

@ -0,0 +1,50 @@
<script lang="ts" setup>
import type { GroupMemberLite } from './group-member.vue'
import { ImFriendAddSource } from '../../../utils/constants'
import { UserAvatar } from '../user'
defineOptions({ name: 'ImGroupMemberGrid' })
withDefaults(
defineProps<{
clickable?: boolean // UserInfoCard
groupName?: string // 'XX ' YY add_source=GROUP
member: GroupMemberLite
size?: number // 38 50 PC
}>(),
{
clickable: false,
size: 38,
groupName: ''
}
)
</script>
<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(--ant-color-text)]"
>
{{ member.showName }}
</div>
<slot></slot>
</div>
</template>

View File

@ -0,0 +1,84 @@
<script lang="ts" setup>
import type { GroupMemberLite } from './group-member.vue'
import { computed } from 'vue'
import { DICT_TYPE } from '@vben/constants'
import { getDictLabel } from '@vben/hooks'
import { ImGroupMemberRole } from '../../../utils/constants'
import { UserAvatar } from '../user'
defineOptions({ name: 'ImGroupMemberItem' })
const props = withDefaults(
defineProps<{
active?: boolean
height?: number
member: GroupMemberLite
}>(),
{
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(--ant-color-primary)] bg-[var(--ant-color-primary-bg)]'
}
return 'text-[var(--ant-color-info)] bg-[var(--ant-color-fill)]'
})
</script>
<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(--ant-color-fill)]"
:class="{ '!bg-[#e1eaf7] dark:!bg-[var(--ant-color-primary-bg-hover)]': 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(--ant-color-text)]"
: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>

View File

@ -0,0 +1,105 @@
<script lang="ts" setup>
import type { GroupMemberLite } from './group-member.vue'
import { ref } from 'vue'
import { ElButton, ElDialog, ElMessage } from 'element-plus'
import { removeGroupMember } from '#/api/im/group/member'
import { GroupMemberPickerPanel } from '../picker'
defineOptions({ name: 'ImGroupMemberRemoveDialog' })
const emit = defineEmits<{
/** 移出成功;父侧通常用来 reload 群数据 */
reload: []
}>()
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
/** 隐藏 userId群主始终隐藏管理员视角额外隐藏其它管理员 */
hideIds?: number[]
members: GroupMemberLite[]
}) {
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 })
ElMessage.success(`已移除 ${memberUserIds.length} 位成员`)
emit('reload')
visible.value = false
} finally {
submitting.value = false
}
}
</script>
<template>
<!--
移除群成员选成员 removeGroupMember 批量踢人
- dialog 壳本组件持有选择 UI 委托 GroupMemberPickerPanel
- 群主 / 管理员视角的不可移除成员通过 hideIds 隐藏由调用方传入
- 对外接口ref + open({ groupId, members, hideIds }) + emit reload()
-->
<ElDialog
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>
<ElButton @click="visible = false">取消</ElButton>
<ElButton
type="primary"
:loading="submitting"
:disabled="selectedIds.length === 0"
@click="handleOk"
>
移出
</ElButton>
</template>
</ElDialog>
</template>
<style scoped lang="scss">
@use '../picker/picker-dialog' as picker;
/* :deep 穿透 el-dialog 内部类;复用 picker 公共 mixin */
.im-picker-dialog {
@include picker.styles;
}
</style>

View File

@ -0,0 +1,64 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { ImFriendAddSource } from '../../../utils/constants'
import { UserAvatar } from '../user'
defineOptions({ name: 'ImGroupMember' })
const props = withDefaults(
defineProps<{
active?: boolean // @
clickable?: boolean // UserInfoCard@
groupName?: string // 'XX ' YY add_source=GROUP
height?: number // px
member: GroupMemberLite
}>(),
{
height: 50,
active: false,
clickable: false,
groupName: ''
}
)
/** 群成员结构(跨多处使用,放这里做窄接口;独立于 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 avatarSize = computed(() => Math.ceil(props.height * 0.75))
</script>
<template>
<!--
群成员单行
跨子域复用@候选 (MentionPicker) / 已读列表 (MessageReadStatus) / 群成员宫格 (ConversationGroupSide)
-->
<div
class="relative flex items-center px-[5px] box-border whitespace-nowrap"
:class="{ 'bg-[#e1eaf7] dark:bg-[var(--ant-color-primary-bg)]': 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(--ant-color-text)]"
:style="{ lineHeight: `${height }px` }"
>
{{ member.showName }}
</div>
</div>
</template>

View File

@ -0,0 +1,97 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { ElButton, ElDialog, ElMessage } from 'element-plus'
import { muteMember } from '#/api/im/group'
defineOptions({ name: 'ImGroupMuteMemberDialog' })
const emit = defineEmits<{
success: []
}>()
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: 43_200 },
{ label: '1 天', value: 86_400 },
{ label: '7 天', value: 604_800 },
{ label: '30 天', value: 2_592_000 },
{ 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
})
ElMessage.success('禁言成功')
visible.value = false
emit('success')
} finally {
loading.value = false
}
}
defineExpose({ open })
</script>
<template>
<!-- 禁言时长选择弹窗 -->
<ElDialog 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(--ant-color-fill-secondary)]"
>
<span class="text-13px text-[var(--ant-color-text-secondary)]">禁言成员</span>
<span
class="text-sm font-medium text-[var(--ant-color-text)] truncate"
>
{{ memberName }}
</span>
</div>
<!-- 禁言时长选项 el-button 平铺选中走 primary gap-2 留间距 -->
<div>
<div class="mb-2 text-13px text-[var(--ant-color-text-secondary)]">禁言时长</div>
<div class="grid grid-cols-3 gap-2">
<ElButton
v-for="opt in presets"
:key="opt.value"
:type="selected === opt.value ? 'primary' : undefined"
class="!ml-0 w-full"
@click="selected = opt.value"
>
{{ opt.label }}
</ElButton>
</div>
</div>
</div>
<template #footer>
<ElButton @click="visible = false">取消</ElButton>
<ElButton type="primary" :loading="loading" @click="handleConfirm"></ElButton>
</template>
</ElDialog>
</template>

View File

@ -0,0 +1,126 @@
<script lang="ts" setup>
import type { GroupMemberLite } from './group-member.vue'
import { computed, ref } from 'vue'
import { confirm } from '@vben/common-ui'
import { ElButton, ElDialog, ElMessage } from 'element-plus'
import { transferGroupOwner } from '#/api/im/group'
import { GroupMemberPickerPanel } from '../picker'
defineOptions({ name: 'ImGroupOwnerTransferDialog' })
const emit = defineEmits<{
/** 转让成功;父侧通常用来 reload 群数据 */
reload: []
}>()
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
/** 隐藏 userId当前用户不能转给自己 */
hideIds?: number[]
members: GroupMemberLite[]
}) {
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 confirm(
`确定将群主转让给 ${newOwner.value.showName}?转让后你将变为普通成员,无法撤销。`,
'确认转让群主'
)
} catch {
return
}
submitting.value = true
try {
await transferGroupOwner({
id: groupId.value,
newOwnerUserId: newOwner.value.userId
})
ElMessage.success('群主转让成功')
emit('reload')
visible.value = false
} finally {
submitting.value = false
}
}
</script>
<template>
<!--
转让群主 1 位新群主 二次确认 transferGroupOwner
- dialog 壳本组件持有选择 UI 委托 GroupMemberPickerPanel
- 当前用户从候选里隐藏不能转给自己
- maxSize=1 限定单选
- 对外接口ref + open({ groupId, members, hideIds }) + emit reload()
-->
<ElDialog
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>
<ElButton @click="visible = false">取消</ElButton>
<ElButton
type="primary"
:loading="submitting"
:disabled="selectedIds.length === 0"
@click="handleOk"
>
确定
</ElButton>
</template>
</ElDialog>
</template>
<style scoped lang="scss">
@use '../picker/picker-dialog' as picker;
/* :deep 穿透 el-dialog 内部类;复用 picker 公共 mixin */
.im-picker-dialog {
@include picker.styles;
}
</style>

View File

@ -0,0 +1,377 @@
<script lang="ts" setup>
import type { ImGroupRequestApi } from '#/api/im/group/request'
import { computed, ref, watch } from 'vue'
import { prompt } from '@vben/common-ui'
import { ElDialog, ElEmpty, ElInput, ElMessage } from 'element-plus'
import { getGroupRequestListByGroupId } from '#/api/im/group/request'
import { ImGroupRequestHandleResult } from '#/views/im/utils/constants'
import { useGroupRequestStore } from '../../store/groupRequestStore'
import { UserAvatar } from '../user'
defineOptions({ name: 'ImGroupRequestListDialog' })
const groupRequestStore = useGroupRequestStore()
const visible = ref(false)
const groupId = ref<number | undefined>() // undefined store.unhandledList
const loading = ref(false)
const groupList = ref<ImGroupRequestApi.GroupRequestRespVO[]>([])
const actingId = ref<null | number>(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<ImGroupRequestApi.GroupRequestRespVO[]>(() =>
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: ImGroupRequestApi.GroupRequestRespVO) {
if (actingId.value !== null) return
actingId.value = item.id
try {
await groupRequestStore.agreeGroupRequest(item.id)
updateLocalResult(item.id, ImGroupRequestHandleResult.AGREED)
ElMessage.success('已同意')
} finally {
actingId.value = null
}
}
/** 拒绝:弹理由输入框;为空则不带 handleContent */
async function handleRefuse(item: ImGroupRequestApi.GroupRequestRespVO) {
if (actingId.value !== null) return
let handleContent: string
try {
const result = await prompt<string>({
component: ElInput,
componentProps: {
clearable: true,
placeholder: '请输入拒绝理由(可选)'
},
content: '',
modelPropName: 'value',
title: '拒绝申请'
})
handleContent = result || ''
} catch {
return
}
actingId.value = item.id
try {
await groupRequestStore.refuseGroupRequest(item.id, handleContent || undefined)
updateLocalResult(item.id, ImGroupRequestHandleResult.REFUSED)
ElMessage.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>
<template>
<!--
进群申请列表对话框
- 仅群主 / 管理员入口可达展示当前群下全部申请含已处理
- 顶部最新一条卡片化突出带申请理由其余按 id 倒序紧凑列表
- 同意 / 拒绝走 groupRequestStore action处理后本地更新 handleResult 让按钮转灰态
-->
<ElDialog
v-model="visible"
title="进群申请"
width="560px"
:close-on-click-modal="false"
class="im-group-request-list__dialog"
>
<div v-loading="loading" class="w-full">
<div class="flex flex-col gap-3 max-h-[60vh] overflow-y-auto pr-1">
<!-- 空态 -->
<ElEmpty 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(--ant-color-border-secondary)] bg-[var(--ant-color-bg-container)] 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(--ant-color-text)]"
>
{{ latest.userNickname || `用户 ${latest.userId}` }}
</div>
<div
class="truncate mt-[2px] text-12px leading-[1.5] text-[var(--ant-color-text-secondary)]"
>
<template v-if="latest.inviterUserId">
通过
<span class="text-[var(--ant-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(--ant-color-text-placeholder)]"
>
已同意
</span>
<span
v-else-if="latest.handleResult === ImGroupRequestHandleResult.REFUSED"
class="flex-shrink-0 text-[13px] text-[var(--ant-color-text-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(--ant-color-fill-secondary)] text-[var(--ant-color-text)]"
>
<span class="text-[var(--ant-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(--ant-color-text-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(--ant-color-border-secondary)] bg-[var(--ant-color-bg-container)] 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(--ant-color-text)]"
>
{{ item.userNickname || `用户 ${item.userId}` }}
</div>
<div
class="truncate mt-[2px] text-12px leading-[1.5] text-[var(--ant-color-text-secondary)]"
>
<template v-if="item.inviterUserId">
通过
<span class="text-[var(--ant-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(--ant-color-text-placeholder)]"
>
已同意
</span>
<span
v-else-if="item.handleResult === ImGroupRequestHandleResult.REFUSED"
class="flex-shrink-0 text-[13px] text-[var(--ant-color-text-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>
</div>
</ElDialog>
</template>
<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(--ant-color-primary);
border-color: var(--ant-color-primary);
}
.im-group-request-list__btn--primary:hover:not(:disabled) {
background-color: var(--ant-color-primary-hover);
border-color: var(--ant-color-primary-hover);
}
.im-group-request-list__btn--ghost {
color: var(--ant-color-text);
background-color: var(--ant-color-bg-container);
border-color: var(--ant-color-border);
}
.im-group-request-list__btn--ghost:hover:not(:disabled) {
color: var(--ant-color-primary);
border-color: var(--ant-color-primary);
}
</style>
<style>
/* el-dialog 内部 body 通过 teleport 渲染到 bodyscoped 选不到,留非 scoped 全局覆盖 */
.im-group-request-list__dialog .el-dialog__body {
padding: 12px 20px 8px;
background-color: var(--ant-color-fill-secondary);
}
</style>

View File

@ -0,0 +1,15 @@
export { default as GroupAdminSetDialog } from './group-admin-set-dialog.vue';
export { default as GroupAvatar } from './group-avatar.vue';
export { default as GroupCreateDialog } from './group-create-dialog.vue';
export { default as GroupInfoCard } from './group-info-card.vue';
export { default as GroupInfo } from './group-info.vue';
export { default as GroupItem } from './group-item.vue';
export { default as GroupMemberAddDialog } from './group-member-add-dialog.vue';
export { default as GroupMemberGrid } from './group-member-grid.vue';
export { default as GroupMemberItem } from './group-member-item.vue';
export { default as GroupMemberRemoveDialog } from './group-member-remove-dialog.vue';
export { default as GroupMember } from './group-member.vue';
export type { GroupMemberLite } from './group-member.vue';
export { default as GroupMuteMemberDialog } from './group-mute-member-dialog.vue';
export { default as GroupOwnerTransferDialog } from './group-owner-transfer-dialog.vue';
export { default as GroupRequestListDialog } from './group-request-list-dialog.vue';

View File

@ -0,0 +1,4 @@
export { default as ContextMenu } from './context-menu.vue';
export { default as PagedScroller } from './paged-scroller.vue';
export { default as ResizableAside } from './resizable-aside.vue';
export { default as ToolBar } from './tool-bar.vue';

View File

@ -0,0 +1,109 @@
<script lang="ts" setup generic="T">
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'
defineOptions({ name: 'ImPagedScroller' })
const props = withDefaults(
defineProps<{
itemKey?: string // 业务 id 字段名(如 'userId' / 'id'不传 / 字段值非 string|number 时回退 idx
items: T[] //
pageSize?: number //
threshold?: number // px
}>(),
{
itemKey: undefined,
pageSize: 30,
threshold: 30
}
)
/** 解析每条 item 的 :keycaller 传 itemKey 则按字段取,无效 / 缺失回退索引,避免传错字段时全表 undefined key */
function resolveItemKey(item: T, idx: number): number | string {
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<HTMLDivElement>('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)
let wrapEl: HTMLElement | null = null
onMounted(() => {
wrapEl = scrollbarRef.value
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>
<template>
<!--
分页增量滚动容器
- 滚到底部自动 page++直到全部渲染完
- 通过 slot 暴露每一项让调用方自己决定渲染
-->
<div ref="scrollbarRef" class="w-full h-full overflow-y-auto">
<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(--ant-color-text-secondary)]"
>
已到底部
</div>
</div>
</template>

View File

@ -0,0 +1,370 @@
<script lang="ts" setup>
import type { Conversation } from '../../types'
import { computed, ref } from 'vue'
import { IconifyIcon as Icon } from '@vben/icons'
import { ElInput, ElMessage } from 'element-plus'
import { ImConversationType } from '../../../utils/constants'
import { filterConversationsByKeyword, getConversationKey } from '../../../utils/conversation'
import { GroupAvatar } from '../group'
import { UserAvatar } from '../user'
defineOptions({ name: 'ImConversationPickerPanel' })
const props = withDefaults(
defineProps<{
/** 全量会话列表 */
conversations: Conversation[]
/** 隐藏 key从候选 / 已选 / 最近转发里都剔除(不能转发回自己、推荐名片自身的会话等) */
hideKeys?: string[]
/** 已选数上限;不传或 <=0 时不限 */
maxSize?: number
/** 最近转发会话 key 列表;展示在左栏顶部横向头像区 */
recentForwardConversationKeys?: string[]
/** 已选会话 keyv-modelkey 由 getConversationKey 生成 */
selectedKeys: string[]
/** 是否展示「创建聊天」入口 */
showCreateChat?: boolean
}>(),
{
recentForwardConversationKeys: () => [],
hideKeys: () => [],
maxSize: 0,
showCreateChat: false
}
)
const emit = defineEmits<{
createChat: []
/** 用户在「最近转发」段进入移除模式后点 ×;业务壳收到后调 conversationStore.removeRecentForwardConversationKey 落盘 */
removeRecent: [key: string]
'update:selectedKeys': [value: string[]]
}>()
const keyword = ref('')
const recentRemoveMode = ref(false) // true ×
/** 全量会话的 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 === -1) {
// 便
if (hideSet.value.has(key)) {
return
}
if (props.maxSize > 0 && next.length >= props.maxSize) {
ElMessage.error(`最多选择 ${props.maxSize} 个会话`)
return
}
next.push(key)
} else {
next.splice(index, 1)
}
emit('update:selectedKeys', next)
}
</script>
<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(--ant-color-border-secondary)] bg-[var(--ant-color-fill-secondary)]"
>
<!-- 搜索框 -->
<div class="flex-shrink-0 px-3 py-2">
<ElInput v-model="keyword" placeholder="搜索" clearable>
<template #prefix>
<Icon icon="ant-design:search-outlined" />
</template>
</ElInput>
</div>
<div 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(--ant-color-text-secondary)]">最近转发</span>
<span
class="px-1 cursor-pointer text-13px text-[var(--ant-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(--ant-color-fill-dark)] text-[var(--ant-color-text)]"
@click.stop="emit('removeRecent', 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(--ant-color-border)] bg-[var(--ant-color-bg-container)]'
"
>
<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(--ant-color-text)]"
>
{{ 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(--ant-color-fill)]"
@click="emit('createChat')"
>
<span
class="flex flex-shrink-0 justify-center items-center w-8 h-8 rounded-full bg-[var(--ant-color-fill)] text-[var(--ant-color-text-secondary)]"
>
<Icon icon="ant-design:plus-outlined" :size="16" />
</span>
<span class="text-sm text-[var(--ant-color-text)]">创建聊天</span>
</div>
<!-- 最近聊天分组标题 -->
<div class="px-3 pb-1.5 text-13px text-[var(--ant-color-text-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(--ant-color-fill)]"
@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(--ant-color-border)] bg-[var(--ant-color-bg-container)]'
"
>
<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(--ant-color-text)]"
>
{{ conversation.name }}
</span>
</div>
<!-- 空态 -->
<div
v-if="shownConversations.length === 0"
class="py-10 text-13px text-center text-[var(--ant-color-text-disabled)]"
>
{{ keyword ? '没有满足条件的会话' : '暂无会话' }}
</div>
</div>
</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(--ant-color-text-secondary)] border-[var(--ant-color-border-secondary)]"
>
{{ sendTitle }}
</div>
<!-- 已选预览 selectedKeys 数组顺序点击顺序展示 -->
<div 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(--ant-color-text)]"
>
{{ conversation.name }}
</span>
<Icon
icon="ant-design:close-outlined"
:size="14"
class="flex-shrink-0 cursor-pointer transition-colors text-[var(--ant-color-text-placeholder)] hover:text-[var(--ant-color-error)]"
@click="handleToggle(conversation)"
/>
</div>
<div
v-if="selectedConversations.length === 0"
class="py-10 text-13px text-center text-[var(--ant-color-text-disabled)]"
>
从左侧选择好友或群聊
</div>
</div>
<!-- 业务壳塞预览卡 / 留言 / 提交按钮的位置 -->
<div
v-if="$slots.footer"
class="flex-shrink-0 border-t border-t-solid border-[var(--ant-color-border-secondary)]"
>
<slot name="footer"></slot>
</div>
</div>
</div>
</template>
<style scoped>
/* 横向滚动条做窄一点避免占视觉;走 ::-webkit-scrollbar 浏览器伪元素 */
.im-conversation-picker__recent::-webkit-scrollbar {
height: 4px;
}
.im-conversation-picker__recent::-webkit-scrollbar-thumb {
background-color: var(--ant-color-border);
border-radius: 2px;
}
</style>

View File

@ -0,0 +1,245 @@
<script lang="ts" setup>
import type { FriendLite } from '../../types'
import { computed, ref } from 'vue'
import { IconifyIcon as Icon } from '@vben/icons'
import { ElInput, ElMessage } from 'element-plus'
import { PagedScroller } from '..'
import { useFriendBuckets } from '../../composables/useFriendBuckets'
import { useSelectedItems } from '../../composables/useSelectedItems'
import { UserAvatar } from '../user'
defineOptions({ name: 'ImFriendPickerPanel' })
const props = withDefaults(
defineProps<{
/** 禁用 id列表里展示置灰、不可勾选、不计入已选数典型邀请入群时已在群成员 */
disabledIds?: number[]
/** 全量好友列表 */
friends: FriendLite[]
/** 隐藏 id不展示hide > locked > disabled */
hideIds?: number[]
/** 锁定 id默认勾选、不可取消、计入已选数典型私聊侧 +建群锁定对方) */
lockedIds?: number[]
/** 已选数上限;不传或 <=0 时不限 */
maxSize?: number
/** 已选好友 idv-model按数组顺序即为点击顺序 */
selectedIds: number[]
}>(),
{
lockedIds: () => [],
disabledIds: () => [],
hideIds: () => [],
maxSize: 0
}
)
const emit = defineEmits<{
'update:selectedIds': [value: number[]]
}>()
const keyword = ref('')
/** id → friend 映射,已选反查 / 三态判定共用,避免每次 O(N) 扫 */
const byId = computed(() => {
const map = new Map<number, FriendLite>()
for (const friend of props.friends) {
map.set(friend.id, friend)
}
return map
})
/** 三态 id 集合:每次过滤复用 */
const hideSet = computed(() => new Set(props.hideIds))
const lockedSet = computed(() => new Set(props.lockedIds))
const disabledSet = computed(() => new Set(props.disabledIds))
const selectedSet = computed(() => new Set(props.selectedIds))
/** 候选好友:剔除 hideIdshide 优先级最高) */
const candidates = computed(() =>
props.friends.filter((friend) => !hideSet.value.has(friend.id))
)
/** 委托 useFriendBuckets搜索规则复用左侧列表按滚动分页渲染 */
const { filtered } = useFriendBuckets(candidates, keyword)
/** 已选数 + 已选好友列表:三态优先级 + 顺序拼接由 useSelectedItems 统一承担 */
const { selectedCount, selectedItems: selectedFriends } = useSelectedItems<FriendLite>(
() => props.selectedIds,
() => props.lockedIds,
() => props.disabledIds,
() => props.hideIds,
byId
)
/** 是否被锁定 */
function isLocked(friend: FriendLite): boolean {
return lockedSet.value.has(friend.id)
}
/** 是否被禁用locked / hide 已被前置过滤,剩下的才算 disabled */
function isDisabled(friend: FriendLite): boolean {
return !lockedSet.value.has(friend.id) && disabledSet.value.has(friend.id)
}
/** 是否选中locked 视为永远选中 */
function isSelected(friend: FriendLite): boolean {
return selectedSet.value.has(friend.id)
}
/** 圆形勾选指示器的 class选中 / 锁定走绿底,禁用灰底,未选空心圆 */
function getCheckClass(friend: FriendLite): string {
if (isLocked(friend) || isSelected(friend)) {
return 'bg-[#07c160] border border-solid border-[#07c160]'
}
if (isDisabled(friend)) {
return 'bg-[var(--ant-color-fill)] border border-solid border-[var(--ant-color-border)]'
}
return 'border border-solid border-[var(--ant-color-border)] bg-[var(--ant-color-bg-container)]'
}
/** 切换选中态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 === -1) {
if (props.maxSize > 0 && selectedCount.value >= props.maxSize) {
ElMessage.error(`最多选择 ${props.maxSize} 位好友`)
return
}
next.push(friend.id)
} else {
next.splice(index, 1)
}
emit('update:selectedIds', next)
}
</script>
<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(--ant-color-border-secondary)] bg-[var(--ant-color-fill-secondary)]"
>
<!-- 搜索框 -->
<div class="flex-shrink-0 px-3 py-2">
<ElInput v-model="keyword" placeholder="搜索好友" clearable>
<template #prefix>
<Icon icon="ant-design:search-outlined" />
</template>
</ElInput>
</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(--ant-color-fill)]"
: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(--ant-color-text)]"
>
{{ (item as FriendLite).displayName || (item as FriendLite).nickname }}
</span>
</div>
</template>
</PagedScroller>
<div v-else class="py-10 text-13px text-center text-[var(--ant-color-text-disabled)]">
{{ keyword ? '没有匹配的好友' : '暂无好友' }}
</div>
</div>
</div>
<!-- 右栏 -->
<div class="flex flex-col flex-1 min-w-0">
<!-- 标题已选数高度对齐左侧 input default32px保证两侧第一项起点同水平 -->
<div
class="flex-shrink-0 h-12 px-4 leading-[3rem] border-b border-b-solid text-13px text-[var(--ant-color-text-secondary)] border-[var(--ant-color-border-secondary)]"
>
已选择 {{ selectedCount }} 个好友
</div>
<!-- 已选预览 selectedIds + lockedIds 数组顺序点击顺序展示 -->
<div 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(--ant-color-text)]"
>
{{ 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(--ant-color-text-placeholder)] hover:text-[var(--ant-color-error)]"
@click="handleToggle(friend)"
/>
</div>
<div
v-if="selectedFriends.length === 0"
class="py-10 text-13px text-center text-[var(--ant-color-text-disabled)]"
>
请从左侧选择好友
</div>
</div>
<!-- 业务壳塞额外内容的位置FriendPickerPanel 主流场景不需要 footer -->
<div
v-if="$slots.footer"
class="flex-shrink-0 border-t border-t-solid border-[var(--ant-color-border-secondary)]"
>
<slot name="footer"></slot>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,242 @@
<script lang="ts" setup>
import type { GroupMemberLite } from '../group'
import { computed, ref } from 'vue'
import { CommonStatusEnum } from '@vben/constants'
import { IconifyIcon as Icon } from '@vben/icons'
import { ElInput, ElMessage } from 'element-plus'
import { PagedScroller } from '..'
import { useSelectedItems } from '../../composables/useSelectedItems'
import { GroupMemberGrid, GroupMemberItem } from '../group'
import { UserAvatar } from '../user'
defineOptions({ name: 'ImGroupMemberPickerPanel' })
const props = withDefaults(
defineProps<{
/** 禁用 userId列表里展示置灰、不可勾选、不计入已选数 */
disabledIds?: number[]
/** 隐藏 userId不展示hide > locked > disabled */
hideIds?: number[]
/** 锁定 userId默认勾选、不可取消、计入已选数 */
lockedIds?: number[]
/** 已选数上限;不传或 <=0 时不限 */
maxSize?: number
/** 群成员列表 */
members: GroupMemberLite[]
/** 已选区展示形态:默认 list 对齐微信新视觉 */
selectedDisplay?: 'grid' | 'list'
/** 已选 userIdv-model按数组顺序即为点击顺序 */
selectedIds: number[]
}>(),
{
lockedIds: () => [],
disabledIds: () => [],
hideIds: () => [],
maxSize: 0,
selectedDisplay: 'list'
}
)
const emit = defineEmits<{
'update:selectedIds': [value: number[]]
}>()
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(--ant-color-fill)] border border-solid border-[var(--ant-color-border)]'
}
return 'border border-solid border-[var(--ant-color-border)] bg-[var(--ant-color-bg-container)]'
}
/** 切换选中态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 === -1) {
if (props.maxSize > 0 && selectedCount.value >= props.maxSize) {
ElMessage.error(`最多选择 ${props.maxSize} 位成员`)
return
}
next.push(member.userId)
} else {
next.splice(index, 1)
}
emit('update:selectedIds', next)
}
</script>
<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(--ant-color-border-secondary)] bg-[var(--ant-color-fill-secondary)]"
>
<!-- 搜索框 -->
<div class="flex-shrink-0 px-3 py-2">
<ElInput v-model="keyword" placeholder="搜索成员" clearable>
<template #prefix>
<Icon icon="ant-design:search-outlined" />
</template>
</ElInput>
</div>
<div class="flex-1 min-h-0">
<PagedScroller :items="shownMembers" :page-size="30" item-key="userId">
<template #default="{ item }">
<GroupMemberItem
:member="item as GroupMemberLite"
:height="46"
@click="handleToggle(item as GroupMemberLite)"
>
<span
class="flex flex-shrink-0 justify-center items-center w-5 h-5 rounded-full transition-colors"
:class="getCheckClass(item as GroupMemberLite)"
>
<Icon
v-if="isSelected(item as GroupMemberLite) || isLocked(item as GroupMemberLite)"
icon="ant-design:check-outlined"
:size="12"
color="#fff"
/>
</span>
</GroupMemberItem>
</template>
</PagedScroller>
</div>
</div>
<!-- 右栏 -->
<div class="flex flex-col flex-1 min-w-0">
<!-- 标题已选数高度对齐左侧 input default32px -->
<div
class="flex-shrink-0 h-12 px-4 leading-[3rem] border-b border-b-solid text-13px text-[var(--ant-color-text-secondary)] border-[var(--ant-color-border-secondary)]"
>
已选择 {{ selectedCount }} 位成员
</div>
<div 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(--ant-color-text)]"
>
{{ 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(--ant-color-text-placeholder)] hover:text-[var(--ant-color-error)]"
@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(--ant-color-text-placeholder)] hover:text-[var(--ant-color-error)]"
@click="handleToggle(member)"
/>
</GroupMemberGrid>
</div>
<div
v-if="selectedMembers.length === 0"
class="py-10 text-13px text-center text-[var(--ant-color-text-disabled)]"
>
请从左侧选择成员
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,3 @@
export { default as ConversationPickerPanel } from './conversation-picker-panel.vue';
export { default as FriendPickerPanel } from './friend-picker-panel.vue';
export { default as GroupMemberPickerPanel } from './group-member-picker-panel.vue';

View File

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

View File

@ -0,0 +1,111 @@
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, ref } from 'vue'
defineOptions({ name: 'ImResizableAside' })
const props = withDefaults(
defineProps<{
defaultWidth?: number //
maxWidth?: number //
minWidth?: 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 = Number.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>
<template>
<!--
可拖拽宽度的左侧 Aside
- 使用 localStorage 记住用户上次调整的宽度storageKey 必填
- 拖拽区在右边缘鼠标变 col-resize
-->
<aside
class="relative flex flex-col shrink-0 bg-[var(--ant-color-fill-secondary)] border-r border-r-solid border-[var(--im-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 }"
content="拖拽调整宽度"
@mousedown="startResize"
>
<div class="im-resizable-aside__line w-0.5 h-full rounded-0.5 bg-transparent transition-all"></div>
</div>
</aside>
</template>
<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: var(--im-resize-line-color);
}
</style>

View File

@ -0,0 +1,8 @@
export { default as RtcCallContainer } from './rtc-call-container.vue';
export { default as RtcCallIncoming } from './rtc-call-incoming.vue';
export { default as RtcCallInviting } from './rtc-call-inviting.vue';
export { default as RtcCallMemberPickerDialog } from './rtc-call-member-picker-dialog.vue';
export { default as RtcCallParticipantTile } from './rtc-call-participant-tile.vue';
export type { CallParticipantVM } from './rtc-call-participant-tile.vue';
export { default as RtcCallRunning } from './rtc-call-running.vue';
export { default as RtcGroupCallBanner } from './rtc-group-call-banner.vue';

View File

@ -0,0 +1,488 @@
<script lang="ts" setup>
import type { CallParticipantVM } from './rtc-call-participant-tile.vue'
import { computed, ref, watch } from 'vue'
import { useIntervalFn } from '@vueuse/core'
import { ElMessage } from 'element-plus'
import { Track } from 'livekit-client'
import {
acceptCall,
cancelCall,
inviteCall,
leaveCall,
noAnswerCallCheck,
rejectCall
} from '#/api/im/rtc'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { RTC_NO_ANSWER_CALL_CHECK_INTERVAL_MS } from '#/views/im/utils/config'
import {
ImConversationType,
ImRtcCallMediaType,
ImRtcCallStage
} from '#/views/im/utils/constants'
import { getSenderAvatar, getSenderDisplayName } from '#/views/im/utils/user'
import { useLiveKitRoom } from '../../composables/useLiveKitRoom'
import { useRtcStore } from '../../store/rtcStore'
import RtcCallIncoming from './rtc-call-incoming.vue'
import RtcCallInviting from './rtc-call-inviting.vue'
import RtcCallMemberPickerDialog from './rtc-call-member-picker-dialog.vue'
import RtcCallRunning from './rtc-call-running.vue'
defineOptions({ name: 'ImRtcCallContainer' })
const rtcStore = useRtcStore()
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[] = [{
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 (error) {
console.error('[Call] connect 失败', { room: rtcStore.call?.room }, error)
ElMessage.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 (error) {
console.error('[Call] accept connect 失败', { room: rtcStore.call?.room }, error)
ElMessage.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)
}
//
ElMessage.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 (error: any) {
//
if (error?.name !== 'NotAllowedError' && error?.message !== 'permission denied') {
console.warn('[Call] screenShare 切换失败', { enabled }, error)
}
}
}
// ==================== ====================
/** 打开「添加成员」弹窗;占位群通话 + 接通中状态才允许 */
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)
ElMessage.success('已发送邀请')
}
</script>
<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>

View File

@ -0,0 +1,112 @@
<script lang="ts" setup>
import type { ImRtcCallNotification } from '../../store/rtcStore'
import { computed } from 'vue'
import { DICT_TYPE } from '@vben/constants'
import { getDictLabel } from '@vben/hooks'
import { IconifyIcon as Icon } from '@vben/icons'
import { useGroupCallMembers } from '../../composables/useGroupCallMembers'
import { UserAvatar } from '../user'
const props = defineProps<{
accepting?: boolean
isGroup?: boolean
payload: ImRtcCallNotification | null
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>
<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 }"
:content="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>

View File

@ -0,0 +1,129 @@
<script lang="ts" setup>
import { IconifyIcon as Icon } from '@vben/icons'
import { useMediaStreamElement } from '../../composables/useMediaStreamElement'
import { UserAvatar } from '../user'
const props = defineProps<{
cameraEnabled: boolean
isGroup?: boolean
isVideo: boolean
localStream?: MediaStream | null //
micEnabled: boolean
peerAvatar?: string
peerNickname?: string
speakerEnabled: boolean
}>()
defineEmits<{
cancel: []
toggleCamera: []
toggleMic: []
toggleSpeaker: []
}>()
const setLocalVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localStream)
</script>
<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="setLocalVideoRef"
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('toggleMic')"
>
<!-- Iconify mic 有静音变体speaker / camera 没有同源静音变体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('toggleCamera')"
>
<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('toggleSpeaker')"
>
<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>

View File

@ -0,0 +1,109 @@
<script lang="ts" setup>
import type { GroupMemberLite } from '../group'
import { computed, ref } from 'vue'
import { ElButton, ElDialog } from 'element-plus'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { useGroupStore } from '../../store/groupStore'
import { GroupMemberPickerPanel } from '../picker'
defineOptions({ name: 'ImRtcCallMemberPickerDialog' })
const emit = defineEmits<{
/** 选完点完成;携带选中的 userId 列表 */
success: [selectedIds: number[]]
}>()
type PickerMode = 'add' | 'invite'
const groupStore = useGroupStore()
const visible = ref(false) //
const groupId = ref(0) // open
const mode = ref<PickerMode>('invite') // 弹窗用途invite=发起群通话选邀请人 / add=通话中追加成员
const excludeUserIds = ref<number[]>([]) // userId add
const selectedIds = ref<number[]>([]) // userId GroupMemberPickerPanel v-model
/** 标题;按用途切换 */
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: { excludeUserIds?: number[]; groupId: number; mode?: PickerMode; }) {
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>
<template>
<!--
通话成员选择弹窗发起群通话时选邀请人 / 通话中添加成员
选择 UI 委托 GroupMemberPickerPanel自己 hide已在通话内的成员 disabled
-->
<ElDialog
v-model="visible"
:content="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>
<ElButton @click="visible = false">取消</ElButton>
<ElButton type="primary" :disabled="!canSubmit" @click="handleOk"></ElButton>
</template>
</ElDialog>
</template>
<style scoped lang="scss">
@use '../picker/picker-dialog' as picker;
/* :deep 穿透 el-dialog 内部类;复用 picker 公共 mixin */
.im-picker-dialog {
@include picker.styles;
}
</style>

View File

@ -0,0 +1,110 @@
<script lang="ts" setup>
import { useMediaStreamElement } from '../../composables/useMediaStreamElement'
import { UserAvatar } from '../user'
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 setVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.participant.videoStream)
const setAudioRef = useMediaStreamElement<HTMLAudioElement>(() => props.participant.audioStream)
</script>
<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="setVideoRef"
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="setAudioRef"
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>
<style scoped>
/* 三点淡入淡出动画;@keyframes 必须 CSS 定义 */
.tile-dot {
animation: tile-dot 1.4s infinite ease-in-out both;
}
@keyframes tile-dot {
0%,
80%,
100% {
opacity: 0.3;
}
40% {
opacity: 1;
}
}
</style>

View File

@ -0,0 +1,291 @@
<script lang="ts" setup>
import { computed, onUnmounted, ref, watch } from 'vue'
import { IconifyIcon as Icon } from '@vben/icons'
import { formatCallDuration } from '#/views/im/utils/time'
import { useMediaStreamElement } from '../../composables/useMediaStreamElement'
import { UserAvatar } from '../user'
import RtcCallParticipantTile, { type CallParticipantVM } from './rtc-call-participant-tile.vue'
const props = defineProps<{
cameraEnabled: boolean
hangingUp?: boolean
/** 是否群通话;决定网格 / 单点布局 + 浮窗大小 */
isGroup: boolean
isVideo: boolean
localStream?: MediaStream | null
micEnabled: boolean
/** 网格视图用:所有参与者(含自己) */
participants: CallParticipantVM[]
peerAvatar?: string
/** 1v1 视图用 */
peerNickname?: string
/** 是否处于网络重连中;显示顶部黄色提示条 */
reconnecting?: boolean
remoteAudioStream?: MediaStream | null
remoteVideoStream?: MediaStream | null
/** 是否正在屏幕共享;按钮高亮 + 文案切换 */
screenShareEnabled?: boolean
/** 扬声器开关true 时正常播放远端音频false 时所有远端 audio 元素静音 */
speakerEnabled: boolean
startedAt: number
}>()
defineEmits<{
addMember: []
hangup: []
toggleCamera: []
toggleMic: []
toggleScreenShare: []
toggleSpeaker: []
}>()
/** 网格列数;按人数自适应;返回 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 setLocalVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localStream)
const setRemoteVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.remoteVideoStream)
const setRemoteAudioRef = useMediaStreamElement<HTMLAudioElement>(() => props.remoteAudioStream)
/** 1v1 视频:是否有远端视频流 */
const hasRemoteVideo = computed(() => !props.isGroup && !!props.remoteVideoStream)
const now = ref(Date.now()) // 1v1 tick
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>
<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="setRemoteVideoRef"
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="setLocalVideoRef"
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="setRemoteAudioRef"
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('toggleMic')"
>
<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('toggleSpeaker')"
>
<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('toggleCamera')"
>
<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('toggleScreenShare')"
>
<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('addMember')"
>
<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>
<style scoped>
/* 重连小点淡入淡出;@keyframes 必须 CSS 定义 */
.reconnect-dot {
animation: reconnect-pulse 1s ease-in-out infinite;
}
@keyframes reconnect-pulse {
0%,
100% {
opacity: 0.3;
}
50% {
opacity: 1;
}
}
</style>

View File

@ -0,0 +1,176 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import { DICT_TYPE } from '@vben/constants'
import { getDictLabel } from '@vben/hooks'
import { IconifyIcon as Icon } from '@vben/icons'
import { ElMessage, ElPopover } from 'element-plus'
import { getActiveCall, joinCall } from '#/api/im/rtc'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { useGroupCallMembers } from '../../composables/useGroupCallMembers'
import { useRtcStore } from '../../store/rtcStore'
import { UserAvatar } from '../user'
defineOptions({ name: 'ImRtcGroupCallBanner' })
const props = defineProps<{
groupId: number
}>()
const rtcStore = useRtcStore()
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 (error) {
console.warn('[GroupCallBanner] getActiveCall 失败', { groupId }, error)
}
},
{ immediate: true }
)
/** 在通话中的成员(已加入)+ 接入中的成员(已邀请未接通) */
const memberList = useGroupCallMembers(computed(() => props.groupId))
/** 本端是否正在该房间通话(处于 INVITING / RUNNING */
const isInThisCall = computed(
() => rtcStore.isActive && rtcStore.call?.room === activeCall.value?.room
)
/**
* 服务端是否记录我已加入刷新后 LiveKit 连接已断但 webhook 还没把 status 标为 LEFT 时仍为 true
* 用于把按钮文案切到重新加入但不 disable 按钮
*/
const serverSaysJoined = computed(() => {
const myId = getCurrentUserId()
return activeCall.value?.joinedUserIds?.includes(myId) ?? false
})
/** 加入按钮禁用:仅在本端实际持有 LiveKit 连接时禁用 */
const joinDisabled = computed(() => isInThisCall.value)
/** 加入按钮文案;本端连着 → 已在通话中;服务端还残留我但本端断了 → 重新加入;其它 → 加入 */
const joinLabel = computed(() => {
if (isInThisCall.value) return '已在通话中'
if (serverSaysJoined.value) return '重新加入'
return '加入'
})
/** 主动加入:调 invite 命中已有 call 拿 tokenrtcStore 按 status 自动进 RUNNING */
async function handleJoin() {
const call = activeCall.value
if (!call || joinDisabled.value) {
return
}
if (rtcStore.isActive) {
ElMessage.warning('您正在通话中')
return
}
popoverVisible.value = false
const data = await joinCall(call.room)
rtcStore.startInviting(data)
}
</script>
<template>
<!-- 仅当该群有活跃通话时显示点击胶囊条展开 popover 看在通话成员 + 加入 -->
<div v-if="activeCall" class="flex-shrink-0 px-4 pb-2 bg-[var(--ant-color-fill-secondary)]">
<ElPopover
v-model="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(--ant-color-success-bg)] text-[var(--ant-color-text)] hover:bg-[var(--ant-color-success-bg-hover)]"
>
<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(--ant-color-text-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 }"
:content="member.pending ? `${member.nickname}(接入中)` : member.nickname"
/>
<!-- 首次填充时房内可能暂时 0 加入后由 ParticipantConnected 事件追加 -->
<div
v-if="memberList.length === 0"
class="p-3 text-13px text-[var(--ant-color-text-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>
</ElPopover>
</div>
</template>

View File

@ -0,0 +1,123 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { IconifyIcon as Icon } from '@vben/icons'
import { useUserStore } from '@vben/stores'
import { ElBadge } from 'element-plus'
import { useConversationStore } from '../store/conversationStore'
import { useFriendStore } from '../store/friendStore'
import { useImUiStore } from '../store/uiStore'
import { UserAvatar } from './user'
defineOptions({ name: 'ImToolBar' })
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const conversationStore = useConversationStore()
const friendStore = useFriendStore()
const uiStore = useImUiStore()
/** 消息 Tab 的红点:所有非免打扰会话的未读总和 */
const totalUnread = computed(() => conversationStore.getTotalUnreadCount)
/** 通讯录 Tab 的红点:未处理好友申请数(接收方=我) */
const unhandledRequestCount = computed(() => friendStore.getUnhandledRequestCount)
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>
<template>
<!--
ToolBarIM 左侧工具栏
布局顶部头像 中间三 Tab消息/好友/群聊 底部设置
-->
<div
class="flex flex-col items-center w-14 pt-4 pb-3 gap-2 flex-shrink-0 select-none bg-[#2b2b2b]"
>
<!-- 顶部用户头像点击跳个人中心 UserAvatar 统一首字 / 哈希配色规则 -->
<div class="mb-2 cursor-pointer" @click="goProfile">
<UserAvatar
:url="userStore.userInfo?.avatar"
:name="userStore.userInfo?.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)"
>
<ElBadge
v-if="item.name === 'ImHomeConversation' && totalUnread > 0"
:count="totalUnread"
:max="99"
class="tool-bar__badge"
>
<Icon :icon="item.icon" class="tool-bar__icon" />
</ElBadge>
<ElBadge
v-else-if="item.name === 'ImHomeContact' && unhandledRequestCount > 0"
:count="unhandledRequestCount"
:max="99"
class="tool-bar__badge"
>
<Icon :icon="item.icon" class="tool-bar__icon" />
</ElBadge>
<Icon v-else :icon="item.icon" class="tool-bar__icon" />
</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" class="tool-bar__icon" />
</div>
</div>
</div>
</template>
<style scoped>
.tool-bar__icon {
width: 22px;
height: 22px;
}
/* 调整红点位置 */
.tool-bar__badge :deep(.el-badge__content) {
top: 4px;
right: 8px;
border: none;
}
</style>

View File

@ -0,0 +1,5 @@
export { default as RecommendCardDialog } from './recommend-card-dialog.vue';
export { default as UserAvatar } from './user-avatar.vue';
export { default as UserInfoCard } from './user-info-card.vue';
export { default as UserInfo } from './user-info.vue';
export type { UserInfoRelation } from './user-info.vue';

View File

@ -0,0 +1,322 @@
<script lang="ts" setup>
import type { Conversation, FriendLite } from '../../types'
import { computed, ref } from 'vue'
import { IconifyIcon as Icon } from '@vben/icons'
import { ElButton, ElDialog, ElInput, ElMessage } from 'element-plus'
import { createGroup } from '#/api/im/group'
import { CardBubble } from '#/views/im/home/components/card'
import { ImContentType, ImConversationType, isGroupConversation } from '../../../utils/constants'
import { getConversationKey } from '../../../utils/conversation'
import { buildDefaultGroupName } from '../../../utils/group'
import { type CardTarget, serializeMessage } from '../../../utils/message'
import { getGroupDisplayName, isGroupQuit } from '../../../utils/user'
import { useMessageSender } from '../../composables/useMessageSender'
import { FacePicker } from '../../pages/conversation/components/input'
import { useConversationStore } from '../../store/conversationStore'
import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore'
import { ConversationPickerPanel } from '../picker'
import { FriendPickerPanel } from '../picker'
defineOptions({ name: 'ImRecommendCardDialog' })
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<'contact' | 'conversation'>('conversation') //
const selectedKeys = ref<string[]>([])
const selectedFriendIds = ref<number[]>([])
const leaveMessage = ref('')
const sending = ref(false)
const emojiVisible = ref(false) // smile icon
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.filter(
(conversation) =>
!(
conversation.type === ImConversationType.GROUP &&
isGroupQuit(groupStore.getGroup(conversation.targetId))
)
)
)
/** 隐藏 key不能把名片推回名片本身的会话用户名片避免自推、群名片避免推回该群 */
const hideKeys = computed<string[]>(() => {
const t = target.value
if (!t) {
return []
}
return [getConversationKey({ type: t.targetType, targetId: t.targetId })]
})
/** 好友视图候选列表:直接复用 friendStore Lite 视图 */
const friends = computed<FriendLite[]>(() => friendStore.getActiveFriendLiteList)
/** 把选中的 emoji 拼到留言末尾FacePicker 自身负责关闭面板 */
function handleEmojiSelect(emoji: string) {
leaveMessage.value = `${leaveMessage.value}${emoji}`
}
/** 切到好友视图:清掉之前在会话视图输入的留言,避免在不可见输入框里把留言静默发到新群 */
function handleSwitchToContact() {
view.value = 'contact'
leaveMessage.value = ''
emojiVisible.value = false
}
/**
* 确认发送会话视图每个选中会话先发 CARDCARD 成功后才发留言保证先看到名片的顺序意图
*
* 文案聚合全部成功已转发全部失败转发失败AB部分失败已转发 XY 失败
* 失败的消息以 FAILED 状态留在对应会话气泡里可右键重试
*/
async function handleSend() {
const card = target.value
if (!card?.targetId || selectedKeys.value.length === 0) {
return
}
const byKey = new Map(candidateConversations.value.map((c) => [getConversationKey(c), c]))
const targets = selectedKeys.value
.map((key) => byKey.get(key))
.filter((c): c is Conversation => c != null)
if (targets.length === 0) {
return
}
const cardContent = serializeMessage({ ...card })
const leaveText = leaveMessage.value.trim()
sending.value = true
try {
const tasks = targets.map(async (conversation) => {
const cardOk = await sendRaw(ImContentType.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) {
ElMessage.success('已转发')
} else if (failedNames.length === targets.length) {
ElMessage.error(`转发失败:${failedNames.join('、')}`)
} else {
ElMessage.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: getGroupDisplayName(group) || name,
avatar: group.avatar || '',
unreadCount: 0,
lastContent: '',
lastSendTime: 0
}
const cardOk = await sendRaw(ImContentType.CARD, serializeMessage({ ...card }), {
conversation: newConversation
})
if (!cardOk) {
ElMessage.warning('群已创建,但名片发送失败,请稍后在群里重试')
visible.value = false
return
}
const leaveText = leaveMessage.value.trim()
if (leaveText) {
await send(leaveText, { conversation: newConversation })
}
conversationStore.pushRecentForwardConversationKeyList([getConversationKey(newConversation)])
ElMessage.success('已创建群聊并发送')
visible.value = false
} finally {
sending.value = false
}
}
</script>
<template>
<!--
把名片推荐给朋友用户 / 群通用
- dialog 壳由本组件持有选择 UI 委托 ConversationPickerPanel / FriendPickerPanel
- view='conversation'选已有会话发名片默认视图
- view='contact'创建聊天入口进入选好友建群再发名片业务壳层切视图两个 Panel 互不知道对方
- 1 发送多个走分别发送(n)文案与微信一致
- 失败的消息以 FAILED 状态留在对应会话气泡里供右键重试
- 对外接口ref + open({ target })不再走 v-model
-->
<ElDialog
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(--ant-color-text-secondary)] transition-colors duration-150 hover:text-[var(--ant-color-primary)]"
@click="view = 'conversation'"
/>
<span class="text-base text-[var(--ant-color-text)]">
{{ 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">
<ElInput v-model="leaveMessage" :maxlength="100" placeholder="给朋友留言">
<template #suffix>
<Icon
icon="ant-design:smile-outlined"
:size="18"
class="cursor-pointer text-[var(--ant-color-text-secondary)] hover:text-[var(--ant-color-primary)]"
@click.stop="emojiVisible = !emojiVisible"
/>
</template>
</ElInput>
<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">
<ElButton @click="visible = false">取消</ElButton>
<ElButton
type="primary"
:loading="sending"
:disabled="selectedKeys.length === 0"
@click="handleSend"
>
{{ selectedKeys.length > 1 ? `分别发送(${selectedKeys.length}` : '发送' }}
</ElButton>
</div>
</div>
</template>
</ConversationPickerPanel>
<!-- 好友视图选好友建群后发送 -->
<FriendPickerPanel v-else v-model:selected-ids="selectedFriendIds" :friends="friends" />
</div>
<!-- 好友视图的 dialog footer建群并发送 -->
<template v-if="view === 'contact'" #footer>
<ElButton @click="visible = false">取消</ElButton>
<ElButton
type="primary"
:loading="sending"
:disabled="selectedFriendIds.length === 0"
@click="handleCreateGroupAndSend"
>
创建群聊并发送
</ElButton>
</template>
</ElDialog>
</template>
<style scoped lang="scss">
@use '../picker/picker-dialog' as picker;
/* :deep 穿透 el-dialog 内部类;复用 picker 公共 mixin */
.im-picker-dialog {
@include picker.styles;
}
</style>

View File

@ -0,0 +1,134 @@
<script lang="ts" setup>
import type { User } from '../../types'
import { computed } from 'vue'
import { ElImage } from 'element-plus'
import { ImFriendAddSource } from '../../../utils/constants'
import { getAvatarBgColor, getAvatarText } from '../../../utils/user'
import { useImUiStore } from '../../store/uiStore'
defineOptions({ name: 'ImUserAvatar', inheritAttrs: false })
const props = withDefaults(
defineProps<{
addSource?: number // UserInfoCard FriendAddDialog 1=
addSourceExtra?: string // addSource=2 XX YY
clickable?: boolean // UserInfoCard true
id?: number | string // id
name?: string // +
previewable?: boolean // clickable
previewZIndex?: number // z-index z-index UserInfoCard
radius?: string // CSS 15%
size?: number // px width/height
url?: string // URL
user?: User //
}>(),
{
addSourceExtra: undefined,
clickable: true,
id: undefined,
name: undefined,
previewable: false,
previewZIndex: 2000,
radius: '15%',
size: 42,
url: undefined,
user: undefined,
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>
<template>
<!--
通用用户头像组件
- url 时展示图片 url 时展示色卡 + 首字母/首字
- 点击默认触发 UserInfoCardclickable
- previewable=true 时改为点头像直接放大预览用于名片 / 详情页等大头像位
-->
<div
class="relative inline-flex"
:style="{ cursor: clickable && !previewable ? 'pointer' : 'default' }"
v-bind="$attrs"
@click="handleClick"
>
<ElImage
v-if="url && previewable"
class="block overflow-hidden"
:src="url"
:preview="{ src: url, zIndex: previewZIndex }"
:style="imgStyle"
/>
<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>

View File

@ -0,0 +1,121 @@
<script lang="ts" setup>
import { computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { ImConversationType } from '../../../utils/constants'
import { getFriendDisplayName } from '../../../utils/user'
import { useConversationStore } from '../../store/conversationStore'
import { useFriendStore } from '../../store/friendStore'
import { useImUiStore } from '../../store/uiStore'
import UserInfo, { type UserInfoRelation } from './user-info.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>
<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(--ant-color-bg-elevated)] 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>

View File

@ -0,0 +1,449 @@
<script lang="ts" setup>
import type { CheckboxValueType } from 'element-plus'
import type { User } from '../../types'
import { computed, h, nextTick, ref, watch } from 'vue'
import { confirm } from '@vben/common-ui'
import { DICT_TYPE } from '@vben/constants'
import { getDictLabel } from '@vben/hooks'
import { IconifyIcon as Icon } from '@vben/icons'
import { ElButton, ElCheckbox, ElDropdown, ElDropdownItem, ElDropdownMenu, ElInput, ElMessage } from 'element-plus'
import { getSimpleUser, type SystemUserApi } from '#/api/system/user'
import { formatDate } from '#/views/im/utils/time'
import { ImFriendAddSource } from '../../../utils/constants'
import { toUserCardTarget } from '../../../utils/message'
import { getGenderColor, getGenderIcon } from '../../../utils/user'
import { useFriendStore } from '../../store/friendStore'
import { FriendAddDialog } from '../friend'
import RecommendCardDialog from './recommend-card-dialog.vue'
import UserAvatar from './user-avatar.vue'
defineOptions({ name: 'ImUserInfo' })
const props = withDefaults(
defineProps<{
/** 加好友来源;默认 SEARCH参见 ImFriendAddSource */
addSource?: number
/** 来源附带信息addSource=GROUP群聊时传群名用于「我是 XX 群的 YY」预填话术 */
addSourceExtra?: string
/** 备注:仅 relation=friend 时有效;空串 → 显示"添加备注"占位 */
displayName?: string
/** UserAvatar 预览层 z-index放在高 z-index 浮层(如 UserInfoCard里需手动抬高 */
previewZIndex?: number
relation?: UserInfoRelation
/** 起手用户:至少要有 id性别 / 部门由组件按 id 懒拉合并补齐 */
user: null | User
}>(),
{
addSource: ImFriendAddSource.SEARCH,
addSourceExtra: '',
displayName: '',
previewZIndex: 2000,
relation: 'readonly'
}
)
const emit = defineEmits<{
/** 用户点"发消息":导航 / 关浮层等场景相关动作由父级承担(比如 UserInfoCard 要 close */
chat: [user: User]
/** 删除联系人成功后通知父级confirm + 调 store 都在本组件内做完):父级关浮层 / 清选中等 */
deleted: [user: User]
/** 备注落库成功后通知父级,用于同步父级持有的本地 FriendLite / Friend 副本(如 contact 页的 selection */
saved: [value: string]
}>()
/**
* 关系态决定备注行 / 右上 "..." 菜单 / 底部动作区的内容
* - friend: 备注可编辑+ "..." 删除菜单 + 3 图标动作
* - stranger: 单按钮"加为好友"
* - self: 单按钮"不能和自己聊天" disabled
* - readonly: 不渲染备注 / 菜单 / 动作区仅展示头部信息
*/
export type UserInfoRelation = 'friend' | 'readonly' | 'self' | 'stranger'
const friendStore = useFriendStore()
const full = ref<null | User>(props.user) // 起手 user + getSimpleUser 合并后的完整对象(性别 / 部门补齐用
/** 主标题:备注优先(好友场景),其次原昵称 */
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)
const editingRemark = ref(false) // editingRemark user watch
const remarkInput = ref('')
const remarkInputRef = ref<null | { focus: () => void; select?: () => void }>(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)
ElMessage.success('已更新备注')
emit('saved', next)
}
function cancelEditRemark() {
editingRemark.value = false
}
/** 发消息:导航 / 关浮层这些"业务侧"动作交给父级,本组件只负责通知 */
function handleChat() {
if (!props.user) {
return
}
emit('chat', props.user)
}
/** 占位提示:语音 / 视频聊天能力尚未接入,先以"开发中"友好提示 */
function handleComingSoon(featureName: string) {
ElMessage.info(`${featureName} 功能开发中`)
}
// ==================== / ====================
const friendAddDialogRef = ref<InstanceType<typeof FriendAddDialog>>() // refhandleAddFriend open({ presetUser, addSource, addSourceExtra })
const recommendDialogRef = ref<InstanceType<typeof RecommendCardDialog>>() // refhandleRecommend open({ target })
/** 把他推荐给朋友:弹 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: SystemUserApi.UserSimple = {
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 SystemUserApi.UserSimple
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 confirm(`确定将「${target.nickname || ''}」加入黑名单吗?`, '加入黑名单')
} catch {
return
}
await friendStore.blockFriend(target.id)
ElMessage.success('已加入黑名单')
}
/** 移出黑名单:操作温和不弹 confirm后端 FRIEND_UNBLOCK 推到时由 dispatcher 同步多端 */
async function handleUnblock() {
if (!props.user?.id) {
return
}
await friendStore.unblockFriend(props.user.id)
ElMessage.success('已移出黑名单')
}
/** 删除联系人:弹自定义确认(含「同时清空聊天记录」选项)→ friendStore.deleteFriend → 通知父级关浮层 / 清选中 */
async function handleDeleteFriend() {
if (!props.user?.id) {
return
}
const target = props.user
const clearConversation = ref(true)
try {
await confirm({
cancelText: '取消',
confirmText: '删除',
content: h('div', { class: 'flex flex-col gap-3 text-sm' }, [
h('div', `确定删除好友「${target.nickname || ''}」?`),
h(
ElCheckbox,
{
modelValue: clearConversation.value,
onChange: (value: CheckboxValueType) => {
clearConversation.value = Boolean(value)
}
},
() => '同时清空聊天记录'
)
]),
icon: 'warning',
title: '删除联系人'
})
} catch {
return
}
await friendStore.deleteFriend(target.id, clearConversation.value)
ElMessage.success('已删除好友')
emit('deleted', target)
}
</script>
<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(--ant-color-text)]"
>
{{ 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(--ant-color-text-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">
<ElDropdown 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(--ant-color-fill-secondary)]"
>
<Icon icon="ep:more-filled" :size="18" class="text-[var(--ant-color-text-secondary)]" />
</div>
<template #dropdown>
<ElDropdownMenu>
<!-- 把他推荐给朋友以个人名片消息形式发到选中的会话 -->
<ElDropdownItem @click="handleRecommend"></ElDropdownItem>
<!-- 拉黑 / 移出黑名单 friendInfo.blocked 切换文案 -->
<ElDropdownItem v-if="!isBlocked" divided @click="handleBlock">
加入黑名单
</ElDropdownItem>
<ElDropdownItem v-else divided @click="handleUnblock"></ElDropdownItem>
<ElDropdownItem divided @click="handleDeleteFriend">
<span class="text-[var(--ant-color-error)]">删除联系人</span>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</div>
<!-- 备注行 friend 态展示未编辑时整行可点编辑态用 el-input 行内替换 value -->
<template v-if="relation === 'friend'">
<div class="my-4 h-px bg-[var(--ant-color-border-secondary)]"></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(--ant-color-fill-tertiary)]'
: ''
"
@click="handleRowClick"
>
<span class="flex-shrink-0 w-16 whitespace-nowrap text-[var(--ant-color-text-secondary)]">备注</span>
<ElInput
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(--ant-color-text)]'
: 'text-[var(--ant-color-text-placeholder)]'
"
>
{{ displayName || '添加备注' }}
</span>
<!-- 默认 opacity:0 占位避免 hover 时其它列位移仅父行 hover 时浮现 -->
<Icon
icon="ant-design:edit-outlined"
:size="14"
class="flex-shrink-0 text-[var(--ant-color-text-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(--ant-color-border-secondary)]"></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(--ant-color-text-secondary)]">来源</span>
<span class="flex-1 min-w-0 truncate text-[var(--ant-color-text)]">
{{ 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(--ant-color-text-secondary)]">添加时间</span>
<span class="flex-1 min-w-0 truncate text-[var(--ant-color-text)]">
{{ formatDate(new Date(friendInfo.addTime), 'YYYY-MM-DD') }}
</span>
</div>
</template>
</template>
<!-- 动作区好友 = 3 图标陌生人 = 加为好友按钮自己 = disabledreadonly 不渲染 -->
<template v-if="relation !== 'readonly'">
<div class="my-4 h-px bg-[var(--ant-color-border-secondary)]"></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(--ant-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(--ant-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(--ant-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">
<ElButton type="primary" disabled>不能和自己聊天</ElButton>
</div>
<div v-else class="flex justify-center">
<ElButton type="primary" @click="handleAddFriend"></ElButton>
</div>
</template>
<!-- 加好友弹窗携带预填用户跳过搜索步骤直接进申请表单理由按 addSource 区分话术 -->
<FriendAddDialog ref="friendAddDialogRef" />
<!-- 把他推荐给朋友弹窗 friend 态下出现入口 -->
<RecommendCardDialog ref="recommendDialogRef" />
</div>
</template>
<style>
/*
scopedel-dropdown 的下拉菜单走 teleport bodyscoped 选不到
UserInfoCard 浮层用 z-9998要把这块抬到更高默认 --ant-z-index-popup-base ~2050 会被遮罩压住
*/
.im-user-info__more-menu {
z-index: 10000 !important;
}
</style>

View File

@ -0,0 +1,99 @@
import type { FriendLite } from '../types'
import { computed, type ComputedRef, type Ref } from 'vue'
/** 字母分桶结果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: ComputedRef<FriendLite[]> | Ref<FriendLite[]>,
keyword: Ref<string>
): {
buckets: ComputedRef<FriendBucket[]>
filtered: ComputedRef<FriendLite[]>
} {
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.replaceAll(/\s/g, '').includes(keywordLower) ||
displayNamePinyin.replaceAll(/\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)
const bucket = map.get(letter) ?? []
bucket.push(friend)
map.set(letter, bucket)
}
const letters = [...map.keys()].toSorted((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) ?? []).toSorted((a, b) =>
getSortKey(a).localeCompare(getSortKey(b))
)
}))
})
return { filtered, buckets }
}

View File

@ -0,0 +1,53 @@
import { computed, type ComputedRef, type Ref } from 'vue'
import { ImConversationType } from '../../utils/constants'
import { getSenderAvatar, getSenderDisplayName } from '../../utils/user'
import { useRtcStore } from '../store/rtcStore'
/** 群通话成员视图模型:已加入 + 接入中pending 头像 UI 半透明joined 不透明 */
export interface GroupCallMember {
userId: number
nickname: string
avatar?: string
pending: boolean
}
/**
* computedjoined joined invitee
* syncGroupActiveCall fallback
*
* @param groupId
* @param fallbackInviterId pending
*/
export function useGroupCallMembers(
groupId: Ref<number | undefined>,
fallbackInviterId?: Ref<number | undefined>
): ComputedRef<GroupCallMember[]> {
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
}
}

View File

@ -0,0 +1,331 @@
import { computed, ref, shallowRef } from 'vue'
import {
ConnectionQuality,
type LocalParticipant,
type Participant,
type RemoteParticipant,
Room,
RoomEvent,
Track,
VideoPresets
} from 'livekit-client'
type ParticipantEventHandler = (userId: number) => void
/** LiveKit Room 连接 / 设备 / 事件的薄封装UI 组件只关心响应式状态 */
export function useLiveKitRoom() {
/** Room 实例;模块内部状态,不对外暴露,避免调用方误写 */
const _room = shallowRef<null | Room>(null)
/** 只读 room 引用;调用方仅用于幂等判定 */
const room = computed(() => _room.value)
/** 本地参与者;连接成功后赋值 */
const localParticipant = shallowRef<LocalParticipant | null>(null)
/** 远端参与者列表ParticipantConnected / Disconnected 时刷新shallowRef 避免 Vue 深度代理 SDK class 内部 */
const remoteParticipants = shallowRef<RemoteParticipant[]>([])
/** 连接状态 */
const isConnected = ref(false)
/** 连接质量 */
const connectionQuality = ref<ConnectionQuality>(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<ParticipantEventHandler>()
/** 房内某人离开订阅者;用于把 userId 标记为「已退出」从 pending 占位中移除 */
const participantDisconnectedHandlers = new Set<ParticipantEventHandler>()
/** 同步远端参与者列表到响应式数组 */
function syncRemotes(r: Room) {
remoteParticipants.value = [...r.remoteParticipants.values()]
}
/** 连接 LiveKit Serveraudio / 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 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 握手并行,省 100300ms 串行延迟;
// 拿到的 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<unknown>[] = []
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<void> {
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 (error) {
// 用户在浏览器原生对话框里取消选择,不当作错误
screenShareEnabled.value = _room.value.localParticipant.isScreenShareEnabled
throw error
}
}
/** 注册「远端连接异常断开」回调;返回反注册函数 */
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): null | number {
const id = Number(identity)
return Number.isNaN(id) ? null : id
}
/**
* MediaStream key `${participantSid}:${source}`value `{ track, stream }`
* MediaStreamTrack MediaStream <video>.srcObject 线
* track /
*/
const streamCache = new Map<string, { stream: MediaStream; track: MediaStreamTrack; }>()
/**
* MediaStream
* unknown livekit-client cast
* MediaStream watch / srcObject
* mute / null video srcObject
*/
function pickStream(participant: unknown, source: Track.Source): MediaStream | null {
const p = participant as Participant
const pub = p.getTrackPublication(source)
if (!pub || pub.isMuted) {
return null
}
const track = pub.track?.mediaStreamTrack
if (!track) {
return null
}
const key = `${p.sid}:${source}`
const cached = streamCache.get(key)
if (cached && cached.track === track) {
return cached.stream
}
const stream = new MediaStream([track])
streamCache.set(key, { track, stream })
return stream
}
/** 断开当前 RoomclearHandlers 为 true 时同步清理外部注册的事件回调 */
async function disconnectRoom(clearHandlers: boolean) {
// 清理通话结束后不再复用的订阅回调
if (clearHandlers) {
disconnectedHandlers.clear()
participantConnectedHandlers.clear()
participantDisconnectedHandlers.clear()
}
// 清理音视频轨道缓存
streamCache.clear()
if (_room.value) {
// 卸载 Room 事件并断开连接
_room.value.removeAllListeners()
await _room.value.disconnect()
_room.value = null
}
// 重置连接和设备状态
localParticipant.value = null
remoteParticipants.value = []
isConnected.value = false
reconnecting.value = false
micEnabled.value = true
cameraEnabled.value = false
speakerEnabled.value = true
screenShareEnabled.value = false
}
/** 主动断开;通话结束统一调 */
async function disconnect() {
await disconnectRoom(true)
}
return {
room,
localParticipant,
remoteParticipants,
isConnected,
connectionQuality,
micEnabled,
cameraEnabled,
speakerEnabled,
screenShareEnabled,
reconnecting,
connect,
disconnect,
setMicEnabled,
setCameraEnabled,
setSpeakerEnabled,
setScreenShareEnabled,
pickStream,
onDisconnected,
onParticipantConnected,
onParticipantDisconnected
}
}
export type ImLiveKitRoom = ReturnType<typeof useLiveKitRoom>

View File

@ -0,0 +1,27 @@
import { ref, type VNodeRef, watch } from 'vue'
/**
* MediaStream `<video>` / `<audio>` srcObject
* stream srcObject
*/
export function useMediaStreamElement<T extends HTMLMediaElement>(
streamSource: () => MediaStream | null | undefined
): VNodeRef {
const elRef = ref<T>()
const syncStream = (stream = streamSource()) => {
if (elRef.value) {
elRef.value.srcObject = stream || null
}
}
watch(
streamSource,
(stream) => {
syncStream(stream)
},
{ flush: 'post', immediate: true }
)
return (el) => {
elRef.value = el instanceof HTMLMediaElement ? (el as T) : undefined
syncStream()
}
}

View File

@ -0,0 +1,408 @@
import type { Conversation, Message } from '../types'
import type { AxiosProgressEvent } from '#/api/infra/file'
import { isOpenableUrl } from '@vben/utils'
import { ElMessage } from 'element-plus'
import { uploadFile } from '#/api/infra/file'
import { getCurrentUserId } from '#/views/im/utils/auth'
import {
MESSAGE_FILE_MAX_MB,
MESSAGE_IMAGE_MAX_MB,
MESSAGE_VIDEO_MAX_MB,
MESSAGE_VOICE_MAX_MB
} from '../../utils/config'
import { ImContentType, ImMessageStatus } from '../../utils/constants'
import { getConversationKey } from '../../utils/conversation'
import {
type AudioMessage,
BLOB_URL_PREFIX,
type FileMessage,
generateClientMessageId,
type ImageMessage,
parseMessage,
type QuoteMessage,
serializeMessage,
type VideoMessage,
withQuotePayload
} from '../../utils/message'
import { useConversationStore } from '../store/conversationStore'
import { useMessageStore } from '../store/messageStore'
import { useMessageSender } from './useMessageSender'
import { useMuteOverlay } from './useMuteOverlay'
type UploadProgressEvent = Parameters<NonNullable<AxiosProgressEvent>>[0]
/** 单条媒体 payload 联合(覆盖 IMAGE / FILE / VOICE / VIDEO 四种) */
export type MediaPayload = AudioMessage | FileMessage | ImageMessage | VideoMessage
/**
* / type
*
* - voiceDuration VoiceRecorder AudioMessage.duration
* - videoProbe probeVideoFile VideoMessage
* - videoCoverUrl URL blob poster 退commit cover VideoMessage.coverUrl blob
*/
export interface MediaTypeContext {
voiceDuration?: number
videoProbe?: { duration?: number; height?: number; width?: number; }
videoCoverUrl?: string
}
export interface MediaTypeHandler {
/** 中文名,仅日志用(替代之前散落 9 处的 kind 字符串) */
kind: string
/** 由 file + url + context 生成 payload占位时 url 是 blob URLcommit 时是真实 url */
build: (file: File, url: string, context: MediaTypeContext) => MediaPayload
/** 重传场景:从旧 content 提取 context让重传不需要重做 probe / 重录语音) */
extractResendContext: (oldContent: string) => MediaTypeContext
}
/** 媒体类型注册表image / file / voice / video 各自的 kind + 首发 / 重传共用的 build / extract */
export const mediaTypeHandlers: Partial<Record<number, MediaTypeHandler>> = {
[ImContentType.IMAGE]: {
kind: '图片',
build: (_file, url) => ({ url }) as ImageMessage,
extractResendContext: () => ({})
},
[ImContentType.FILE]: {
kind: '文件',
build: (file, url) => ({ url, name: file.name, size: file.size }) as FileMessage,
extractResendContext: () => ({})
},
[ImContentType.VOICE]: {
kind: '语音',
build: (_file, url, context) => ({ url, duration: context.voiceDuration ?? 0 }) as AudioMessage,
extractResendContext: (oldContent) => {
const old = parseMessage<AudioMessage>(oldContent)
return { voiceDuration: old?.duration ?? 0 }
}
},
[ImContentType.VIDEO]: {
kind: '视频',
build: (file, url, context) =>
({
url,
coverUrl: context.videoCoverUrl,
duration: context.videoProbe?.duration,
width: context.videoProbe?.width,
height: context.videoProbe?.height,
size: file.size
}) as VideoMessage,
extractResendContext: (oldContent) => {
const old = parseMessage<VideoMessage>(oldContent)
// 旧 coverUrl 是 blob 说明上传期失败cover 没传成功),不复用;真实 URL 直接复用,省一次封面上传
const reuseCover =
old?.coverUrl && !old.coverUrl.startsWith(BLOB_URL_PREFIX) ? old.coverUrl : undefined
return {
videoProbe: { duration: old?.duration, width: old?.width, height: old?.height },
videoCoverUrl: reuseCover
}
}
}
}
/** 单次媒体上传的入参image / file / voice 走 uploadAndSendMediavideo 走低层 helper 自行组装) */
export interface UploadAndSendMediaOptions {
file: File
/** 对齐 ImContentTypemediaTypeHandlers 必须有对应项 */
type: number
/** 媒体特定的元数据(如语音时长 / 视频元信息);不传按空对象处理 */
context?: MediaTypeContext
/** 引用消息(若有),写进 payload.quote */
quote?: QuoteMessage
/** 锁定起始会话,上传期间会话切走则放弃发送 */
conversation: Conversation
/** 重试已有占位消息时复用的客户端消息编号 */
existingClientMessageId?: string
}
/**
* + composableimage / file / voice / video helper
*
* useMessageSender.sendRaw ack
* 1. insertMessage status=SENDINGcontent blob URL_localFile File
* 2. uploadFile onUploadProgress patchMessage uploadProgressUI
* 3. url contentpatchMessage blob URL store revoke
* 4. sendRaw(existingClientMessageId)
*
* FAILEDMessageItem _localFile
*/
/** 按内容类型映射体积上限MB未识别类型返回 0 表示不限 */
function resolveMediaMaxMb(type: number): number {
switch (type) {
case ImContentType.FILE: {
return MESSAGE_FILE_MAX_MB
}
case ImContentType.IMAGE: {
return MESSAGE_IMAGE_MAX_MB
}
case ImContentType.VIDEO: {
return MESSAGE_VIDEO_MAX_MB
}
case ImContentType.VOICE: {
return MESSAGE_VOICE_MAX_MB
}
default: {
return 0
}
}
}
/** 校验媒体文件大小是否超过内容类型上限;超限触发 warn 并返回 false调用方不应进入占位 / 上传链路 */
export function ensureMediaSizeWithinLimit(
file: File,
type: number,
warn: (text: string) => void
): boolean {
const maxMb = resolveMediaMaxMb(type)
if (maxMb && file.size > maxMb * 1024 * 1024) {
warn(`文件大小超过上限 ${maxMb}MB请压缩后再发`)
return false
}
return true
}
export const useMediaUploader = () => {
const conversationStore = useConversationStore()
const messageStore = useMessageStore()
const muteOverlay = useMuteOverlay()
const { sendRaw } = useMessageSender()
/**
* helperimage/file/voice uploadAndSendMedia video
*
* createObjectURL(file) blob URL buildContent status=SENDING + uploadProgress=0
* file _localFile
*/
const insertMediaPlaceholder = (opts: {
buildContent: (blobUrl: string) => string
conversation: Conversation
existingClientMessageId?: string
file: File
type: number
}): { blobUrl: string; clientMessageId: string; } => {
const { conversation } = opts
const blobUrl = URL.createObjectURL(opts.file)
const clientMessageId = opts.existingClientMessageId || generateClientMessageId()
if (opts.existingClientMessageId) {
messageStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, {
content: opts.buildContent(blobUrl),
status: ImMessageStatus.SENDING,
uploadProgress: 0,
_localFile: opts.file
})
return { clientMessageId, blobUrl }
}
const placeholder: Message = {
clientMessageId,
type: opts.type,
content: opts.buildContent(blobUrl),
status: ImMessageStatus.SENDING,
sendTime: Date.now(),
senderId: getCurrentUserId(),
targetId: conversation.targetId,
selfSend: true,
uploadProgress: 0,
_localFile: opts.file
}
void messageStore.insertMessage(
{
type: conversation.type,
targetId: conversation.targetId,
name: conversation.name || String(conversation.targetId),
avatar: conversation.avatar || ''
},
placeholder
).catch(() => undefined)
return { clientMessageId, blobUrl }
}
/**
* FAILED / /
*
* uploadProgress MessageItem isUploading / / loading
* _localFile uploadAndSendMedia
*/
const markMediaFailed = (
conversationType: number,
targetId: number,
clientMessageId: string
): void => {
messageStore.patchMessage(conversationType, targetId, clientMessageId, {
status: ImMessageStatus.FAILED,
uploadProgress: undefined
})
}
/**
* axios `onUploadProgress` closure return store
*
* XHR onProgress 10-50 Math.round
* store find + Object.assign + Vue reactivity
*/
const createUploadProgressHandler = (conversation: Conversation, clientMessageId: string) => {
let lastPercent = -1
return (event: UploadProgressEvent): void => {
if (!event.total) {
return
}
const percent = Math.round((event.loaded / event.total) * 100)
if (percent === lastPercent) {
return
}
lastPercent = percent
messageStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, {
uploadProgress: percent
})
}
}
/** 取媒体类型中文名(仅日志用);未注册 type 退化为通用「媒体」 */
const getMediaKind = (type: number): string => mediaTypeHandlers[type]?.kind ?? '媒体'
/**
* type handler
*
* `MediaTypeHandler` undefined `!`
* type image/file/voice/video dispatcher `mediaTypeHandlers[type]?.` optional chain
*/
const requireMediaHandler = (type: number): MediaTypeHandler => {
const handler = mediaTypeHandlers[type]
if (!handler) {
throw new Error(`[IM] 未注册的媒体类型 ${type}`)
}
return handler
}
/**
* + markMediaFailed + false
*
* image / file / voice / video url sendRaw
*/
const verifyMediaUploadStillAllowed = (
conversation: Conversation,
startKey: string,
type: number,
clientMessageId: string
): boolean => {
const activeConversation = conversationStore.activeConversation
if (!activeConversation || getConversationKey(activeConversation) !== startKey) {
console.warn(`[IM] ${getMediaKind(type)}上传期间切换了会话,放弃发送`, { startKey })
markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
return false
}
if (muteOverlay.value) {
console.warn(`[IM] ${getMediaKind(type)}上传期间被禁言,放弃发送`, { startKey })
markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
return false
}
return true
}
/**
* url content sendRaw
*
* patch content sendRaw existingClientMessageIdstore revoke blob URL
*/
const commitMediaPlaceholder = async (opts: {
clientMessageId: string
conversation: Conversation
realContent: string
type: number
}): Promise<void> => {
messageStore.patchMessage(
opts.conversation.type,
opts.conversation.targetId,
opts.clientMessageId,
{ content: opts.realContent }
)
// 显式传 conversation 而非依赖 sendRaw 内部取 active
// verifyMediaUploadStillAllowed 与 sendRaw 之间存在微秒窗口,期间用户切会话也能保证发到原会话
await sendRaw(opts.type, opts.realContent, {
existingClientMessageId: opts.clientMessageId,
targetId: opts.conversation.targetId,
conversation: opts.conversation
})
}
/**
* image / file / voice video helper
*
* @returns clientMessageId patch / FAILED
*/
const uploadAndSendMedia = async (opts: UploadAndSendMediaOptions): Promise<string> => {
const { conversation } = opts
const handler = mediaTypeHandlers[opts.type]
if (!handler) {
console.warn('[IM] uploadAndSendMedia 收到未注册的媒体类型', { type: opts.type })
return ''
}
// 体积上限拦截:大文件浏览器内截帧 / 解码可致 OOM超限直接 warning不进入占位 / 上传链路
if (!ensureMediaSizeWithinLimit(opts.file, opts.type, ElMessage.warning)) {
return ''
}
const startKey = getConversationKey(conversation)
const context = opts.context ?? {}
const buildContent = (url: string): string =>
serializeMessage(withQuotePayload(handler.build(opts.file, url, context), opts.quote))
// 1. 立即占位
const { clientMessageId } = insertMediaPlaceholder({
file: opts.file,
type: opts.type,
conversation,
buildContent,
existingClientMessageId: opts.existingClientMessageId
})
// 2. 上传:进度回调 patch uploadProgress失败保留 _localFile 供重试
let url: string | undefined
try {
url = await uploadFile(
{ file: opts.file },
createUploadProgressHandler(conversation, clientMessageId)
)
} catch (error) {
console.error(`[IM] ${handler.kind}上传失败`, error)
}
if (!url) {
markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
return clientMessageId
}
if (!isOpenableUrl(url)) {
console.warn(`[IM] ${handler.kind}上传返回了不支持打开的 URL`, { url })
ElMessage.warning('上传返回的文件地址不支持打开')
markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
return clientMessageId
}
// 3. 上传期间会话切换 / 用户登出 / 被禁言:任一情况都放弃发送,占位置 FAILED
if (!verifyMediaUploadStillAllowed(conversation, startKey, opts.type, clientMessageId)) {
return clientMessageId
}
// 4. patch content + sendRaw 收尾
await commitMediaPlaceholder({
type: opts.type,
conversation,
clientMessageId,
realContent: buildContent(url)
})
return clientMessageId
}
return {
uploadAndSendMedia,
insertMediaPlaceholder,
markMediaFailed,
commitMediaPlaceholder,
createUploadProgressHandler,
verifyMediaUploadStillAllowed,
getMediaKind,
requireMediaHandler
}
}

View File

@ -0,0 +1,48 @@
import type { Message } from '../types'
import { computed, reactive } from 'vue'
/**
*
*
* reactive stateMessagePanel
* - MessageItem +
* - MessageMultiSelectBar
* - MessageForwardDialog exit
* - MessagePanel watch activeConversation exit
*/
const state = reactive({
active: false,
/** 已选 clientMessageId 列表,按选中顺序保序 */
selectedClientMessageIds: [] as string[]
})
/** 已选 clientMessageId 集合MessageItem 大量 has 查询走它,避免 array.includes O(N²) */
const selectedIdSet = computed(() => new Set(state.selectedClientMessageIds))
/** 进入多选模式,可附带初始勾选项 */
function enter(initialMessage?: Message) {
state.active = true
state.selectedClientMessageIds = initialMessage ? [initialMessage.clientMessageId] : []
}
/** 退出多选模式 */
function exit() {
state.active = false
state.selectedClientMessageIds = []
}
/** 切换某条消息的选中态 */
function toggle(message: Message) {
const ids = state.selectedClientMessageIds
const index = ids.indexOf(message.clientMessageId)
if (index === -1) {
ids.push(message.clientMessageId)
} else {
ids.splice(index, 1)
}
}
export function useMessageMultiSelect() {
return { state, selectedIdSet, enter, exit, toggle }
}

View File

@ -0,0 +1,455 @@
import type { Message } from '../types'
import type { ImChannelMessageApi } from '#/api/im/message/channel'
import type { ImGroupMessageApi } from '#/api/im/message/group'
import type { ImPrivateMessageApi } from '#/api/im/message/private'
import { watch } from 'vue'
import { pullChannelMessages as apiPullChannelMessages } from '#/api/im/message/channel'
import { pullGroupMessages as apiPullGroupMessages } from '#/api/im/message/group'
import { getPrivateMaxReadMessageId as apiGetPrivateMaxReadMessageId, pullPrivateMessages as apiPullPrivateMessages } from '#/api/im/message/private'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { buildChannelConversationStub } from '../../utils/channel'
import {
MESSAGE_GROUP_PULL_SIZE,
MESSAGE_PRIVATE_PULL_SIZE,
MESSAGE_PRIVATE_READ_ENABLED
} from '../../utils/config'
import {
ImContentType,
ImConversationType,
ImMessageStatus,
isFriendChatTip,
isFriendNotification
} from '../../utils/constants'
import { generateClientMessageId, getPrivateMessagePeerId } from '../../utils/message'
import { runMinIdPull } from '../../utils/pull'
import { getFriendDisplayName, getGroupDisplayName } from '../../utils/user'
import { useConversationStore } from '../store/conversationStore'
import { useFriendStore } from '../store/friendStore'
import { useGroupRequestStore } from '../store/groupRequestStore'
import { useGroupStore } from '../store/groupStore'
import { type PulledMessage, useMessageStore } from '../store/messageStore'
import { useImWebSocketStore } from '../store/websocketStore'
/** 三类消息 pull 接口返回的原始 VO 联合类型runMinIdPull 只需 id 推进游标,具体分发在 applyPage 内按类型 cast */
type PulledRawMessage = ImChannelMessageApi.ChannelMessageRespVO | ImGroupMessageApi.GroupMessageRespVO | ImPrivateMessageApi.PrivateMessageRespVO
/**
* 线
*
*
* 1. + 使 `minId` privateMessageMaxId / groupMessageMaxId
* 2. size minId
* 3. conversationStore.loading=true
* - conversationStore
* - websocketStore WS
* 4. WebSocket
*/
export const useMessagePuller = () => {
const conversationStore = useConversationStore()
const messageStore = useMessageStore()
const wsStore = useImWebSocketStore()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const groupRequestStore = useGroupRequestStore()
const currentUserId = getCurrentUserId()
/** 判断请求是否被主动取消 */
const isAbortError = (e: unknown): boolean => {
const error = e as { code?: string; message?: string; name?: string; }
return (
error?.name === 'CanceledError' ||
error?.code === 'ERR_CANCELED' ||
error?.message === 'canceled'
)
}
/** 私聊会话归属:自己发的算"发给 receiverId 的会话",否则算"发送方的会话"curry currentUserId 进闭包减少 3 处调用方的样板 */
const getPrivatePeerId = (message: ImPrivateMessageApi.PrivateMessageRespVO) =>
getPrivateMessagePeerId(message, currentUserId)
/** 服务端私聊消息 -> 本地 MessagetargetId 是会话主键(对端 userId */
const convertPrivateMessage = (message: ImPrivateMessageApi.PrivateMessageRespVO): Message => {
return {
id: message.id,
clientMessageId: message.clientMessageId || generateClientMessageId(),
type: message.type,
content: message.content,
status: message.status,
receiptStatus: message.receiptStatus,
sendTime: new Date(message.sendTime).getTime(),
senderId: message.senderId,
targetId: getPrivatePeerId(message),
selfSend: message.senderId === currentUserId
}
}
/** 服务端群聊消息 -> 本地 Message */
const convertGroupMessage = (message: ImGroupMessageApi.GroupMessageRespVO): Message => {
return {
id: message.id,
clientMessageId: message.clientMessageId || generateClientMessageId(),
type: message.type,
content: message.content,
status: message.status,
sendTime: new Date(message.sendTime).getTime(),
senderId: message.senderId,
targetId: message.groupId,
selfSend: message.senderId === currentUserId,
atUserIds: message.atUserIds || [],
receiverUserIds: message.receiverUserIds || [],
receiptStatus: message.receiptStatus,
readCount: message.readCount
}
}
/** 服务端频道消息 -> 本地 Message */
const convertChannelMessage = (message: ImChannelMessageApi.ChannelMessageRespVO): Message => {
return {
id: message.id,
clientMessageId: message.clientMessageId || generateClientMessageId(),
type: message.type,
content: message.content,
status: ImMessageStatus.NORMAL, // 频道无撤回,恒为正常
receiptStatus: message.receiptStatus, // 频道已读态DONE 已读 / PENDING 未读
sendTime: new Date(message.sendTime).getTime(),
senderId: 0, // 系统下发,无发送人
targetId: message.channelId, // 会话归属到频道编号
selfSend: false,
materialId: message.materialId // 详情页拉富文本用
}
}
/** 频道:会话归属到 channelIdname / avatar 暂用占位,将来接入 channelStore 后再填真值 */
const convertChannelConversation = (message: ImChannelMessageApi.ChannelMessageRespVO) =>
buildChannelConversationStub(message.channelId)
/** 私聊:会话归属到对端 userId */
const convertPrivateConversation = (message: ImPrivateMessageApi.PrivateMessageRespVO) => {
const targetId = getPrivatePeerId(message)
const friend = friendStore.getFriend(targetId)
return {
type: ImConversationType.PRIVATE,
targetId,
name: friend ? getFriendDisplayName(friend) : String(targetId), // 会话列表 / 顶部标题展示:好友备注 > 真实昵称
avatar: friend?.avatar || '',
silent: friend?.silent
}
}
/** 群聊:会话归属到 groupId */
const convertGroupConversation = (message: ImGroupMessageApi.GroupMessageRespVO) => {
const group = groupStore.getGroup(message.groupId)
return {
type: ImConversationType.GROUP,
targetId: message.groupId,
name: group ? getGroupDisplayName(group) : String(message.groupId),
avatar: group?.avatar || '',
silent: group?.silent
}
}
/**
* 线 / minId / runMinIdPull +
* / / +
*
* isActive runMinIdPull session store
* 1. startEpochcancelPull() pullEpoch IM /
* 2. startUserId await userId logout / tab cancelPull
*/
const pullByType = async (
conversationType: number,
startMinId: number,
startEpoch: number,
startUserId: number,
signal: AbortSignal
) => {
// 私聊 / 群聊 / 频道各自一套接口;按 conversationType 分支调度。翻页机制minId 游标 / 空页判断 / 防死翻)交给 runMinIdPull
const isPrivate = conversationType === ImConversationType.PRIVATE
const isChannel = conversationType === ImConversationType.CHANNEL
const size = isPrivate ? MESSAGE_PRIVATE_PULL_SIZE : MESSAGE_GROUP_PULL_SIZE
const isStillValid = () =>
!signal.aborted && pullEpoch === startEpoch && getCurrentUserId() === startUserId
await runMinIdPull<PulledRawMessage>({
initialMinId: startMinId,
pageSize: size,
isActive: isStillValid,
fetchPage: ({ minId, size }) => {
if (isPrivate) {
return apiPullPrivateMessages({ minId, size }, signal)
}
if (isChannel) {
return apiPullChannelMessages({ minId, size }, signal)
}
return apiPullGroupMessages({ minId, size }, signal)
},
applyPage: async (list, nextMinId) => {
const pulledMessages: PulledMessage[] = []
// 逐条 dispatch原消息走批量 insertRECALL 信号走批量 recall 把同批内已 insert 的原消息更新为撤回提示。
// 后端按 id 升序返回,且信号 id 一定 > 原消息 id先更新 status 再插信号所以原消息一定先到、recallMessage 找得到
for (const raw of list) {
if (isChannel) {
const message = raw as ImChannelMessageApi.ChannelMessageRespVO
pulledMessages.push({
kind: 'insert',
conversationInfo: convertChannelConversation(message),
message: convertChannelMessage(message)
})
continue
}
if (isPrivate) {
const message = raw as ImPrivateMessageApi.PrivateMessageRespVO
// 特殊:撤回消息的处理
if (message.type === ImContentType.RECALL) {
pulledMessages.push({
kind: 'recall',
conversationType: ImConversationType.PRIVATE,
targetId: getPrivatePeerId(message),
recallSignalContent: message.content
})
continue
}
// 特殊:历史好友事件只还原聊天气泡;好友主数据由好友增量补偿同步
// 仅 FRIEND_ADD / FRIEND_DELETE 才作为会话气泡入消息列表
if (isFriendNotification(message.type) && !isFriendChatTip(message.type)) {
continue
}
// 其它消息正常入会话消息列表
pulledMessages.push({
kind: 'insert',
conversationInfo: convertPrivateConversation(message),
message: convertPrivateMessage(message)
})
} else {
const message = raw as ImGroupMessageApi.GroupMessageRespVO
// 特殊:撤回消息的处理
if (message.type === ImContentType.RECALL) {
pulledMessages.push({
kind: 'recall',
conversationType: ImConversationType.GROUP,
targetId: message.groupId,
recallSignalContent: message.content
})
continue
}
pulledMessages.push({
kind: 'insert',
conversationInfo: convertGroupConversation(message),
message: convertGroupMessage(message)
})
}
}
// 入库 + 推进 messageMaxIdnextMinId 为空(本批无有效 id时不推进游标与旧逻辑一致
await messageStore.applyPulledMessageList(pulledMessages, conversationType, nextMinId)
}
})
}
/** 同一时刻只允许一次 pullindex.vue 的手动调用与重连 watch 触发可能并发,共用同一个 promise 即可去重 */
let pullPromise: null | Promise<void> = null
let pullAbortController: AbortController | null = null
/**
* pull true isConnected watch pull
* socket onopen friendStore/groupStore watcher
*/
let initialPulled = false
/**
* pull / IM cancelPull() pullByType epoch
* session session
*
* WS pull /
* initialPulled false watcher
*/
let pullEpoch = 0
/** 显式取消:仅由 index.vue onUnmounted离开 IM / 切账号 / 路由跳出)调用 */
const cancelPull = () => {
pullEpoch++
pullAbortController?.abort()
pullAbortController = null
// 旧 promise 仍在 finally 阶段跑,但 epoch 守卫已阻断后续副作用;这里立刻让 pullPromise = null 让新一轮可重入
pullPromise = null
// 同步丢弃 WS 缓冲帧;旧 pull 已不会 flushBuffer若不清下次进 IM 第一次 pullOnce 会把旧 session 的帧回放进新 store
wsStore.discardBuffer()
}
/**
* /
*
* index.vue store allSettled
* cache groupId
*/
const pullStateEvents = async (): Promise<void> => {
groupStore.markAllGroupMembersExpired()
const results = await Promise.allSettled([
friendStore.pullFriends(),
friendStore.pullFriendRequests(),
conversationStore.pullConversationReads(),
groupStore.fetchGroupList(true),
groupRequestStore.pullGroupRequests(),
groupRequestStore.fetchUnhandledGroupRequestList()
])
for (const result of results) {
if (result.status === 'rejected') {
console.warn('[IM] 状态事件增量补偿失败', result.reason)
}
}
}
/** 执行一次全量增量拉取(重入安全:进行中再次调用复用同一个 promise */
const pullOnce = (): Promise<void> => {
if (!currentUserId) {
return Promise.resolve()
}
if (pullPromise) {
return pullPromise
}
const startEpoch = pullEpoch
// 启动时的用户快照pullByType 每批 await 后比对当前登录用户,账号变了立刻丢弃
const startUserId = currentUserId
const abortController = new AbortController()
pullAbortController = abortController
// 本轮 pull 仍属于当前 sessionepoch 未漂 + 用户未切;任何动新 store 状态的副作用都要先过这道关
const isCurrentPull = () =>
!abortController.signal.aborted &&
pullEpoch === startEpoch &&
getCurrentUserId() === startUserId
pullPromise = (async () => {
try {
// 旧 puller 在 cancelPull 未触发的异常路径上再进来时,先于任何副作用退出,避免污染新 session 的 loading
if (!isCurrentPull()) {
return
}
conversationStore.loading = true
let messagePullSucceeded = false
try {
// 并发拉取私聊 + 群聊 + 频道消息,降低初始加载耗时
await Promise.all([
pullByType(
ImConversationType.PRIVATE,
messageStore.privateMessageMaxId,
startEpoch,
startUserId,
abortController.signal
),
pullByType(
ImConversationType.GROUP,
messageStore.groupMessageMaxId,
startEpoch,
startUserId,
abortController.signal
),
pullByType(
ImConversationType.CHANNEL,
messageStore.channelMessageMaxId,
startEpoch,
startUserId,
abortController.signal
)
])
messagePullSucceeded = true
} catch (error) {
if (isAbortError(error)) {
return
}
console.error('[IM] 拉取离线消息失败:', error)
} finally {
// 仍属本轮才复位 loading旧轮被 cancel / 切账号时由新一轮自管,避免覆盖新 session 的 true
if (isCurrentPull()) {
conversationStore.loading = false
}
}
// 取消 / 切账号后跳过 flushBuffer / 排序 / 已读位置补齐
if (!isCurrentPull()) {
return
}
if (!messagePullSucceeded) {
return
}
// 回放 WebSocket 在 loading 期间收到的缓冲消息
const buffered = wsStore.flushBuffer()
const replayPersistPromises: Promise<void>[] = []
for (const item of buffered) {
if (item.conversationType === ImConversationType.PRIVATE) {
replayPersistPromises.push(wsStore.handlePrivateMessage(item.payload))
} else if (item.conversationType === ImConversationType.CHANNEL) {
replayPersistPromises.push(wsStore.handleChannelMessage(item.payload))
} else {
replayPersistPromises.push(wsStore.handleGroupMessage(item.payload))
}
}
await Promise.all(replayPersistPromises)
// pull + replay 都完成后再排序,避免回放消息打乱顺序
conversationStore.sortConversationList()
// 重连 / 冷启动后补齐当前激活私聊会话的「对方已读位置」
// 离线期间错过的 RECEIPT 推送会被这里补回;其他私聊会话等用户点开时由 index.vue 的 watch 触发
// 私聊已读关闭时跳过,避免打到已禁用接口触发错误日志
const active = conversationStore.activeConversation
if (MESSAGE_PRIVATE_READ_ENABLED && active && active.type === ImConversationType.PRIVATE) {
try {
const maxReadId = await apiGetPrivateMaxReadMessageId(
active.targetId,
abortController.signal
)
if (!isCurrentPull()) {
return
}
if (maxReadId) {
messageStore.applyMessageReadReceipt({
conversationType: ImConversationType.PRIVATE,
targetId: active.targetId,
privateReadMaxId: maxReadId
})
}
} catch (error) {
if (isAbortError(error)) {
return
}
console.warn('[IM] 拉取对方已读位置失败', error)
}
}
} finally {
// 仍属本轮正常完成首拉epoch 等但 userId 切了:清 pullPromise 防卡死、不标首拉epoch 漂cancelPull 已清no-op
if (isCurrentPull()) {
pullPromise = null
initialPulled = true
if (pullAbortController === abortController) {
pullAbortController = null
}
} else if (pullEpoch === startEpoch) {
pullPromise = null
if (pullAbortController === abortController) {
pullAbortController = null
}
}
}
})()
return pullPromise
}
/**
* WS minId update_time + id / /
* index.vue pullOnce + store
* store pullStateEvents pullOnce
*/
watch(
() => wsStore.isConnected,
(isConnected) => {
if (isConnected && initialPulled) {
void pullOnce()
void pullStateEvents()
}
}
)
return { pullOnce, cancelPull, convertPrivateMessage, convertGroupMessage }
}

View File

@ -0,0 +1,311 @@
import type { Conversation, Message } from '../types'
import { readChannelMessages as apiReadChannelMessages } from '#/api/im/message/channel'
import {
readGroupMessages as apiReadGroupMessages,
recallGroupMessage as apiRecallGroupMessage,
sendGroupMessage as apiSendGroupMessage
} from '#/api/im/message/group'
import {
getPrivateMaxReadMessageId as apiGetPrivateMaxReadMessageId,
readPrivateMessages as apiReadPrivateMessages,
recallPrivateMessage as apiRecallPrivateMessage,
sendPrivateMessage as apiSendPrivateMessage
} from '#/api/im/message/private'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { MESSAGE_GROUP_READ_ENABLED, MESSAGE_PRIVATE_READ_ENABLED } from '../../utils/config'
import { ImContentType, ImConversationType, ImMessageStatus } from '../../utils/constants'
import { getClientConversationId } from '../../utils/db'
import {
generateClientMessageId,
type QuoteMessage,
serializeMessage,
type TextMessage,
withQuotePayload
} from '../../utils/message'
import { useConversationStore } from '../store/conversationStore'
import { useMessageStore } from '../store/messageStore'
/** 非文本消息的扩展选项(通用) */
interface SendExtOptions {
atUserIds?: number[] // 群聊 @ 的用户编号列表
receipt?: boolean // 是否需要群回执(默认 false
targetId?: number // 覆盖默认的 targetId
/**
* /
*
* conversationStore.activeConversation +
*
*/
conversation?: Conversation
/** 被引用消息(可选):写进 content.quote 用于乐观渲染,服务端按 quote.messageId 反查重算覆盖 */
quote?: QuoteMessage
/**
* clientMessageId
*
* insertMessage blob URL +
* buildLocalMessage / insertMessage id ackMessage
*/
existingClientMessageId?: string
}
/**
* / /
*
*
* 1. / conversation.type
* 2. insertMessage SENDING ackMessage NORMAL FAILED
* 3. WebSocket RECALL websocketStore 退
* 4.
*/
export const useMessageSender = () => {
const conversationStore = useConversationStore()
const messageStore = useMessageStore()
/** 构造本地乐观消息对象 */
const buildLocalMessage = (opts: {
atUserIds?: number[]
clientMessageId: string
content: string
targetId: number
type: number
}): Message => {
return {
clientMessageId: opts.clientMessageId,
type: opts.type,
content: opts.content,
status: ImMessageStatus.SENDING,
sendTime: Date.now(),
senderId: getCurrentUserId(),
targetId: opts.targetId,
selfSend: true,
atUserIds: opts.atUserIds
}
}
/**
*
* 1.
* 2. type / content
* 3. true / false FAILED false
* /
*/
const sendRaw = async (
type: number,
content: string,
options?: SendExtOptions
): Promise<boolean> => {
// 1. 参数校验:优先用显式传入的 conversation转发场景否则取激活会话
const conversation = options?.conversation ?? conversationStore.activeConversation
if (!conversation) {
return false
}
const realTarget = options?.targetId || conversation.targetId
if (!realTarget) {
return false
}
// 2. 准备 clientMessageId媒体上传链路在 step 1 已经 insertMessage 占位,这里直接复用 id其余场景走默认乐观插入
let clientMessageId: string
if (options?.existingClientMessageId) {
clientMessageId = options.existingClientMessageId
// 占位若已被删除(上传期间用户右键删除 / 撤回 / removeMessage 等)则放弃发送,
// 否则 sendRaw 仍会把消息推到服务端,导致"本地无气泡 / 对方却收到一条"
const stillExists = messageStore
.getMessageList(conversation.type, realTarget)
.some((message) => message.clientMessageId === clientMessageId && !message._ackMerging)
if (!stillExists) {
return false
}
} else {
clientMessageId = generateClientMessageId()
const message = buildLocalMessage({
clientMessageId,
content,
targetId: realTarget,
type,
atUserIds: options?.atUserIds
})
const conversationInfo = {
type: conversation.type,
targetId: realTarget,
name: conversation.name || String(realTarget),
avatar: conversation.avatar || ''
}
void messageStore.insertMessage(conversationInfo, message).catch(() => undefined)
}
// 3. 发送请求:按会话类型分发到不同接口;成功后 ackMessage 更新为 NORMAL失败更新为 FAILED
try {
if (conversation.type === ImConversationType.PRIVATE) {
const data = await apiSendPrivateMessage({
clientMessageId,
receiverId: realTarget,
type,
content
})
void messageStore
.ackMessage(conversation.type, realTarget, clientMessageId, {
id: data.id,
sendTime: new Date(data.sendTime).getTime(),
status: data.status,
receiptStatus: data.receiptStatus,
content: data.content
})
.catch(() => undefined)
} else if (conversation.type === ImConversationType.GROUP) {
const data = await apiSendGroupMessage({
clientMessageId,
groupId: realTarget,
type,
content,
atUserIds: options?.atUserIds,
receipt: options?.receipt
})
void messageStore
.ackMessage(conversation.type, realTarget, clientMessageId, {
id: data.id,
sendTime: new Date(data.sendTime).getTime(),
status: data.status,
receiptStatus: data.receiptStatus,
readCount: data.readCount,
content: data.content
})
.catch(() => undefined)
}
return true
} catch (error) {
console.error('[IM] 消息发送失败', { type, realTarget, clientMessageId }, error)
void messageStore
.ackMessage(conversation.type, realTarget, clientMessageId, {
status: ImMessageStatus.FAILED
})
.catch(() => undefined)
return false
}
}
/**
* message-input.vue
* true / false / false sendRaw
*/
const send = async (text: string, options?: SendExtOptions): Promise<boolean> => {
if (!text.trim()) {
return false
}
const payload = withQuotePayload<TextMessage>({ content: text }, options?.quote)
return sendRaw(ImContentType.TEXT, serializeMessage(payload), options)
}
/**
*
* 1. WebSocket RECALL UI websocketStore
* 2. 退
*/
const recall = async (message: Message) => {
// 参数校验:本地占位消息不能撤回
if (!message.id) {
return
}
const conversation = conversationStore.activeConversation
if (!conversation) {
return
}
// 私聊 / 群聊接口签名一致,按会话类型分发
const isPrivate = conversation.type === ImConversationType.PRIVATE
try {
await (isPrivate ? apiRecallPrivateMessage(message.id) : apiRecallGroupMessage(message.id))
} catch (error) {
console.error('[IM] 撤回失败', { messageId: message.id, type: conversation.type }, error)
}
}
/**
* /
* 1.
* 2. id
*/
const readActive = async () => {
const conversation = conversationStore.activeConversation
if (!conversation) {
return
}
let loadedMaxMessageId = 0
for (const message of messageStore.getMessages(
getClientConversationId(conversation.type, conversation.targetId)
)) {
if (message.id && message.id > loadedMaxMessageId) {
loadedMaxMessageId = message.id
}
}
const maxMessageId = Math.max(loadedMaxMessageId, conversation.lastMessageId || 0)
// 本地标记已读未读数清零UI 立刻响应)
conversationStore.markConversationRead(conversation.type, conversation.targetId, maxMessageId)
if (!maxMessageId) {
return
}
// 接口调用:按会话类型分发,并按对应已读开关控制;失败仅记录日志,不回退本地已读状态
const isPrivate = conversation.type === ImConversationType.PRIVATE
const isGroup = conversation.type === ImConversationType.GROUP
const isChannel = conversation.type === ImConversationType.CHANNEL
if (!isPrivate && !isGroup && !isChannel) {
return
}
if (isPrivate && !MESSAGE_PRIVATE_READ_ENABLED) {
return
}
if (isGroup && !MESSAGE_GROUP_READ_ENABLED) {
return
}
try {
if (isPrivate) {
await apiReadPrivateMessages(conversation.targetId, maxMessageId)
} else if (isGroup) {
await apiReadGroupMessages(conversation.targetId, maxMessageId)
} else {
await apiReadChannelMessages(conversation.targetId, maxMessageId)
}
} catch (error) {
console.error(
'[IM] 标记已读失败',
{ type: conversation.type, targetId: conversation.targetId, maxMessageId },
error
)
}
}
/**
*
*
* 1. 线 / RECEIPT 线
* maxReadId status
* 2. 使 readCount / receiptStatus 线
*/
const syncPrivateReadStatus = async (peerId: number) => {
if (!peerId) {
return
}
// 私聊已读关闭:跳过对方已读位置同步,避免无谓接口调用
if (!MESSAGE_PRIVATE_READ_ENABLED) {
return
}
try {
// 拉取对方已读到的最大消息 id
const maxReadId = await apiGetPrivateMaxReadMessageId(peerId)
if (!maxReadId) {
return
}
// applyMessageReadReceipt 内部把 ≤ maxReadId 的本端消息回执更新为 DONE
messageStore.applyMessageReadReceipt({
conversationType: ImConversationType.PRIVATE,
targetId: peerId,
privateReadMaxId: maxReadId
})
} catch (error) {
console.warn('[IM] 拉取对方已读位置失败', { peerId }, error)
}
}
return { send, sendRaw, recall, readActive, syncPrivateReadStatus }
}

View File

@ -0,0 +1,102 @@
import { computed, type ComputedRef, onScopeDispose, ref } from 'vue'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { ImConversationType, ImGroupMemberRole } from '../../utils/constants'
import { isGroupQuit } from '../../utils/user'
import { useConversationStore } from '../store/conversationStore'
import { useGroupStore } from '../store/groupStore'
export type MuteOverlayInfo = { icon: string; text: string; }
/**
* now tick +
* MessageItem v-for useMuteOverlay() timer 30s
* setInterval timer
*/
const sharedNow = ref(Date.now())
let sharedTickTimer: null | number = null
let subscriberCount = 0
function subscribeNowTick(): void {
subscriberCount++
if (!sharedTickTimer) {
sharedTickTimer = window.setInterval(() => {
sharedNow.value = Date.now()
}, 30_000)
}
}
function unsubscribeNowTick(): void {
subscriberCount--
if (subscriberCount <= 0) {
subscriberCount = 0
if (sharedTickTimer) {
window.clearInterval(sharedTickTimer)
sharedTickTimer = null
}
}
}
/**
* / null
*
* > / >
*
* MessageInput overlay / handleResend / uploadAndSendMedia
* sendRaw
*/
export function useMuteOverlay(): ComputedRef<MuteOverlayInfo | null> {
const conversationStore = useConversationStore()
const groupStore = useGroupStore()
// 订阅模块级 tickscope 销毁时反订阅,最后一个订阅者退场后 timer 也跟着清
subscribeNowTick()
onScopeDispose(unsubscribeNowTick)
return computed(() => {
const conversation = conversationStore.activeConversation
if (!conversation || conversation.type !== ImConversationType.GROUP) {
return null
}
const group = groupStore.getGroup(conversation.targetId)
if (!group) {
return null
}
// 历史退群群:已退群只能查看历史,禁止发送(文本 / 图片 / 文件 / 语音 / 重试共用这一层拦截)
if (isGroupQuit(group)) {
return { text: '你已退出群聊,仅可查看历史消息', icon: 'ant-design:logout-outlined' }
}
const myId = getCurrentUserId()
// 群封禁:管理后台操作,所有人不可发送
if (group.banned) {
return { text: '该群已被管理员封禁,无法发送消息', icon: 'ant-design:stop-outlined' }
}
// 全群禁言:群主走 ownerUserId 比较直接豁免;其它人需要成员列表加载完才能区分管理员 vs 普通成员
// - 加载完 + 我是管理员 → 豁免
// - 加载完 + 我不是管理员(含已退群)→ 拦
// - 加载未完 → 不显示 overlay后端兜底拒绝普通成员避免误拦管理员
if (group.mutedAll && myId !== group.ownerUserId && group.membersLoaded) {
const myMember = group.members?.find((m) => m.userId === myId)
if (myMember?.role !== ImGroupMemberRole.ADMIN) {
return { text: '全群禁言中,暂时无法发送消息', icon: 'ant-design:audio-muted-outlined' }
}
}
// 成员禁言muteEndTime 在未来才算;用响应式 sharedNow 比对,到期后下一个 tick 就让 overlay 消失
const myMember = group.members?.find((m) => m.userId === myId)
if (myMember?.muteEndTime) {
const endTime = new Date(myMember.muteEndTime)
if (endTime.getTime() > sharedNow.value) {
const pad = (n: number) => n.toString().padStart(2, '0')
const timeStr =
`${pad(endTime.getMonth() + 1)}-${pad(endTime.getDate())} ` +
`${pad(endTime.getHours())}:${pad(endTime.getMinutes())}`
return {
text: `您已被禁言,解除时间:${timeStr}`,
icon: 'ant-design:audio-muted-outlined'
}
}
}
return null
})
}

View File

@ -0,0 +1,75 @@
import { computed, type ComputedRef, type Ref } from 'vue'
/**
* +
*
* - dialog-picker-contract hide > locked > disabled
* - hide /
* - locked + + 使 disabledIds
* - disabled selectedIds /
* - lockedIds selectedIds
*
* FriendPickerPanel / GroupMemberPickerPanel 25 computed
* Panel isLocked / isDisabled / isSelected composable
*/
export function useSelectedItems<T>(
selectedIds: () => readonly number[],
lockedIds: () => readonly number[],
disabledIds: () => readonly number[],
hideIds: () => readonly number[],
byId: ComputedRef<Map<number, T>> | Ref<Map<number, T>>
): {
selectedCount: ComputedRef<number>
selectedItems: ComputedRef<T[]>
} {
const hideSet = computed(() => new Set(hideIds()))
const disabledSet = computed(() => new Set(disabledIds()))
const selectedCount = computed(() => {
const merged = new Set<number>()
for (const id of selectedIds()) {
if (hideSet.value.has(id) || disabledSet.value.has(id)) {
continue
}
merged.add(id)
}
// locked 仅被 hide 过滤;契约里 locked 胜过 disabled确保锁定项始终计入
for (const id of lockedIds()) {
if (hideSet.value.has(id)) {
continue
}
merged.add(id)
}
return merged.size
})
const selectedItems = computed(() => {
const seen = new Set<number>()
const result: T[] = []
// locked 在前;仅被 hide 过滤
for (const id of lockedIds()) {
if (seen.has(id) || hideSet.value.has(id)) {
continue
}
const item = byId.value.get(id)
if (item) {
seen.add(id)
result.push(item)
}
}
// selectedIds 紧随;额外过滤 disabled
for (const id of selectedIds()) {
if (seen.has(id) || disabledSet.value.has(id) || hideSet.value.has(id)) {
continue
}
const item = byId.value.get(id)
if (item) {
seen.add(id)
result.push(item)
}
}
return result
})
return { selectedCount, selectedItems }
}

View File

@ -0,0 +1,82 @@
import { ref } from 'vue'
/**
*
*
*
*
* - MessageBubble toggle
* - MessagePanel stop()
* - MessageHistory / MessageMergeDetailDialog stop()
*
* key MessageBubble setup Symbol() / /
* url key
*/
export type VoiceKey = symbol
interface VoiceTask {
key: VoiceKey
url: string
audio: HTMLAudioElement
}
const currentTask = ref<null | VoiceTask>(null)
/**
*
*
* - key /
* - key task key
*/
function stop(key?: VoiceKey) {
const task = currentTask.value
if (!task) {
return
}
if (key !== undefined && task.key !== key) {
return
}
task.audio.pause()
// removeAttribute('src') + load() 是 W3C 推荐的释放姿势:不会触发空 src 加载导致的 error 事件,
// 也能让浏览器立即释放底层 decoder buffer比 audio.src = '' 更干净
task.audio.removeAttribute('src')
task.audio.load()
currentTask.value = null
}
/**
*
*
* - key
* - key
*/
function play(key: VoiceKey, url: string) {
if (!url) {
return
}
if (currentTask.value?.key === key) {
stop(key)
return
}
stop()
const audio = new Audio(url)
const task: VoiceTask = { key, url, audio }
/** 播放结束 / 异常清栈;只清当前任务,避免被后续新任务的回调误清 */
const finalize = () => {
if (currentTask.value === task) {
currentTask.value = null
}
}
audio.addEventListener('ended', finalize, { once: true })
audio.addEventListener('error', finalize, { once: true })
currentTask.value = task
audio.play().catch(finalize)
}
export function useVoicePlayer() {
/** 指定 key 是否正在播放 */
function isPlaying(key: VoiceKey): boolean {
return currentTask.value?.key === key
}
return { isPlaying, play, stop }
}

View File

@ -0,0 +1,290 @@
<script lang="ts" setup>
import type { Conversation } from './types'
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { preferences } from '@vben/preferences'
import { ImConversationType } from '../utils/constants'
import { initDb, stopRequests, StorageKeys } from '../utils/db'
import { ContextMenu, ToolBar } from './components'
import { GroupInfoCard } from './components/group'
import { RtcCallContainer } from './components/rtc'
import { UserInfoCard } from './components/user'
import { useMessagePuller } from './composables/useMessagePuller'
import { useMessageSender } from './composables/useMessageSender'
import { useVoicePlayer } from './composables/useVoicePlayer'
import { useChannelStore } from './store/channelStore'
import { useConversationStore } from './store/conversationStore'
import { useFaceStore } from './store/faceStore'
import { useFriendStore } from './store/friendStore'
import { useGroupRequestStore } from './store/groupRequestStore'
import { useGroupStore } from './store/groupStore'
import { useMessageStore } from './store/messageStore'
import { useImWebSocketStore } from './store/websocketStore'
defineOptions({ name: 'ImIndex' })
const route = useRoute()
const conversationStore = useConversationStore()
const messageStore = useMessageStore()
const webSocketStore = useImWebSocketStore()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const groupRequestStore = useGroupRequestStore()
const faceStore = useFaceStore()
const channelStore = useChannelStore()
const { pullOnce, cancelPull } = useMessagePuller()
const { readActive, syncPrivateReadStatus } = useMessageSender()
const voicePlayer = useVoicePlayer()
const childRouteReady = ref(false) //
/** 初始化:先吃本地缓存让首屏立即渲染,再远端刷新最新数据,最后建实时通信拉离线消息 */
onMounted(async () => {
// 0.1 IDB /
void faceStore.ensureFacePackList().catch((error) => console.warn('[IM] 后台预拉表情包失败', error))
// 1.1 loading=true + WebSocket connect pullOnce maxId pull 线
conversationStore.loading = true
try {
// 1.2 IM DB
await initDb()
// 1.3 store IDB
const cacheResults = await Promise.all([
conversationStore.loadConversationList(),
messageStore.loadMessageCursorList(),
friendStore.loadFriendData(),
groupStore.loadGroupList(),
channelStore.loadChannelList(),
groupRequestStore.loadGroupRequestList()
])
const hasFriendRows = cacheResults[2]
const hasGroupRows = cacheResults[3]
const hasChannelRows = cacheResults[4]
groupStore.markAllGroupMembersExpired()
childRouteReady.value = true
// 1.4 unhandled-list
// pullGroupRequests / useMessagePuller.pullStateEvents
void groupRequestStore
.fetchUnhandledGroupRequestList()
.catch((error) => console.warn('[IM] 拉取未处理加群申请失败', error))
// 2. pull线 / 退
// 2.1 IDB pullOnce
// 2.2 / await + onMounted
// pullOnce senderId IDB Promise.all RTT
const requiredFetches: Promise<unknown>[] = []
if (hasFriendRows) {
void friendStore.pullFriends().catch((error) => console.warn('[IM] 后台增量拉好友失败', error))
} else {
requiredFetches.push(friendStore.pullFriends())
}
if (hasGroupRows) {
void groupStore.fetchGroupList(true).catch((error) => console.warn('[IM] 后台刷新群列表失败', error))
} else {
requiredFetches.push(groupStore.fetchGroupList(true))
}
// 2.3 pull list
if (hasChannelRows) {
void channelStore.fetchChannelList().catch((error) => console.warn('[IM] 后台刷频道列表失败', error))
} else {
requiredFetches.push(channelStore.fetchChannelList())
}
// 2.4
if (requiredFetches.length > 0) {
await Promise.all(requiredFetches)
}
// 2.5 线
void friendStore
.pullFriendRequests()
.catch((error) => console.warn('[IM] 后台增量拉好友申请失败', error))
// 3.
await conversationStore
.pullConversationReads()
.catch((error) => console.warn('[IM] 拉取会话读位置失败', error))
// 4. WebSocket + 线pullOnce finally loading
webSocketStore.connect()
await pullOnce()
// 5.
const sorted = conversationStore.getSortedConversationList
const firstVisible = pickFirstVisibleConversation(sorted)
if (firstVisible && !conversationStore.activeConversation) {
conversationStore.setActiveConversation(firstVisible)
}
} catch (error) {
// 1. loadingpullOnce finally return
// 2. WebSocket disconnect onUnmounted
conversationStore.loading = false
console.error('[IM] 初始化失败', error)
}
})
/**
* 选首屏自动激活的会话置顶分组折叠时跳过被折叠隐藏的置顶项避免激活后被自动顶上来
*
* 折叠态判定只看用户开关非置顶 / 有未读的置顶项始终可见全是可折叠置顶时回退到 sorted[0] 兜底
*/
function pickFirstVisibleConversation(sorted: Conversation[]): Conversation | undefined {
if (sorted.length === 0) {
return undefined
}
const pinnedExpanded =
localStorage.getItem(StorageKeys.localStorage.conversationPinnedExpanded) === 'true'
if (pinnedExpanded) {
return sorted[0]
}
return sorted.find((c) => !c.top || (!c.silent && (c.unreadCount || 0) > 0)) ?? sorted[0]
}
/** 标签关闭前 flush 草稿队列debounce 默认 trail-edge 触发,最后一次输入可能还压在队列里 */
function onBeforeUnload() {
conversationStore.flushConversationDraftSave()
}
window.addEventListener('beforeunload', onBeforeUnload)
/** 离开 IM 主壳:取消在飞的 pull + 主动断 WebSocket + flush 草稿 + 清空表情缓存 + 解绑 unload + 停语音 */
onUnmounted(() => {
cancelPull()
webSocketStore.disconnect()
conversationStore.flushConversationDraftSave()
faceStore.clear()
// audio
voicePlayer.stop()
window.removeEventListener('beforeunload', onBeforeUnload)
// IM session store
void stopRequests()
})
/**
* 当前会话切换本地清零未读 + 上报后端已读 + 私聊补"对方已读到哪条"
*
* type+targetId 一起监听私聊与群聊 id 同号时切换也能触发其它会话已读状态由 WebSocket
* READ / RECEIPT 事件被动同步私聊补一次拉对方已读位置弥补离线 / 多端漏掉的 RECEIPT 推送
*/
watch(
() => [
conversationStore.activeConversation?.type,
conversationStore.activeConversation?.targetId
],
async ([type, targetId]) => {
if (!targetId) {
return
}
// + / UI
await readActive()
// ""线 / RECEIPT
if (type === ImConversationType.PRIVATE) {
void syncPrivateReadStatus(targetId)
}
}
)
/**
* 浏览器标签 title 拼上未读数前缀(63条未读)芋道源码
*
* 路由切换时 router.afterEach 会调 useTitle 重置 title nextTick 排在它之后再覆盖
* 一并监听 route.fullPathIM 子路由切换消息 / 通讯录也能重新加上前缀
*/
watch(
[() => conversationStore.getTotalUnreadCount, () => route.fullPath],
([count]) => {
nextTick(() => {
const base = preferences.app.name
document.title = count > 0 ? `(${count > 99 ? '99+' : count}条未读)${base}` : base
})
},
{ immediate: true }
)
</script>
<template>
<!--
IM 外层容器聊天模块的全屏沉浸式壳
- 左侧 ToolBar头像 + Tab消息/好友/群聊+ 底部设置
- 右侧 <router-view>按路由渲染 MessagePage / FriendPage / GroupPage
- 挂载全局弹层UserInfoCard / GroupInfoCard / ContextMenu
-->
<div
class="im-home flex w-full h-full overflow-hidden bg-[var(--ant-color-bg-layout)] text-[var(--ant-color-text)]"
>
<ToolBar />
<!--
keep-alive 缓存子页面
- Tab 不重建组件MessagePanel 滚动位置输入框草稿等 UI 状态不丢
- Vue 3 keep-alive 不能直接包 <router-view>会有警告必须走 v-slot Component
-->
<router-view v-if="childRouteReady" v-slot="{ Component }">
<keep-alive>
<component :is="Component" />
</keep-alive>
</router-view>
<div v-else class="flex-1 bg-[var(--ant-color-bg-container)]"></div>
<!-- 全局弹层 useImUiStore 统一调度 -->
<UserInfoCard />
<GroupInfoCard />
<ContextMenu />
<!-- 实时通话浮层监听 rtcStore 全局状态可在任意 IM 子页弹出 -->
<RtcCallContainer />
</div>
</template>
<style scoped>
:global(:root) {
--im-border-color-lighter: #e8eaed;
--im-resize-line-color: #d8dde5;
/*
* IM 聊天端复用 web-antd --ant-color-* 变量名此处用 Vben design token 兜底定义
* 保持 antd / ele 两端样式结构一致并随主题/自动切换
* 缺失时这些变量为空 背景透明边框/填充失效典型表情面板透出输入框占位文字
*/
--ant-color-bg-container: hsl(var(--card));
--ant-color-bg-elevated: hsl(var(--popover));
--ant-color-bg-layout: hsl(var(--background-deep));
--ant-color-border: hsl(var(--border));
--ant-color-border-secondary: hsl(var(--border));
--ant-color-text: hsl(var(--foreground));
--ant-color-text-secondary: hsl(var(--foreground) / 65%);
--ant-color-text-placeholder: hsl(var(--foreground) / 45%);
--ant-color-text-disabled: hsl(var(--foreground) / 30%);
/*
* fill 系列浅色下用 Element Plus 风格的冷调浅灰实色而非半透明深色
* 否则面板会话列表 / 消息面板等叠在灰底上会显脏发暗 Vue3+EP 的干净白差距明显
* 取值依组件实际用法secondary 多用于面板底取最浅对齐 EP --el-bg-color-page #f5f7fa
* tertiary 多用于 hover / 图标块比面板略深保证 hover 可见深色在 .dark 里另行覆盖
*/
--ant-color-fill: #e2e6ec;
--ant-color-fill-secondary: #f4f6f9;
--ant-color-fill-tertiary: #eaedf2;
--ant-color-fill-dark: #d5dae2;
--ant-color-primary: hsl(var(--primary));
--ant-color-primary-hover: hsl(var(--primary) / 80%);
--ant-color-primary-bg: hsl(var(--primary) / 12%);
--ant-color-primary-bg-hover: hsl(var(--primary) / 18%);
--ant-color-info: hsl(var(--primary));
--ant-color-success: hsl(var(--success));
--ant-color-success-bg: hsl(var(--success) / 12%);
--ant-color-success-bg-hover: hsl(var(--success) / 18%);
--ant-color-warning: hsl(var(--warning));
--ant-color-warning-bg: hsl(var(--warning) / 12%);
--ant-color-warning-bg-hover: hsl(var(--warning) / 18%);
--ant-color-error: hsl(var(--destructive));
}
:global(.dark) {
--im-border-color-lighter: rgb(255 255 255 / 12%);
--im-resize-line-color: rgb(255 255 255 / 18%);
/* 深色下 fill 回到「基于前景色的半透明亮色」,叠在深底上得到自然的微亮表面 */
--ant-color-fill: hsl(var(--foreground) / 12%);
--ant-color-fill-secondary: hsl(var(--foreground) / 8%);
--ant-color-fill-tertiary: hsl(var(--foreground) / 4%);
--ant-color-fill-dark: hsl(var(--foreground) / 18%);
}
</style>

View File

@ -0,0 +1,73 @@
<script lang="ts" setup>
import type { FriendLite } from '../../types'
import { ref, toRef } from 'vue'
import { IconifyIcon as Icon } from '@vben/icons'
import { FriendItem } from '../../components/friend'
import { useFriendBuckets } from '../../composables/useFriendBuckets'
defineOptions({ name: 'ImContactFriendList' })
const props = defineProps<{
activeId?: number
friends: FriendLite[]
keyword: string
}>()
const emit = defineEmits<{
chat: [friend: FriendLite]
delete: [friend: FriendLite]
select: [friend: FriendLite]
}>()
const expanded = ref(true)
const { filtered, buckets } = useFriendBuckets(toRef(props, 'friends'), toRef(props, 'keyword'))
</script>
<template>
<!--
通讯录 - 好友分组
- 自治折叠状态 + 关键字过滤 + 字母分桶 本组件内闭环
- 字母分桶 / 拼音搜索委托 useFriendBuckets与选择类弹窗 FriendPickerPanel 共用一份规则
- 选中态由父级 activeId 透传chat / delete 透传到父级走 store 改造
-->
<div>
<!-- 折叠分组头字号对齐微信 PC15pxhover 浅底色反馈 -->
<div
class="flex gap-2 items-center px-3.5 py-2.5 cursor-pointer select-none text-15px text-[var(--ant-color-text)] hover:bg-[var(--ant-color-fill-secondary)]"
@click="expanded = !expanded"
>
<Icon :icon="expanded ? 'ep:caret-bottom' : 'ep:caret-right'" :size="14" />
<span class="flex-1">好友</span>
<span class="text-sm text-[var(--ant-color-text-secondary)]">{{ filtered.length }}</span>
</div>
<div v-show="expanded">
<template v-for="bucket in buckets" :key="bucket.letter">
<!-- 字母分桶 header浅底 + 小字号作为好友列表内部分隔 -->
<div
class="pt-1 pb-0.5 px-3.5 text-12px text-[var(--ant-color-text-secondary)] bg-[var(--ant-color-fill-tertiary)]"
>
{{ bucket.letter }}
</div>
<FriendItem
v-for="friend in bucket.list"
:key="friend.id"
:friend="friend"
:active="activeId === friend.id"
@click="emit('select', friend)"
@chat="emit('chat', $event)"
@delete="emit('delete', $event)"
/>
</template>
<div
v-if="filtered.length === 0"
class="py-3 text-12px text-center text-[var(--ant-color-text-disabled)]"
>
{{ keyword ? '没有匹配的好友' : '暂无好友' }}
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,229 @@
<script lang="ts" setup>
import type { FriendRequest, User } from '../../types'
import { computed, ref } from 'vue'
import { prompt } from '@vben/common-ui'
import { DICT_TYPE } from '@vben/constants'
import { getDictLabel } from '@vben/hooks'
import { ElButton, ElInput, ElMessage } from 'element-plus'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { ImFriendRequestHandleResult } from '../../../utils/constants'
import { UserAvatar } from '../../components/user'
import { UserInfo } from '../../components/user'
import { useFriendStore } from '../../store/friendStore'
defineOptions({ name: 'ImContactFriendRequestDetail' })
const props = defineProps<{
request: FriendRequest
}>()
const emit = defineEmits<{
chat: [peerUserId: number]
}>()
const friendStore = useFriendStore()
/** 当前登录用户编号;用 computed 包一层,切账号后随 wsCache 重取,避免顶层求值在 keep-alive 实例里持有旧 id */
const currentUserId = computed(() => getCurrentUserId())
/** 是不是我发起的fromUserId === currentUserId */
const iSentIt = computed(() => props.request.fromUserId === currentUserId.value)
/** 是否「已拒绝」态模板里多处用到computed 一次省得到处写枚举比对 */
const refused = computed(
() => props.request.handleResult === ImFriendRequestHandleResult.REFUSED
)
/** 是否「已通过」态:转走 UserInfo 好友详情入口 */
const agreed = computed(
() => props.request.handleResult === ImFriendRequestHandleResult.AGREED
)
/** 对端的用户编号 / 昵称 / 头像 */
const peerUserId = computed(() =>
iSentIt.value ? props.request.toUserId : props.request.fromUserId
)
const peerNickname = computed(() =>
iSentIt.value
? props.request.toNickname || String(props.request.toUserId)
: props.request.fromNickname || String(props.request.fromUserId)
)
const peerAvatar = computed(() =>
iSentIt.value ? props.request.toAvatar : props.request.fromAvatar
)
/** 透给 UserInfo 的最小用户信息UserInfo 内部会按 id 调 getSimpleUser 补齐性别 / 部门 */
const peerUser = computed<User>(() => ({
id: peerUserId.value,
nickname: peerNickname.value,
avatar: peerAvatar.value
}))
// loading spinner processing /
const agreeing = ref(false)
const refusing = ref(false)
const processing = ref(false)
/** 同意申请:互斥锁 + 状态二次校验,避免并发 / 服务端已处理后再次提交 */
async function handleAgree() {
if (processing.value) {
return
}
if (props.request.handleResult !== ImFriendRequestHandleResult.UNHANDLED) {
return
}
processing.value = true
agreeing.value = true
try {
await friendStore.agreeFriendRequest(props.request.id)
ElMessage.success('已同意好友申请')
} finally {
agreeing.value = false
processing.value = false
}
}
/** 拒绝申请:弹 prompt 收集可选拒绝理由(点取消则中止),随后调 store 落库 + 提示 */
async function handleRefuse() {
if (processing.value) {
return
}
if (props.request.handleResult !== ImFriendRequestHandleResult.UNHANDLED) {
return
}
// 1. prompt 255 reject
let handleContent: string | undefined
try {
const result = await prompt<string>({
beforeClose(scope) {
if (!scope.isConfirm) {
return
}
if ((scope.value || '').length > 255) {
ElMessage.error('最多 255 个字符')
return false
}
},
cancelText: '取消',
component: ElInput,
componentProps: {
clearable: true,
maxlength: 255,
placeholder: '不填则不告知对方原因',
rows: 3
},
content: '',
defaultValue: '',
confirmText: '拒绝',
modelPropName: 'value',
title: '拒绝好友申请'
})
handleContent = result || undefined
} catch {
return
}
// 2. prompt AGREED / REFUSED
if (processing.value) {
return
}
if (props.request.handleResult !== ImFriendRequestHandleResult.UNHANDLED) {
return
}
processing.value = true
refusing.value = true
try {
await friendStore.refuseFriendRequest(props.request.id, handleContent)
ElMessage.success('已拒绝好友申请')
} finally {
refusing.value = false
processing.value = false
}
}
</script>
<template>
<!--
新的朋友详情面板
- 已通过 直接走 UserInfo 好友详情跟通讯录里点开好友的体验完全一致
- 未处理 / 已拒绝 申请态面板头像 + 申请/拒绝对话气泡 + 来源 + 操作按钮
-->
<div v-if="agreed" class="flex justify-center pt-12 px-6">
<div class="w-full max-w-[320px]">
<UserInfo
:user="peerUser"
:display-name="friendStore.getFriend(peerUserId)?.displayName || ''"
relation="friend"
@chat="emit('chat', peerUserId)"
/>
</div>
</div>
<div v-else class="flex flex-col items-center px-6 pt-12">
<UserAvatar
:id="peerUserId"
:url="peerAvatar"
:name="peerNickname"
:size="64"
:clickable="false"
/>
<div class="mt-3 text-base font-semibold text-[var(--ant-color-text)]">
{{ peerNickname }}
</div>
<!-- 申请理由块仿微信灰底气泡按申请方身份前缀长文本 break-words 折行 -->
<div
v-if="request.applyContent"
class="w-full max-w-[420px] mt-6 px-3.5 py-3 rounded-md bg-[var(--ant-color-fill-secondary)] text-13px text-[var(--ant-color-text)] break-words"
>
<span class="text-[var(--ant-color-text-secondary)]">
{{ iSentIt ? '我' : peerNickname }}:
</span>
<span class="ml-1">{{ request.applyContent }}</span>
</div>
<!-- 来源行label value 对齐微信来源 -->
<div
v-if="request.addSource"
class="w-full max-w-[420px] mt-3 flex items-center text-13px text-[var(--ant-color-text-secondary)]"
>
<span class="w-16 flex-shrink-0 whitespace-nowrap">来源</span>
<span class="text-[var(--ant-color-text)]">
{{ getDictLabel(DICT_TYPE.IM_FRIEND_ADD_SOURCE, request.addSource) }}
</span>
</div>
<!-- 拒绝理由label value 长文本 break-words 自动折行 -->
<div
v-if="refused && request.handleContent"
class="w-full max-w-[420px] mt-3 flex items-start text-13px text-[var(--ant-color-text-secondary)]"
>
<span class="w-16 flex-shrink-0 whitespace-nowrap">拒绝理由</span>
<span class="flex-1 min-w-0 break-words text-[var(--ant-color-text)]">
{{ request.handleContent }}
</span>
</div>
<!-- 操作按钮 -->
<div class="w-full max-w-[420px] mt-8 flex justify-center">
<!-- 我发起 + 等待中禁用等待对方验证 -->
<ElButton
v-if="iSentIt && request.handleResult === ImFriendRequestHandleResult.UNHANDLED"
disabled
>
等待对方验证
</ElButton>
<!-- 别人加我 + 等待中同意 / 拒绝 -->
<template v-if="!iSentIt && request.handleResult === ImFriendRequestHandleResult.UNHANDLED">
<ElButton @click="handleRefuse" :loading="refusing" :disabled="processing">拒绝</ElButton>
<ElButton type="primary" @click="handleAgree" :loading="agreeing" :disabled="processing">
同意
</ElButton>
</template>
<!-- 已拒绝占位禁用按钮 -->
<ElButton v-if="refused" disabled>已拒绝</ElButton>
</div>
</div>
</template>

View File

@ -0,0 +1,138 @@
<script lang="ts" setup>
import type { FriendRequest } from '../../types'
import { computed, ref } from 'vue'
import { DICT_TYPE } from '@vben/constants'
import { getDictLabel } from '@vben/hooks'
import { IconifyIcon as Icon } from '@vben/icons'
import { ElBadge } from 'element-plus'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { UserAvatar } from '../../components/user'
import { useFriendStore } from '../../store/friendStore'
defineOptions({ name: 'ImContactFriendRequestList' })
const props = defineProps<{
activeId?: number
requests: FriendRequest[]
}>()
const emit = defineEmits<{
select: [request: FriendRequest]
}>()
const friendStore = useFriendStore()
const expanded = ref(true)
/** 当前登录用户编号;用 computed 包一层,切账号后随 wsCache 重取,避免顶层求值在 keep-alive 实例里持有旧 id */
const currentUserId = computed(() => getCurrentUserId())
/** 列表项展示对端fromUserId == 我 → 对端 = toUser否则对端 = fromUser */
function getPeer(request: FriendRequest) {
const sentByMe = request.fromUserId === currentUserId.value
return {
id: sentByMe ? request.toUserId : request.fromUserId,
nickname: sentByMe
? request.toNickname || String(request.toUserId)
: request.fromNickname || String(request.fromUserId),
avatar: sentByMe ? request.toAvatar : request.fromAvatar
}
}
/** 列表项预先附 peer 字段,模板里直接 {{ peer.xxx }} 一次成型,省 3 次 helper 调用 */
const enrichedRequests = computed(() =>
props.requests.map((request) => ({ request, peer: getPeer(request) }))
)
const loadingMore = ref(false) // store maxId + pending
async function handleLoadMore() {
if (loadingMore.value) {
return
}
loadingMore.value = true
try {
await friendStore.loadMoreFriendRequestList()
} finally {
loadingMore.value = false
}
}
</script>
<template>
<!--
通讯录 - 新的朋友分组
- 自治折叠状态由组件内 ref 管理默认展开
- 列表项展示头像 + 昵称 + 申请理由 + 状态标签等待验证 / 已添加 / 已拒绝
- 选中态由父级 activeId 透传点击触发 select 事件
-->
<div>
<!-- 折叠分组头 -->
<div
class="flex gap-2 items-center px-3.5 py-2.5 text-15px text-[var(--ant-color-text)] cursor-pointer select-none hover:bg-[var(--ant-color-fill-secondary)]"
@click="expanded = !expanded"
>
<Icon :icon="expanded ? 'ep:caret-bottom' : 'ep:caret-right'" :size="14" />
<span class="flex-1">新的朋友</span>
<!-- 红点未处理且别人加我的统一走 store getter避免本地 computed store 双口径 -->
<ElBadge
v-if="friendStore.getUnhandledRequestCount > 0"
:count="friendStore.getUnhandledRequestCount"
:max="99"
class="mr-2"
/>
<span class="text-sm text-[var(--ant-color-text-secondary)]">{{ requests.length }}</span>
</div>
<div v-show="expanded">
<div
v-for="{ request, peer } in enrichedRequests"
:key="request.id"
class="flex gap-3 items-start px-3.5 py-2.5 cursor-pointer transition-colors hover:bg-[var(--ant-color-fill-secondary)]"
:class="{
'bg-[var(--ant-color-fill)]': activeId === request.id
}"
@click="emit('select', request)"
>
<UserAvatar
:id="peer.id"
:url="peer.avatar"
:name="peer.nickname"
:size="36"
:clickable="false"
/>
<div class="flex-1 min-w-0 overflow-hidden">
<div class="flex justify-between gap-2 items-center">
<span class="flex-1 text-sm font-medium truncate text-[var(--ant-color-text)]">
{{ peer.nickname }}
</span>
<span class="flex-shrink-0 text-12px text-[var(--ant-color-text-secondary)]">
{{ getDictLabel(DICT_TYPE.IM_FRIEND_REQUEST_HANDLE_RESULT, request.handleResult) }}
</span>
</div>
<div
v-if="request.applyContent"
class="mt-0.5 text-xs truncate text-[var(--ant-color-text-secondary)]"
>
{{ request.applyContent }}
</div>
</div>
</div>
<div
v-if="requests.length === 0"
class="py-3 text-12px text-center text-[var(--ant-color-text-disabled)]"
>
暂无新的朋友
</div>
<!-- 加载更多按本地最旧 requestId 游标分页拉下一批hasMore=false 不展示 -->
<div
v-else-if="friendStore.hasMoreFriendRequests"
class="py-2 text-12px text-center cursor-pointer text-[var(--ant-color-text-secondary)] hover:bg-[var(--ant-color-fill-secondary)]"
@click="handleLoadMore"
>
{{ loadingMore ? '加载中…' : '加载更多' }}
</div>
</div>
</div>
</template>

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