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)
|
const existingIndex = this.friendRequests.findIndex((item) => item.id === payload.requestId)
|
||||||
if (existingIndex !== -1) {
|
if (existingIndex !== -1) {
|
||||||
const existing = this.friendRequests.splice(existingIndex, 1)[0]
|
const existing = this.friendRequests.splice(existingIndex, 1)[0]
|
||||||
|
if (!existing) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const next = {
|
const next = {
|
||||||
...existing,
|
...existing,
|
||||||
fromUserId: payload.operatorUserId,
|
fromUserId: payload.operatorUserId,
|
||||||
|
|
|
||||||
|
|
@ -488,13 +488,17 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
const messages = this.getMessageList(conversationInfo.type, conversationInfo.targetId)
|
const messages = this.getMessageList(conversationInfo.type, conversationInfo.targetId)
|
||||||
const existingIndex = messages.findIndex((existing) => isSameMessage(existing, message))
|
const existingIndex = messages.findIndex((existing) => isSameMessage(existing, message))
|
||||||
if (existingIndex !== -1) {
|
if (existingIndex !== -1) {
|
||||||
|
const existing = messages[existingIndex]
|
||||||
|
if (!existing) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
// 1.3 已存在消息合并服务端状态
|
// 1.3 已存在消息合并服务端状态
|
||||||
applyServerMessageUpdate(messages[existingIndex], message)
|
applyServerMessageUpdate(existing, message)
|
||||||
if (existingIndex === messages.length - 1) {
|
if (existingIndex === messages.length - 1) {
|
||||||
recomputeConversationLast(conversation, messages)
|
recomputeConversationLast(conversation, messages)
|
||||||
syncConversationAtFlags(conversation, message)
|
syncConversationAtFlags(conversation, message)
|
||||||
}
|
}
|
||||||
addChanged(conversation, messages[existingIndex], {
|
addChanged(conversation, existing, {
|
||||||
mergeClientRecord: hasServerClientMessageId
|
mergeClientRecord: hasServerClientMessageId
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
|
|
@ -580,14 +584,18 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
const existingIndex = messages.findIndex((item) => isSameMessage(item, message))
|
const existingIndex = messages.findIndex((item) => isSameMessage(item, message))
|
||||||
// 3. 已存在消息走覆盖更新
|
// 3. 已存在消息走覆盖更新
|
||||||
if (existingIndex !== -1) {
|
if (existingIndex !== -1) {
|
||||||
applyServerMessageUpdate(messages[existingIndex], message)
|
const existing = messages[existingIndex]
|
||||||
|
if (!existing) {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
applyServerMessageUpdate(existing, message)
|
||||||
if (existingIndex === messages.length - 1) {
|
if (existingIndex === messages.length - 1) {
|
||||||
recomputeConversationLast(conversation, messages)
|
recomputeConversationLast(conversation, messages)
|
||||||
syncConversationAtFlags(conversation, message)
|
syncConversationAtFlags(conversation, message)
|
||||||
}
|
}
|
||||||
return getDb()
|
return getDb()
|
||||||
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
|
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
|
||||||
await this.saveMessageRecord(messages[existingIndex], conversationInfo.type, tx, {
|
await this.saveMessageRecord(existing, conversationInfo.type, tx, {
|
||||||
mergeClientRecord: hasIncomingClientMessageId
|
mergeClientRecord: hasIncomingClientMessageId
|
||||||
})
|
})
|
||||||
await conversationStore.saveConversationRecord(conversation, tx)
|
await conversationStore.saveConversationRecord(conversation, tx)
|
||||||
|
|
@ -876,6 +884,9 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||||
}
|
}
|
||||||
// 2. 从内存移除消息
|
// 2. 从内存移除消息
|
||||||
const [removed] = messages.splice(index, 1)
|
const [removed] = messages.splice(index, 1)
|
||||||
|
if (!removed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
revokeBlobUrlsInContent(removed.content)
|
revokeBlobUrlsInContent(removed.content)
|
||||||
if (index === messages.length) {
|
if (index === messages.length) {
|
||||||
recomputeConversationLast(conversation, messages)
|
recomputeConversationLast(conversation, messages)
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,8 @@ const props = defineProps<{
|
||||||
type?: number;
|
type?: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const payload = computed<Record<string, any> | undefined>(() =>
|
const payload = computed<null | Record<string, any>>(() =>
|
||||||
parseMessage<Record<string, any>>(props.content || ''),
|
parseMessage<Record<string, any>>(props.content ?? ''),
|
||||||
);
|
);
|
||||||
|
|
||||||
const textContent = computed(() => payload.value?.content || '');
|
const textContent = computed(() => payload.value?.content || '');
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { EchartsUIType } from '@vben/plugins/echarts';
|
import type { EChartsOption, EchartsUIType } from '@vben/plugins/echarts';
|
||||||
|
|
||||||
import { nextTick, onMounted, ref } from 'vue';
|
import { nextTick, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
|
@ -27,7 +27,10 @@ const chartRef = ref<EchartsUIType>();
|
||||||
const { renderEcharts } = useEcharts(chartRef);
|
const { renderEcharts } = useEcharts(chartRef);
|
||||||
|
|
||||||
/** 获取图表配置 */
|
/** 获取图表配置 */
|
||||||
function buildOptions(dates: string[], series: Record<string, number[]>) {
|
function buildOptions(
|
||||||
|
dates: string[],
|
||||||
|
series: Record<string, number[]>,
|
||||||
|
): EChartsOption {
|
||||||
if (props.type === 'message') {
|
if (props.type === 'message') {
|
||||||
return {
|
return {
|
||||||
tooltip: { trigger: 'axis' },
|
tooltip: { trigger: 'axis' },
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useAccessStore, useUserStore } from '@vben/stores'
|
import { useAccessStore, useUserStore } from '@vben/stores'
|
||||||
|
|
||||||
// TODO @AI:是不是换成 vben 里更合适的方法;
|
// TODO DONE @AI:已使用 Vben 的 useUserStore / useAccessStore 获取登录信息;
|
||||||
|
|
||||||
/** 获取当前用户编号 */
|
/** 获取当前用户编号 */
|
||||||
export function getCurrentUserId(): number {
|
export function getCurrentUserId(): number {
|
||||||
|
|
|
||||||
|
|
@ -244,7 +244,7 @@ function computeCellRectsSmall(count: number, target: number, divider: number):
|
||||||
function computeCellRectsMedium(count: number, target: number, divider: number): CellRect[] {
|
function computeCellRectsMedium(count: number, target: number, divider: number): CellRect[] {
|
||||||
const s = (target - 4 * divider) / 3
|
const s = (target - 4 * divider) / 3
|
||||||
const step = s + divider
|
const step = s + divider
|
||||||
const xs = [divider, divider + step, divider + 2 * step]
|
const xs = [divider, divider + step, divider + 2 * step] as const
|
||||||
// 2 行高 + 1 行间 div
|
// 2 行高 + 1 行间 div
|
||||||
const totalH = 2 * s + divider
|
const totalH = 2 * s + divider
|
||||||
const y0 = (target - totalH) / 2
|
const y0 = (target - totalH) / 2
|
||||||
|
|
@ -265,8 +265,8 @@ function computeCellRectsMedium(count: number, target: number, divider: number):
|
||||||
function computeCellRectsLarge(count: number, target: number, divider: number): CellRect[] {
|
function computeCellRectsLarge(count: number, target: number, divider: number): CellRect[] {
|
||||||
const s = (target - 4 * divider) / 3
|
const s = (target - 4 * divider) / 3
|
||||||
const step = s + divider
|
const step = s + divider
|
||||||
const xs = [divider, divider + step, divider + 2 * step]
|
const xs = [divider, divider + step, divider + 2 * step] as const
|
||||||
const ys = [divider, divider + step, divider + 2 * step]
|
const ys = [divider, divider + step, divider + 2 * step] as const
|
||||||
const rects: CellRect[] = []
|
const rects: CellRect[] = []
|
||||||
if (count === 7) {
|
if (count === 7) {
|
||||||
// 上 1 居中 + 中 3 + 下 3
|
// 上 1 居中 + 中 3 + 下 3
|
||||||
|
|
@ -280,9 +280,9 @@ function computeCellRectsLarge(count: number, target: number, divider: number):
|
||||||
return rects
|
return rects
|
||||||
}
|
}
|
||||||
// count === 9:3×3 满铺
|
// count === 9:3×3 满铺
|
||||||
for (let row = 0; row < 3; row++) {
|
for (const y of ys) {
|
||||||
for (let col = 0; col < 3; col++) {
|
for (const x of xs) {
|
||||||
rects.push({ x: xs[col], y: ys[row], w: s, h: s })
|
rects.push({ x, y, w: s, h: s })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return rects
|
return rects
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,9 @@ export async function runIncrementalPull<T extends PullRecord>(
|
||||||
}
|
}
|
||||||
// 推进游标到本页最后一条并持久化:下次从这里接着拉
|
// 推进游标到本页最后一条并持久化:下次从这里接着拉
|
||||||
const last = list[list.length - 1]
|
const last = list[list.length - 1]
|
||||||
|
if (!last) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (last.updateTime == null) {
|
if (last.updateTime == null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@
|
||||||
"element-plus": "catalog:",
|
"element-plus": "catalog:",
|
||||||
"fast-xml-parser": "catalog:",
|
"fast-xml-parser": "catalog:",
|
||||||
"highlight.js": "catalog:",
|
"highlight.js": "catalog:",
|
||||||
|
"livekit-client": "catalog:",
|
||||||
"pinia": "catalog:",
|
"pinia": "catalog:",
|
||||||
"steady-xml": "catalog:",
|
"steady-xml": "catalog:",
|
||||||
"tinymce": "catalog:",
|
"tinymce": "catalog:",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
username: string;
|
||||||
nickname: string;
|
nickname: string;
|
||||||
deptId: number;
|
deptId: number;
|
||||||
|
deptName?: string;
|
||||||
postIds: string[];
|
postIds: string[];
|
||||||
email: string;
|
email: string;
|
||||||
mobile: string;
|
mobile: string;
|
||||||
sex: number;
|
sex: number;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
loginIp: string;
|
loginIp: string;
|
||||||
|
loginDate?: Date;
|
||||||
status: number;
|
status: number;
|
||||||
remark: string;
|
remark: string;
|
||||||
createTime?: Date;
|
createTime?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 用户精简信息 */
|
||||||
|
export interface UserSimple extends User {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 查询用户管理列表 */
|
/** 查询用户管理列表 */
|
||||||
|
|
@ -34,6 +41,13 @@ export function getUser(id: number) {
|
||||||
return requestClient.get<SystemUserApi.User>(`/system/user/get?id=${id}`);
|
return requestClient.get<SystemUserApi.User>(`/system/user/get?id=${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 查询用户列表 */
|
||||||
|
export function getUserList(ids: number[]) {
|
||||||
|
return requestClient.get<SystemUserApi.User[]>('/system/user/list', {
|
||||||
|
params: { ids: ids.join(',') },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** 新增用户 */
|
/** 新增用户 */
|
||||||
export function createUser(data: SystemUserApi.User) {
|
export function createUser(data: SystemUserApi.User) {
|
||||||
return requestClient.post('/system/user/create', data);
|
return requestClient.post('/system/user/create', data);
|
||||||
|
|
@ -86,3 +100,20 @@ export function updateUserStatus(id: number, status: number) {
|
||||||
export function getSimpleUserList() {
|
export function getSimpleUserList() {
|
||||||
return requestClient.get<SystemUserApi.User[]>('/system/user/simple-list');
|
return requestClient.get<SystemUserApi.User[]>('/system/user/simple-list');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按用户编号查询用户精简信息 */
|
||||||
|
export function getSimpleUser(id: number | string) {
|
||||||
|
return requestClient.get<SystemUserApi.UserSimple>('/system/user/get-simple', {
|
||||||
|
params: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按昵称模糊搜索用户 */
|
||||||
|
export function getSimpleUserListByNickname(nickname: string) {
|
||||||
|
return requestClient.get<SystemUserApi.UserSimple[]>(
|
||||||
|
'/system/user/list-by-nickname',
|
||||||
|
{
|
||||||
|
params: { nickname },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -12,13 +12,43 @@ interface DictTagProps {
|
||||||
icon?: string; // 图标
|
icon?: string; // 图标
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TagType = '' | 'danger' | 'info' | 'primary' | 'success' | 'warning';
|
||||||
|
type TagProps = { type?: Exclude<TagType, ''> };
|
||||||
|
|
||||||
const props = defineProps<DictTagProps>();
|
const props = defineProps<DictTagProps>();
|
||||||
|
|
||||||
|
/** 获取标签类型 */
|
||||||
|
function getTagType(colorType?: string): TagType {
|
||||||
|
switch (colorType) {
|
||||||
|
case 'danger': {
|
||||||
|
return 'danger';
|
||||||
|
}
|
||||||
|
case 'default': {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
case 'info': {
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
case 'primary': {
|
||||||
|
return 'primary';
|
||||||
|
}
|
||||||
|
case 'success': {
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
case 'warning': {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 获取字典标签 */
|
/** 获取字典标签 */
|
||||||
const dictTag = computed(() => {
|
const dictTag = computed(() => {
|
||||||
const defaultDict = {
|
const defaultDict: { colorType: TagType; label: string } = {
|
||||||
label: '',
|
label: '',
|
||||||
colorType: 'primary',
|
colorType: '',
|
||||||
};
|
};
|
||||||
// 校验参数有效性
|
// 校验参数有效性
|
||||||
if (!props.type || props.value === undefined || props.value === null) {
|
if (!props.type || props.value === undefined || props.value === null) {
|
||||||
|
|
@ -31,45 +61,20 @@ const dictTag = computed(() => {
|
||||||
return defaultDict;
|
return defaultDict;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理颜色类型
|
|
||||||
let colorType = dict.colorType;
|
|
||||||
switch (colorType) {
|
|
||||||
case 'danger': {
|
|
||||||
colorType = 'danger';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'info': {
|
|
||||||
colorType = 'info';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'primary': {
|
|
||||||
colorType = 'primary';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'success': {
|
|
||||||
colorType = 'success';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'warning': {
|
|
||||||
colorType = 'warning';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
if (!colorType) {
|
|
||||||
colorType = 'primary';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: dict.label || '',
|
label: dict.label || '',
|
||||||
colorType,
|
colorType: getTagType(dict.colorType),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 获取标签属性 */
|
||||||
|
const tagProps = computed<TagProps>(() =>
|
||||||
|
dictTag.value.colorType ? { type: dictTag.value.colorType } : {},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ElTag v-if="dictTag.label" :type="dictTag.colorType as any">
|
<ElTag v-if="dictTag.label" v-bind="tagProps">
|
||||||
{{ dictTag.label }}
|
{{ dictTag.label }}
|
||||||
</ElTag>
|
</ElTag>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
AntdProfileOutlined,
|
AntdProfileOutlined,
|
||||||
BookOpenText,
|
BookOpenText,
|
||||||
CircleHelp,
|
CircleHelp,
|
||||||
|
IconifyIcon,
|
||||||
SvgGithubIcon,
|
SvgGithubIcon,
|
||||||
} from '@vben/icons';
|
} from '@vben/icons';
|
||||||
import {
|
import {
|
||||||
|
|
@ -27,7 +28,7 @@ import { preferences, usePreferences } from '@vben/preferences';
|
||||||
import { useAccessStore, useUserStore } from '@vben/stores';
|
import { useAccessStore, useUserStore } from '@vben/stores';
|
||||||
import { formatDateTime, openWindow } from '@vben/utils';
|
import { formatDateTime, openWindow } from '@vben/utils';
|
||||||
|
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage, ElTooltip } from 'element-plus';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getUnreadNotifyMessageCount,
|
getUnreadNotifyMessageCount,
|
||||||
|
|
@ -156,6 +157,12 @@ function handleNotificationOpen(open: boolean) {
|
||||||
handleNotificationGetUnreadCount();
|
handleNotificationGetUnreadCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 打开 IM 聊天 */
|
||||||
|
function handleOpenImHome() {
|
||||||
|
const { href } = router.resolve({ name: 'ImHome' });
|
||||||
|
window.open(href, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
// 租户列表
|
// 租户列表
|
||||||
const tenants = ref<SystemTenantApi.Tenant[]>([]);
|
const tenants = ref<SystemTenantApi.Tenant[]>([]);
|
||||||
const tenantEnable = computed(
|
const tenantEnable = computed(
|
||||||
|
|
@ -276,6 +283,17 @@ watch(
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<template #header-right-900>
|
||||||
|
<ElTooltip content="IM 聊天">
|
||||||
|
<button
|
||||||
|
class="hover:bg-accent hover:text-accent-foreground mr-1 inline-flex size-8 items-center justify-center rounded-md transition-colors"
|
||||||
|
type="button"
|
||||||
|
@click="handleOpenImHome"
|
||||||
|
>
|
||||||
|
<IconifyIcon class="size-4" icon="lucide:message-circle" />
|
||||||
|
</button>
|
||||||
|
</ElTooltip>
|
||||||
|
</template>
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<AuthenticationLoginExpiredModal
|
<AuthenticationLoginExpiredModal
|
||||||
v-model:open="accessStore.loginExpired"
|
v-model:open="accessStore.loginExpired"
|
||||||
|
|
|
||||||
|
|
@ -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