feat(im):初始化 ele 的 im 迁移
parent
5a4f8b4e2a
commit
dfe4c8a040
|
|
@ -670,6 +670,9 @@ export const useFriendStore = defineStore('imFriendStore', {
|
|||
const existingIndex = this.friendRequests.findIndex((item) => item.id === payload.requestId)
|
||||
if (existingIndex !== -1) {
|
||||
const existing = this.friendRequests.splice(existingIndex, 1)[0]
|
||||
if (!existing) {
|
||||
return
|
||||
}
|
||||
const next = {
|
||||
...existing,
|
||||
fromUserId: payload.operatorUserId,
|
||||
|
|
|
|||
|
|
@ -488,13 +488,17 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
const messages = this.getMessageList(conversationInfo.type, conversationInfo.targetId)
|
||||
const existingIndex = messages.findIndex((existing) => isSameMessage(existing, message))
|
||||
if (existingIndex !== -1) {
|
||||
const existing = messages[existingIndex]
|
||||
if (!existing) {
|
||||
continue
|
||||
}
|
||||
// 1.3 已存在消息合并服务端状态
|
||||
applyServerMessageUpdate(messages[existingIndex], message)
|
||||
applyServerMessageUpdate(existing, message)
|
||||
if (existingIndex === messages.length - 1) {
|
||||
recomputeConversationLast(conversation, messages)
|
||||
syncConversationAtFlags(conversation, message)
|
||||
}
|
||||
addChanged(conversation, messages[existingIndex], {
|
||||
addChanged(conversation, existing, {
|
||||
mergeClientRecord: hasServerClientMessageId
|
||||
})
|
||||
continue
|
||||
|
|
@ -580,14 +584,18 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
const existingIndex = messages.findIndex((item) => isSameMessage(item, message))
|
||||
// 3. 已存在消息走覆盖更新
|
||||
if (existingIndex !== -1) {
|
||||
applyServerMessageUpdate(messages[existingIndex], message)
|
||||
const existing = messages[existingIndex]
|
||||
if (!existing) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
applyServerMessageUpdate(existing, message)
|
||||
if (existingIndex === messages.length - 1) {
|
||||
recomputeConversationLast(conversation, messages)
|
||||
syncConversationAtFlags(conversation, message)
|
||||
}
|
||||
return getDb()
|
||||
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
|
||||
await this.saveMessageRecord(messages[existingIndex], conversationInfo.type, tx, {
|
||||
await this.saveMessageRecord(existing, conversationInfo.type, tx, {
|
||||
mergeClientRecord: hasIncomingClientMessageId
|
||||
})
|
||||
await conversationStore.saveConversationRecord(conversation, tx)
|
||||
|
|
@ -876,6 +884,9 @@ export const useMessageStore = defineStore('imMessageStore', {
|
|||
}
|
||||
// 2. 从内存移除消息
|
||||
const [removed] = messages.splice(index, 1)
|
||||
if (!removed) {
|
||||
return
|
||||
}
|
||||
revokeBlobUrlsInContent(removed.content)
|
||||
if (index === messages.length) {
|
||||
recomputeConversationLast(conversation, messages)
|
||||
|
|
|
|||
|
|
@ -38,8 +38,8 @@ const props = defineProps<{
|
|||
type?: number;
|
||||
}>();
|
||||
|
||||
const payload = computed<Record<string, any> | undefined>(() =>
|
||||
parseMessage<Record<string, any>>(props.content || ''),
|
||||
const payload = computed<null | Record<string, any>>(() =>
|
||||
parseMessage<Record<string, any>>(props.content ?? ''),
|
||||
);
|
||||
|
||||
const textContent = computed(() => payload.value?.content || '');
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<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';
|
||||
|
||||
|
|
@ -27,7 +27,10 @@ const chartRef = ref<EchartsUIType>();
|
|||
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') {
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useAccessStore, useUserStore } from '@vben/stores'
|
||||
|
||||
// TODO @AI:是不是换成 vben 里更合适的方法;
|
||||
// TODO DONE @AI:已使用 Vben 的 useUserStore / useAccessStore 获取登录信息;
|
||||
|
||||
/** 获取当前用户编号 */
|
||||
export function getCurrentUserId(): number {
|
||||
|
|
|
|||
|
|
@ -244,7 +244,7 @@ function computeCellRectsSmall(count: number, target: number, divider: number):
|
|||
function computeCellRectsMedium(count: number, target: number, divider: number): CellRect[] {
|
||||
const s = (target - 4 * divider) / 3
|
||||
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
|
||||
const totalH = 2 * s + divider
|
||||
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[] {
|
||||
const s = (target - 4 * divider) / 3
|
||||
const step = s + divider
|
||||
const xs = [divider, divider + step, divider + 2 * step]
|
||||
const ys = [divider, divider + step, divider + 2 * step]
|
||||
const xs = [divider, divider + step, divider + 2 * step] as const
|
||||
const ys = [divider, divider + step, divider + 2 * step] as const
|
||||
const rects: CellRect[] = []
|
||||
if (count === 7) {
|
||||
// 上 1 居中 + 中 3 + 下 3
|
||||
|
|
@ -280,9 +280,9 @@ function computeCellRectsLarge(count: number, target: number, divider: number):
|
|||
return rects
|
||||
}
|
||||
// count === 9:3×3 满铺
|
||||
for (let row = 0; row < 3; row++) {
|
||||
for (let col = 0; col < 3; col++) {
|
||||
rects.push({ x: xs[col], y: ys[row], w: s, h: s })
|
||||
for (const y of ys) {
|
||||
for (const x of xs) {
|
||||
rects.push({ x, y, w: s, h: s })
|
||||
}
|
||||
}
|
||||
return rects
|
||||
|
|
|
|||
|
|
@ -76,6 +76,9 @@ export async function runIncrementalPull<T extends PullRecord>(
|
|||
}
|
||||
// 推进游标到本页最后一条并持久化:下次从这里接着拉
|
||||
const last = list[list.length - 1]
|
||||
if (!last) {
|
||||
return
|
||||
}
|
||||
if (last.updateTime == null) {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@
|
|||
"element-plus": "catalog:",
|
||||
"fast-xml-parser": "catalog:",
|
||||
"highlight.js": "catalog:",
|
||||
"livekit-client": "catalog:",
|
||||
"pinia": "catalog:",
|
||||
"steady-xml": "catalog:",
|
||||
"tinymce": "catalog:",
|
||||
|
|
|
|||
|
|
@ -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 } },
|
||||
);
|
||||
}
|
||||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
|
|
@ -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 } },
|
||||
);
|
||||
}
|
||||
|
|
@ -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; // 当前登录用户在该群的成员状态(参见 CommonStatusEnum:0 在群 / 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
);
|
||||
}
|
||||
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
|
|
@ -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(',') } },
|
||||
);
|
||||
}
|
||||
|
|
@ -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(',') },
|
||||
});
|
||||
}
|
||||
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
|
|
@ -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 } },
|
||||
);
|
||||
}
|
||||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
|
|
@ -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 } },
|
||||
);
|
||||
}
|
||||
|
|
@ -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 } },
|
||||
);
|
||||
}
|
||||
|
|
@ -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 } },
|
||||
);
|
||||
}
|
||||
|
|
@ -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(',') } },
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
);
|
||||
}
|
||||
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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 } },
|
||||
);
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
||||
/** 通话会话 VO;create / 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 } },
|
||||
);
|
||||
}
|
||||
|
|
@ -9,16 +9,23 @@ export namespace SystemUserApi {
|
|||
username: string;
|
||||
nickname: string;
|
||||
deptId: number;
|
||||
deptName?: string;
|
||||
postIds: string[];
|
||||
email: string;
|
||||
mobile: string;
|
||||
sex: number;
|
||||
avatar: string;
|
||||
loginIp: string;
|
||||
loginDate?: Date;
|
||||
status: number;
|
||||
remark: string;
|
||||
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}`);
|
||||
}
|
||||
|
||||
/** 查询用户列表 */
|
||||
export function getUserList(ids: number[]) {
|
||||
return requestClient.get<SystemUserApi.User[]>('/system/user/list', {
|
||||
params: { ids: ids.join(',') },
|
||||
});
|
||||
}
|
||||
|
||||
/** 新增用户 */
|
||||
export function createUser(data: SystemUserApi.User) {
|
||||
return requestClient.post('/system/user/create', data);
|
||||
|
|
@ -86,3 +100,20 @@ export function updateUserStatus(id: number, status: number) {
|
|||
export function getSimpleUserList() {
|
||||
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.
|
|
@ -12,13 +12,43 @@ interface DictTagProps {
|
|||
icon?: string; // 图标
|
||||
}
|
||||
|
||||
type TagType = '' | 'danger' | 'info' | 'primary' | 'success' | 'warning';
|
||||
type TagProps = { type?: Exclude<TagType, ''> };
|
||||
|
||||
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 defaultDict = {
|
||||
const defaultDict: { colorType: TagType; label: string } = {
|
||||
label: '',
|
||||
colorType: 'primary',
|
||||
colorType: '',
|
||||
};
|
||||
// 校验参数有效性
|
||||
if (!props.type || props.value === undefined || props.value === null) {
|
||||
|
|
@ -31,45 +61,20 @@ const dictTag = computed(() => {
|
|||
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 {
|
||||
label: dict.label || '',
|
||||
colorType,
|
||||
colorType: getTagType(dict.colorType),
|
||||
};
|
||||
});
|
||||
|
||||
/** 获取标签属性 */
|
||||
const tagProps = computed<TagProps>(() =>
|
||||
dictTag.value.colorType ? { type: dictTag.value.colorType } : {},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElTag v-if="dictTag.label" :type="dictTag.colorType as any">
|
||||
<ElTag v-if="dictTag.label" v-bind="tagProps">
|
||||
{{ dictTag.label }}
|
||||
</ElTag>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
AntdProfileOutlined,
|
||||
BookOpenText,
|
||||
CircleHelp,
|
||||
IconifyIcon,
|
||||
SvgGithubIcon,
|
||||
} from '@vben/icons';
|
||||
import {
|
||||
|
|
@ -27,7 +28,7 @@ import { preferences, usePreferences } from '@vben/preferences';
|
|||
import { useAccessStore, useUserStore } from '@vben/stores';
|
||||
import { formatDateTime, openWindow } from '@vben/utils';
|
||||
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { ElMessage, ElTooltip } from 'element-plus';
|
||||
|
||||
import {
|
||||
getUnreadNotifyMessageCount,
|
||||
|
|
@ -156,6 +157,12 @@ function handleNotificationOpen(open: boolean) {
|
|||
handleNotificationGetUnreadCount();
|
||||
}
|
||||
|
||||
/** 打开 IM 聊天 */
|
||||
function handleOpenImHome() {
|
||||
const { href } = router.resolve({ name: 'ImHome' });
|
||||
window.open(href, '_blank');
|
||||
}
|
||||
|
||||
// 租户列表
|
||||
const tenants = ref<SystemTenantApi.Tenant[]>([]);
|
||||
const tenantEnable = computed(
|
||||
|
|
@ -276,6 +283,17 @@ watch(
|
|||
/>
|
||||
</div>
|
||||
</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>
|
||||
<AuthenticationLoginExpiredModal
|
||||
v-model:open="accessStore.loginExpired"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { default as CardBubble } from './card-bubble.vue';
|
||||
export { default as CardLineLabel } from './card-line-label.vue';
|
||||
|
|
@ -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 = 9px(my-1 上下各 4 + 1px border),仅非首项的 divided 计入;
|
||||
* menuHeight 额外加 8 是外层 py-1 的上下 padding 之和(4px × 2)
|
||||
*/
|
||||
const adjustedPosition = computed(() => {
|
||||
const items = contextMenu.value.items
|
||||
const itemHeight = 34
|
||||
const dividerCount = items.filter((it, i) => it.divided && i > 0).length
|
||||
const menuHeight = items.length * itemHeight + dividerCount * 9 + 8
|
||||
const menuWidth = 120
|
||||
let x = contextMenu.value.position.x
|
||||
let y = contextMenu.value.position.y
|
||||
// SSR 兜底:window 不可用时直接返回原始坐标
|
||||
if (typeof window !== 'undefined') {
|
||||
if (y + menuHeight > window.innerHeight) {
|
||||
y = window.innerHeight - menuHeight
|
||||
}
|
||||
if (x + menuWidth > window.innerWidth) {
|
||||
x = window.innerWidth - menuWidth
|
||||
}
|
||||
}
|
||||
// 视口很小 / 菜单项很多时上面减法会算出负值,把菜单顶 / 左边推到 0 兜底
|
||||
return { x: Math.max(0, x), y: Math.max(0, y) }
|
||||
})
|
||||
|
||||
type MenuItem = (typeof contextMenu.value.items)[number]
|
||||
|
||||
/** 选中菜单项:disabled 项忽略;正常项调 onSelect 回调后关闭菜单 */
|
||||
function handleSelect(item: MenuItem) {
|
||||
if (item.disabled) {
|
||||
return
|
||||
}
|
||||
uiStore.contextMenu.onSelect?.(item)
|
||||
uiStore.closeContextMenu()
|
||||
}
|
||||
|
||||
/** 关闭菜单:点遮罩 / 在遮罩上再次右键都会触发 */
|
||||
function handleClose() {
|
||||
uiStore.closeContextMenu()
|
||||
}
|
||||
</script>
|
||||
|
||||
<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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { default as FriendAddDialog } from './friend-add-dialog.vue';
|
||||
export { default as FriendItem } from './friend-item.vue';
|
||||
|
|
@ -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 委托 GroupMemberPickerPanel(grid 形态对齐当前视觉)
|
||||
- 群主从候选里隐藏(不能设为管理员)
|
||||
- 对外接口: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>
|
||||
|
|
@ -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 拼图并写回 mergedUrl;mergeToken 校验避免老 await 覆盖新结果 */
|
||||
async function applyMerge(key: string, targetSize: number): Promise<void> {
|
||||
const myToken = ++mergeToken
|
||||
const cached = getCachedGroupAvatar(key)
|
||||
if (cached) {
|
||||
mergedUrl.value = cached
|
||||
return
|
||||
}
|
||||
const dataUrl = await buildGroupAvatar(memberItems.value, { targetSize })
|
||||
if (myToken !== mergeToken) {
|
||||
return
|
||||
}
|
||||
if (dataUrl) {
|
||||
setCachedGroupAvatar(key, dataUrl)
|
||||
}
|
||||
mergedUrl.value = dataUrl
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.url, props.groupId, props.size, memberSignature.value] as const,
|
||||
([url, groupId, size, signature]) => {
|
||||
if (url) {
|
||||
mergedUrl.value = ''
|
||||
return
|
||||
}
|
||||
if (!signature) {
|
||||
mergeToken++
|
||||
mergedUrl.value = ''
|
||||
groupStore.loadGroupMemberList(groupId)
|
||||
return
|
||||
}
|
||||
const targetSize = getTargetSize(size)
|
||||
const key = `${groupId}:${targetSize}:${signature}`
|
||||
applyMerge(key, targetSize)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
/** 最终展示 url:服务端 url 优先 → 拼图 → 空字符串(让 UserAvatar 走色卡) */
|
||||
const finalUrl = computed(() => props.url || mergedUrl.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- url 非空走原图;url 空时取前 9 个成员头像拼九宫格 dataURL,成员未在 store 缓存时走色卡兜底 -->
|
||||
<UserAvatar
|
||||
:url="finalUrl"
|
||||
:name="name"
|
||||
:size="size"
|
||||
:radius="radius"
|
||||
:clickable="clickable"
|
||||
:previewable="previewable"
|
||||
:preview-z-index="previewZIndex"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 渲染到 body,scoped 选不到,留非 scoped 全局覆盖 */
|
||||
.im-group-request-list__dialog .el-dialog__body {
|
||||
padding: 12px 20px 8px;
|
||||
background-color: var(--ant-color-fill-secondary);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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 的 :key:caller 传 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>
|
||||
|
|
@ -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[]
|
||||
/** 已选会话 key(v-model);key 由 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>
|
||||
|
|
@ -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
|
||||
/** 已选好友 id(v-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))
|
||||
|
||||
/** 候选好友:剔除 hideIds(hide 优先级最高) */
|
||||
const candidates = computed(() =>
|
||||
props.friends.filter((friend) => !hideSet.value.has(friend.id))
|
||||
)
|
||||
|
||||
/** 委托 useFriendBuckets:搜索规则复用,左侧列表按滚动分页渲染 */
|
||||
const { filtered } = useFriendBuckets(candidates, keyword)
|
||||
|
||||
/** 已选数 + 已选好友列表:三态优先级 + 顺序拼接由 useSelectedItems 统一承担 */
|
||||
const { selectedCount, selectedItems: selectedFriends } = useSelectedItems<FriendLite>(
|
||||
() => props.selectedIds,
|
||||
() => props.lockedIds,
|
||||
() => props.disabledIds,
|
||||
() => props.hideIds,
|
||||
byId
|
||||
)
|
||||
|
||||
/** 是否被锁定 */
|
||||
function isLocked(friend: FriendLite): boolean {
|
||||
return lockedSet.value.has(friend.id)
|
||||
}
|
||||
|
||||
/** 是否被禁用:locked / hide 已被前置过滤,剩下的才算 disabled */
|
||||
function isDisabled(friend: FriendLite): boolean {
|
||||
return !lockedSet.value.has(friend.id) && disabledSet.value.has(friend.id)
|
||||
}
|
||||
|
||||
/** 是否选中:locked 视为永远选中 */
|
||||
function isSelected(friend: FriendLite): boolean {
|
||||
return selectedSet.value.has(friend.id)
|
||||
}
|
||||
|
||||
/** 圆形勾选指示器的 class:选中 / 锁定走绿底,禁用灰底,未选空心圆 */
|
||||
function getCheckClass(friend: FriendLite): string {
|
||||
if (isLocked(friend) || isSelected(friend)) {
|
||||
return 'bg-[#07c160] border border-solid border-[#07c160]'
|
||||
}
|
||||
if (isDisabled(friend)) {
|
||||
return 'bg-[var(--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 default(32px),保证两侧第一项起点同水平 -->
|
||||
<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>
|
||||
|
|
@ -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'
|
||||
/** 已选 userId(v-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 default(32px) -->
|
||||
<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>
|
||||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 拿 token;rtcStore 按 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>
|
||||
|
|
@ -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>
|
||||
<!--
|
||||
ToolBar:IM 左侧工具栏
|
||||
布局:顶部头像 → 中间三 Tab(消息/好友/群聊)→ 底部设置
|
||||
-->
|
||||
<div
|
||||
class="flex flex-col items-center w-14 pt-4 pb-3 gap-2 flex-shrink-0 select-none bg-[#2b2b2b]"
|
||||
>
|
||||
<!-- 顶部用户头像,点击跳个人中心;走 UserAvatar 统一首字 / 哈希配色规则 -->
|
||||
<div class="mb-2 cursor-pointer" @click="goProfile">
|
||||
<UserAvatar
|
||||
:url="userStore.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>
|
||||
|
|
@ -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';
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认发送(会话视图):每个选中会话先发 CARD,CARD 成功后才发留言(保证「先看到名片」的顺序意图)
|
||||
*
|
||||
* 文案聚合:全部成功「已转发」、全部失败「转发失败:A、B」、部分失败「已转发,但 X、Y 失败」;
|
||||
* 失败的消息以 FAILED 状态留在对应会话气泡里,可右键重试
|
||||
*/
|
||||
async function handleSend() {
|
||||
const card = target.value
|
||||
if (!card?.targetId || selectedKeys.value.length === 0) {
|
||||
return
|
||||
}
|
||||
const byKey = new Map(candidateConversations.value.map((c) => [getConversationKey(c), c]))
|
||||
const targets = selectedKeys.value
|
||||
.map((key) => byKey.get(key))
|
||||
.filter((c): c is Conversation => c != null)
|
||||
if (targets.length === 0) {
|
||||
return
|
||||
}
|
||||
const cardContent = serializeMessage({ ...card })
|
||||
const leaveText = leaveMessage.value.trim()
|
||||
sending.value = true
|
||||
try {
|
||||
const tasks = targets.map(async (conversation) => {
|
||||
const cardOk = await sendRaw(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>
|
||||
|
|
@ -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 时展示色卡 + 首字母/首字
|
||||
- 点击默认触发 UserInfoCard(clickable)
|
||||
- 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>>() // 加好友弹窗 ref:handleAddFriend 调 open({ presetUser, addSource, addSourceExtra }) 触发
|
||||
|
||||
const recommendDialogRef = ref<InstanceType<typeof RecommendCardDialog>>() // 推荐名片弹窗 ref:handleRecommend 调用 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 图标;陌生人 = 加为好友按钮;自己 = disabled;readonly 不渲染 -->
|
||||
<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>
|
||||
/*
|
||||
非 scoped:el-dropdown 的下拉菜单走 teleport 到 body,scoped 选不到。
|
||||
UserInfoCard 浮层用 z-9998,要把这块抬到更高(默认 --ant-z-index-popup-base ~2050 会被遮罩压住)。
|
||||
*/
|
||||
.im-user-info__more-menu {
|
||||
z-index: 10000 !important;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
* 群通话成员列表 computed:joined 在前,未 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 Server;audio / video 控制初始默认开关 */
|
||||
async function connect(url: string, token: string, opts: { audio?: boolean; video?: boolean }) {
|
||||
// 新连接前先断开旧 Room;保留本次注册的事件回调
|
||||
if (_room.value) {
|
||||
await disconnectRoom(false)
|
||||
}
|
||||
const r = new Room({
|
||||
// 按格子尺寸自动选 simulcast 层
|
||||
adaptiveStream: true,
|
||||
// 未订阅的层动态停发,节省上行
|
||||
dynacast: true,
|
||||
// 采集分辨率 720p,确保大格子清晰
|
||||
videoCaptureDefaults: {
|
||||
resolution: VideoPresets.h720.resolution
|
||||
},
|
||||
// 发布编码上限 1.5 Mbps / 30fps;保留默认 simulcast 三层(180p / 360p / 720p)
|
||||
publishDefaults: {
|
||||
videoEncoding: {
|
||||
maxBitrate: 1_500_000,
|
||||
maxFramerate: 30,
|
||||
priority: 'high'
|
||||
},
|
||||
// 屏幕共享码率 3 Mbps,文字界面清晰
|
||||
screenShareEncoding: {
|
||||
maxBitrate: 3_000_000,
|
||||
maxFramerate: 15,
|
||||
priority: 'medium'
|
||||
}
|
||||
}
|
||||
})
|
||||
_room.value = r
|
||||
|
||||
r.on(RoomEvent.ParticipantConnected, (rp) => {
|
||||
syncRemotes(r)
|
||||
const userId = parseUserId(rp.identity)
|
||||
if (userId != null) {
|
||||
participantConnectedHandlers.forEach((cb) => cb(userId))
|
||||
}
|
||||
})
|
||||
.on(RoomEvent.ParticipantDisconnected, (rp) => {
|
||||
syncRemotes(r)
|
||||
// 离开的参与者缓存清掉,避免下次同 sid 重连命中失效引用
|
||||
for (const key of streamCache.keys()) {
|
||||
if (key.startsWith(`${rp.sid}:`)) {
|
||||
streamCache.delete(key)
|
||||
}
|
||||
}
|
||||
const userId = parseUserId(rp.identity)
|
||||
if (userId != null) {
|
||||
participantDisconnectedHandlers.forEach((cb) => cb(userId))
|
||||
}
|
||||
})
|
||||
.on(RoomEvent.TrackSubscribed, () => syncRemotes(r))
|
||||
.on(RoomEvent.TrackUnsubscribed, () => syncRemotes(r))
|
||||
// mute / unmute 让 pickStream 的 isMuted 短路重算,video 元素能解绑 srcObject 而不是卡最后一帧
|
||||
.on(RoomEvent.TrackMuted, () => syncRemotes(r))
|
||||
.on(RoomEvent.TrackUnmuted, () => syncRemotes(r))
|
||||
.on(RoomEvent.ConnectionQualityChanged, (quality) => {
|
||||
connectionQuality.value = quality
|
||||
})
|
||||
// 瞬断 → 显示「重连中」;不关通话窗,由 SDK 内部重连机制恢复
|
||||
.on(RoomEvent.Reconnecting, () => {
|
||||
reconnecting.value = true
|
||||
})
|
||||
.on(RoomEvent.Reconnected, () => {
|
||||
reconnecting.value = false
|
||||
})
|
||||
// 重连失败 / 主动断 / 被踢时触发清理
|
||||
.on(RoomEvent.Disconnected, () => {
|
||||
isConnected.value = false
|
||||
reconnecting.value = false
|
||||
disconnectedHandlers.forEach((cb) => cb())
|
||||
})
|
||||
|
||||
// 预热 getUserMedia 与 WebSocket 握手并行,省 100~300ms 串行延迟;
|
||||
// 拿到的 stream 仅用于触发权限弹窗 + 设备就绪,握手完成后由 LiveKit 内部重新请求设备发布轨
|
||||
const warmup = prewarmMedia(opts)
|
||||
// 建立 WebSocket 信令 + WebRTC 媒体通道;完成后 localParticipant 可用,已在房参与者会通过 ParticipantConnected 事件批量推送
|
||||
await r.connect(url, token)
|
||||
// 期间被外部 disconnect 替换;中止后续 publish,避免摄像头被重新启用
|
||||
if (_room.value !== r) {
|
||||
return
|
||||
}
|
||||
localParticipant.value = r.localParticipant
|
||||
isConnected.value = true
|
||||
|
||||
// 预热结果不直接发布(避免 SDK 与外部 track 生命周期纠缠),仅等待权限就绪后再走标准 setXxxEnabled
|
||||
await warmup
|
||||
if (_room.value !== r) {
|
||||
return
|
||||
}
|
||||
// 麦克风与摄像头权限相互独立,并行启用发布
|
||||
const inits: Promise<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
|
||||
}
|
||||
|
||||
/** 断开当前 Room;clearHandlers 为 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>
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 URL,commit 时是真实 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 走 uploadAndSendMedia;video 走低层 helper 自行组装) */
|
||||
export interface UploadAndSendMediaOptions {
|
||||
file: File
|
||||
/** 对齐 ImContentType;mediaTypeHandlers 必须有对应项 */
|
||||
type: number
|
||||
/** 媒体特定的元数据(如语音时长 / 视频元信息);不传按空对象处理 */
|
||||
context?: MediaTypeContext
|
||||
/** 引用消息(若有),写进 payload.quote */
|
||||
quote?: QuoteMessage
|
||||
/** 锁定起始会话,上传期间会话切走则放弃发送 */
|
||||
conversation: Conversation
|
||||
/** 重试已有占位消息时复用的客户端消息编号 */
|
||||
existingClientMessageId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 媒体上传 + 发送 composable(image / file / voice / video 共用底层 helper)
|
||||
*
|
||||
* 与 useMessageSender.sendRaw 的「先发请求再 ack」不同,媒体链路必须「先占位再上传」:
|
||||
* 1. 立即 insertMessage 写入占位消息(status=SENDING、content 用 blob URL、_localFile 内存留 File)
|
||||
* 2. uploadFile 上传,onUploadProgress 回调 patchMessage 更新 uploadProgress;UI 实时显示进度条
|
||||
* 3. 上传成功后用真实 url 重生 content,patchMessage 替换;旧 blob URL 由 store 自动 revoke
|
||||
* 4. 走 sendRaw(existingClientMessageId) 复用占位发送请求,避免重复插入两条
|
||||
*
|
||||
* 任意失败把消息状态置 FAILED;MessageItem 上点重试再走一次本函数(_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()
|
||||
|
||||
/**
|
||||
* 立即写入媒体占位消息(低层 helper;image/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 复用 existingClientMessageId;store 内部 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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import type { Message } from '../types'
|
||||
|
||||
import { computed, reactive } from 'vue'
|
||||
|
||||
/**
|
||||
* 消息多选模式
|
||||
*
|
||||
* 模块级单例 reactive state;MessagePanel 子树内多处共享:
|
||||
* - 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 }
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
/** 服务端私聊消息 -> 本地 Message:targetId 是会话主键(对端 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 // 详情页拉富文本用
|
||||
}
|
||||
}
|
||||
|
||||
/** 频道:会话归属到 channelId;name / 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. startEpoch:cancelPull() 递增 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:原消息走批量 insert;RECALL 信号走批量 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
// 入库 + 推进 messageMaxId;nextMinId 为空(本批无有效 id)时不推进游标,与旧逻辑一致
|
||||
await messageStore.applyPulledMessageList(pulledMessages, conversationType, nextMinId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 同一时刻只允许一次 pull:index.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 仍属于当前 session:epoch 未漂 + 用户未切;任何动新 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 }
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
||||
// 订阅模块级 tick;scope 销毁时反订阅,最后一个订阅者退场后 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
|
||||
})
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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. 首拉失败:手动复位 loading(pullOnce 没跑到,它的 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.fullPath,IM 子路由切换(消息 / 通讯录)也能重新加上前缀
|
||||
*/
|
||||
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>
|
||||
|
|
@ -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>
|
||||
<!-- 折叠分组头:字号对齐微信 PC(15px),hover 浅底色反馈 -->
|
||||
<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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
Loading…
Reference in New Issue