fix: lint
This commit is contained in:
@@ -1,31 +1,29 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import type { CardMessage, CardTarget } from '#/views/im/utils/message';
|
||||
|
||||
import { isPrivateConversation } from '#/views/im/utils/constants'
|
||||
import {
|
||||
type CardMessage,
|
||||
type CardTarget,
|
||||
getCardLabelInfo
|
||||
} from '#/views/im/utils/message'
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { UserAvatar } from '../user'
|
||||
import { isPrivateConversation } from '#/views/im/utils/constants';
|
||||
import { getCardLabelInfo } from '#/views/im/utils/message';
|
||||
|
||||
defineOptions({ name: 'ImCardBubble' })
|
||||
import { UserAvatar } from '../user';
|
||||
|
||||
defineOptions({ name: 'ImCardBubble' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 名片数据;CardMessage(接收侧消息体)或 CardTarget(发送侧预览)共用结构 */
|
||||
card: CardMessage | CardTarget
|
||||
card: CardMessage | CardTarget;
|
||||
/** 是否显示 cursor: pointer;调用方负责绑 @click 监听 */
|
||||
clickable?: boolean
|
||||
clickable?: boolean;
|
||||
}>(),
|
||||
{ clickable: false }
|
||||
)
|
||||
{ clickable: false },
|
||||
);
|
||||
|
||||
/** 是否用户名片:决定 UserAvatar 是否带 id 触发 UserInfoCard */
|
||||
const isUser = computed(() => isPrivateConversation(props.card.targetType))
|
||||
const isUser = computed(() => isPrivateConversation(props.card.targetType));
|
||||
/** 名片标签信息 */
|
||||
const labelInfo = computed(() => getCardLabelInfo(props.card))
|
||||
const labelInfo = computed(() => getCardLabelInfo(props.card));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { IconifyIcon as Icon } from '@vben/icons'
|
||||
import { IconifyIcon as Icon } from '@vben/icons';
|
||||
|
||||
import { getCardLabelInfo } from '#/views/im/utils/message'
|
||||
import { getCardLabelInfo } from '#/views/im/utils/message';
|
||||
|
||||
defineOptions({ name: 'ImCardLineLabel' })
|
||||
defineOptions({ name: 'ImCardLineLabel' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 名片数据;只读 targetType / name 派生标签 + 显示,结构性类型兼容 CardMessage / 引用预览的 partial */
|
||||
card: null | undefined | { name?: string; targetType?: number; }
|
||||
iconSize?: number
|
||||
card: null | undefined | { name?: string; targetType?: number };
|
||||
iconSize?: number;
|
||||
}>(),
|
||||
{ iconSize: 14 }
|
||||
)
|
||||
{ iconSize: 14 },
|
||||
);
|
||||
|
||||
/** 标签 + 图标按 targetType 二分;兜底「个人名片」避免 null 时 UI 空白 */
|
||||
const labelInfo = computed(() => getCardLabelInfo(props.card))
|
||||
const labelInfo = computed(() => getCardLabelInfo(props.card));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { IconifyIcon as Icon } from '@vben/icons'
|
||||
import { IconifyIcon as Icon } from '@vben/icons';
|
||||
|
||||
import { useImUiStore } from '../store/uiStore'
|
||||
import { useImUiStore } from '../store/uiStore';
|
||||
|
||||
defineOptions({ name: 'ImContextMenu' })
|
||||
defineOptions({ name: 'ImContextMenu' });
|
||||
|
||||
const uiStore = useImUiStore()
|
||||
const contextMenu = computed(() => uiStore.contextMenu)
|
||||
const uiStore = useImUiStore();
|
||||
const contextMenu = computed(() => uiStore.contextMenu);
|
||||
|
||||
/**
|
||||
* 计算菜单实际渲染坐标:靠近视口右 / 下边缘时回弹,避免菜单被裁剪
|
||||
@@ -18,40 +18,40 @@ const contextMenu = computed(() => uiStore.contextMenu)
|
||||
* 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
|
||||
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
|
||||
y = window.innerHeight - menuHeight;
|
||||
}
|
||||
if (x + menuWidth > window.innerWidth) {
|
||||
x = window.innerWidth - menuWidth
|
||||
x = window.innerWidth - menuWidth;
|
||||
}
|
||||
}
|
||||
// 视口很小 / 菜单项很多时上面减法会算出负值,把菜单顶 / 左边推到 0 兜底
|
||||
return { x: Math.max(0, x), y: Math.max(0, y) }
|
||||
})
|
||||
return { x: Math.max(0, x), y: Math.max(0, y) };
|
||||
});
|
||||
|
||||
type MenuItem = (typeof contextMenu.value.items)[number]
|
||||
type MenuItem = (typeof contextMenu.value.items)[number];
|
||||
|
||||
/** 选中菜单项:disabled 项忽略;正常项调 onSelect 回调后关闭菜单 */
|
||||
function handleSelect(item: MenuItem) {
|
||||
if (item.disabled) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
uiStore.contextMenu.onSelect?.(item)
|
||||
uiStore.closeContextMenu()
|
||||
uiStore.contextMenu.onSelect?.(item);
|
||||
uiStore.closeContextMenu();
|
||||
}
|
||||
|
||||
/** 关闭菜单:点遮罩 / 在遮罩上再次右键都会触发 */
|
||||
function handleClose() {
|
||||
uiStore.closeContextMenu()
|
||||
uiStore.closeContextMenu();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -70,7 +70,10 @@ function 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` }"
|
||||
:style="{
|
||||
left: `${adjustedPosition.x}px`,
|
||||
top: `${adjustedPosition.y}px`,
|
||||
}"
|
||||
>
|
||||
<template v-for="(item, index) in contextMenu.items" :key="item.key">
|
||||
<!-- divided 项上方插一条分割线(首项跳过,避免空白) -->
|
||||
@@ -85,7 +88,7 @@ function handleClose() {
|
||||
? '!text-[var(--ant-color-text-disabled)] cursor-not-allowed hover:!bg-transparent'
|
||||
: item.danger
|
||||
? 'text-[#f56c6c]'
|
||||
: 'text-[var(--ant-color-text)]'
|
||||
: 'text-[var(--ant-color-text)]',
|
||||
]"
|
||||
@click.stop="handleSelect(item)"
|
||||
>
|
||||
|
||||
@@ -1,154 +1,164 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SystemUserApi } from '#/api/system/user'
|
||||
import type { SystemUserApi } from '#/api/system/user';
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { IconifyIcon as Icon } from '@vben/icons'
|
||||
import { useUserStore } from '@vben/stores'
|
||||
import { IconifyIcon as Icon } from '@vben/icons';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import { Button, Input, message, Modal, Spin } from 'ant-design-vue'
|
||||
import { Button, Input, message, Modal, Spin } from 'ant-design-vue';
|
||||
|
||||
import { getSimpleUserListByNickname } from '#/api/system/user'
|
||||
import { getCurrentUserId } from '#/views/im/utils/auth'
|
||||
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'
|
||||
import { ImFriendAddSource } from '../../../utils/constants';
|
||||
import { getGenderColor, getGenderIcon } from '../../../utils/user';
|
||||
import { useFriendStore } from '../../store/friendStore';
|
||||
import { UserAvatar } from '../user';
|
||||
|
||||
defineOptions({ name: 'ImFriendAddDialog' })
|
||||
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」
|
||||
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
|
||||
}
|
||||
})
|
||||
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()
|
||||
const friendStore = useFriendStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
/** 当前登录用户编号;用 computed 包一层,切账号后随 wsCache 重取,避免顶层求值在 keep-alive 实例里持有旧 id */
|
||||
const currentUserId = computed(() => getCurrentUserId())
|
||||
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) // 提交中
|
||||
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' ? '申请添加朋友' : '添加好友'))
|
||||
const dialogTitle = computed(() =>
|
||||
step.value === 'apply' ? '申请添加朋友' : '添加好友',
|
||||
);
|
||||
|
||||
/** 是否预填模式(presetUser 不为空 → 跳过搜索,关闭即销毁,无「取消返回搜索」按钮) */
|
||||
const presetMode = computed(() => !!presetUser.value)
|
||||
const presetMode = computed(() => !!presetUser.value);
|
||||
|
||||
function resetAll() {
|
||||
keyword.value = ''
|
||||
users.value = []
|
||||
searched.value = false
|
||||
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
|
||||
targetUser.value = presetUser.value;
|
||||
applyContent.value = buildPresetApplyContent();
|
||||
displayName.value = '';
|
||||
step.value = 'apply';
|
||||
return;
|
||||
}
|
||||
// 非预填模式:默认进搜索步骤
|
||||
step.value = 'search'
|
||||
targetUser.value = null
|
||||
applyContent.value = ''
|
||||
displayName.value = ''
|
||||
step.value = 'search';
|
||||
targetUser.value = null;
|
||||
applyContent.value = '';
|
||||
displayName.value = '';
|
||||
}
|
||||
|
||||
/** 预填模式下的申请理由话术:群聊「我是"XX 群"的 YY」;其它「我是 YY」 */
|
||||
function buildPresetApplyContent(): string {
|
||||
const myNickname = userStore.userInfo?.nickname || ''
|
||||
const myNickname = userStore.userInfo?.nickname || '';
|
||||
if (!myNickname) {
|
||||
return ''
|
||||
return '';
|
||||
}
|
||||
// 群聊场景拼带群名的话术;其它场景默认「我是 YY」
|
||||
const groupExtra = addSource.value === ImFriendAddSource.GROUP ? addSourceExtra.value : ''
|
||||
return groupExtra ? `我是"${groupExtra}"的${myNickname}` : `我是${myNickname}`
|
||||
const groupExtra =
|
||||
addSource.value === ImFriendAddSource.GROUP ? addSourceExtra.value : '';
|
||||
return groupExtra
|
||||
? `我是"${groupExtra}"的${myNickname}`
|
||||
: `我是${myNickname}`;
|
||||
}
|
||||
|
||||
/** 按昵称搜索用户:空关键字直接清空结果 */
|
||||
async function handleSearch() {
|
||||
searched.value = true
|
||||
searched.value = true;
|
||||
if (!keyword.value.trim()) {
|
||||
users.value = []
|
||||
return
|
||||
users.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true
|
||||
loading.value = true;
|
||||
try {
|
||||
users.value = (await getSimpleUserListByNickname(keyword.value.trim())) || []
|
||||
users.value =
|
||||
(await getSimpleUserListByNickname(keyword.value.trim())) || [];
|
||||
} finally {
|
||||
loading.value = false
|
||||
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'
|
||||
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
|
||||
step.value = 'search';
|
||||
targetUser.value = null;
|
||||
}
|
||||
|
||||
/** 提交好友申请:返回 requestId 走「等待验证」;返回 null 表示后端命中「单向好友静默重启」分支,已直接成为好友 */
|
||||
async function handleSubmitApply() {
|
||||
if (!targetUser.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
// 预校验:不能加自己(搜索列表已过滤,这里兜底 presetUser / 名片入口等场景)
|
||||
if (targetUser.value.id === currentUserId.value) {
|
||||
message.warning('不能添加自己为好友')
|
||||
return
|
||||
message.warning('不能添加自己为好友');
|
||||
return;
|
||||
}
|
||||
submitting.value = true
|
||||
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
|
||||
})
|
||||
addSource: addSource.value,
|
||||
});
|
||||
// silent 分支(已是单向好友被静默重启):主动 fetchFriendInfo 入库,不依赖 WS FRIEND_ADD 推送,避免丢推时列表看不到
|
||||
if (requestId === null) {
|
||||
await friendStore.fetchFriendInfo(targetUser.value.id)
|
||||
await friendStore.fetchFriendInfo(targetUser.value.id);
|
||||
}
|
||||
message.success(requestId ? '申请已发送,等待对方验证' : '已添加为好友')
|
||||
visible.value = false
|
||||
message.success(requestId ? '申请已发送,等待对方验证' : '已添加为好友');
|
||||
visible.value = false;
|
||||
} catch {
|
||||
// 业务错误(已是好友 / 被对方拉黑 / 用户被禁用 等):全局拦截器已弹错误提示,本地关弹窗避免脏状态停留
|
||||
visible.value = false
|
||||
visible.value = false;
|
||||
} finally {
|
||||
submitting.value = false
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -175,7 +185,11 @@ async function handleSubmitApply() {
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<template #suffix>
|
||||
<Icon icon="ant-design:search-outlined" class="cursor-pointer" @click="handleSearch" />
|
||||
<Icon
|
||||
icon="ant-design:search-outlined"
|
||||
class="cursor-pointer"
|
||||
@click="handleSearch"
|
||||
/>
|
||||
</template>
|
||||
</Input>
|
||||
|
||||
@@ -250,7 +264,9 @@ async function handleSubmitApply() {
|
||||
:clickable="false"
|
||||
/>
|
||||
<div class="flex-1 min-w-0 overflow-hidden">
|
||||
<div class="text-sm font-semibold text-[var(--ant-color-text)] truncate">
|
||||
<div
|
||||
class="text-sm font-semibold text-[var(--ant-color-text)] truncate"
|
||||
>
|
||||
{{ targetUser.nickname }}
|
||||
</div>
|
||||
<div
|
||||
@@ -262,7 +278,9 @@ async function handleSubmitApply() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-13px text-[var(--ant-color-text-secondary)] mb-1.5">发送添加朋友申请</div>
|
||||
<div class="text-13px text-[var(--ant-color-text-secondary)] mb-1.5">
|
||||
发送添加朋友申请
|
||||
</div>
|
||||
<Input.TextArea
|
||||
v-model:value="applyContent"
|
||||
:rows="3"
|
||||
@@ -271,7 +289,9 @@ async function handleSubmitApply() {
|
||||
placeholder="请填写申请理由"
|
||||
/>
|
||||
|
||||
<div class="text-13px text-[var(--ant-color-text-secondary)] mt-3 mb-1.5">备注</div>
|
||||
<div class="text-13px text-[var(--ant-color-text-secondary)] mt-3 mb-1.5">
|
||||
备注
|
||||
</div>
|
||||
<Input
|
||||
v-model:value="displayName"
|
||||
:maxlength="16"
|
||||
@@ -282,8 +302,12 @@ async function handleSubmitApply() {
|
||||
<!-- 仅在 apply 步骤显示 footer 操作按钮(slot 必须是 el-dialog 直接子节点) -->
|
||||
<template v-if="step === 'apply'" #footer>
|
||||
<!-- 预填模式无搜索步骤,「取消」直接关闭弹窗 -->
|
||||
<Button @click="presetMode ? (visible = false) : backToSearch()">取消</Button>
|
||||
<Button type="primary" :loading="submitting" @click="handleSubmitApply"> 确定 </Button>
|
||||
<Button @click="presetMode ? (visible = false) : backToSearch()">
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" :loading="submitting" @click="handleSubmitApply">
|
||||
确定
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -1,47 +1,47 @@
|
||||
<script lang="ts" setup>
|
||||
import type { FriendLite } from '../../types'
|
||||
import type { FriendLite } from '../../types';
|
||||
|
||||
import { useImUiStore } from '../../store/uiStore'
|
||||
import { UserAvatar } from '../user'
|
||||
import { useImUiStore } from '../../store/uiStore';
|
||||
import { UserAvatar } from '../user';
|
||||
|
||||
defineOptions({ name: 'ImFriendItem' })
|
||||
defineOptions({ name: 'ImFriendItem' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
active?: boolean
|
||||
friend: FriendLite
|
||||
menu?: boolean // 是否启用右键菜单;在选择器弹窗里一般关闭
|
||||
active?: boolean;
|
||||
friend: FriendLite;
|
||||
menu?: boolean; // 是否启用右键菜单;在选择器弹窗里一般关闭
|
||||
}>(),
|
||||
{
|
||||
active: false,
|
||||
menu: true
|
||||
}
|
||||
)
|
||||
menu: true,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
chat: [friend: FriendLite]
|
||||
click: [friend: FriendLite]
|
||||
delete: [friend: FriendLite]
|
||||
}>()
|
||||
chat: [friend: FriendLite];
|
||||
click: [friend: FriendLite];
|
||||
delete: [friend: FriendLite];
|
||||
}>();
|
||||
|
||||
const uiStore = useImUiStore()
|
||||
const uiStore = useImUiStore();
|
||||
|
||||
/** 右键菜单:发送消息 / 删除好友 */
|
||||
function handleContextMenu(event: MouseEvent) {
|
||||
if (!props.menu) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
uiStore.openContextMenu(
|
||||
{ x: event.clientX, y: event.clientY },
|
||||
[
|
||||
{ key: 'chat', name: '发送消息' },
|
||||
{ key: 'delete', name: '删除好友' }
|
||||
{ key: 'delete', name: '删除好友' },
|
||||
],
|
||||
(item) => {
|
||||
if (item.key === 'chat') emit('chat', props.friend)
|
||||
else if (item.key === 'delete') emit('delete', props.friend)
|
||||
}
|
||||
)
|
||||
if (item.key === 'chat') emit('chat', props.friend);
|
||||
else if (item.key === 'delete') emit('delete', props.friend);
|
||||
},
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -54,7 +54,9 @@ function handleContextMenu(event: MouseEvent) {
|
||||
-->
|
||||
<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 }"
|
||||
:class="{
|
||||
'!bg-[#d9ecff] dark:!bg-[var(--ant-color-primary-bg-hover)]': active,
|
||||
}"
|
||||
@click="$emit('click', friend)"
|
||||
@contextmenu.prevent="handleContextMenu"
|
||||
>
|
||||
@@ -70,7 +72,9 @@ function handleContextMenu(event: MouseEvent) {
|
||||
/>
|
||||
<!-- 单行展示 displayName 优先;昵称仅在好友详情面板展示,列表里不重复 -->
|
||||
<div class="flex flex-1 min-w-0">
|
||||
<div class="overflow-hidden text-sm truncate text-[var(--ant-color-text)]">
|
||||
<div
|
||||
class="overflow-hidden text-sm truncate text-[var(--ant-color-text)]"
|
||||
>
|
||||
{{ friend.displayName || friend.nickname }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,82 +1,84 @@
|
||||
<script lang="ts" setup>
|
||||
import type { GroupMemberLite } from './group-member.vue'
|
||||
import type { GroupMemberLite } from './group-member.vue';
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Button, message, Modal } from 'ant-design-vue'
|
||||
import { Button, message, Modal } from 'ant-design-vue';
|
||||
|
||||
import { addGroupAdmin, removeGroupAdmin } from '#/api/im/group'
|
||||
import { GROUP_ADMIN_MAX_COUNT } from '#/views/im/utils/config'
|
||||
import { addGroupAdmin, removeGroupAdmin } from '#/api/im/group';
|
||||
import { GROUP_ADMIN_MAX_COUNT } from '#/views/im/utils/config';
|
||||
|
||||
import { GroupMemberPickerPanel } from '../picker'
|
||||
import { GroupMemberPickerPanel } from '../picker';
|
||||
|
||||
defineOptions({ name: 'ImGroupAdminSetDialog' })
|
||||
defineOptions({ name: 'ImGroupAdminSetDialog' });
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 管理员变更成功;父侧通常用来 reload 群数据 */
|
||||
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[]>([])
|
||||
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
|
||||
currentAdminIds: number[];
|
||||
groupId: number;
|
||||
/** 隐藏 userId(群主) */
|
||||
hideIds?: number[]
|
||||
hideIds?: number[];
|
||||
/** 已选数上限;不传走 GROUP_ADMIN_MAX_COUNT */
|
||||
maxSize?: number
|
||||
members: GroupMemberLite[]
|
||||
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
|
||||
}
|
||||
})
|
||||
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
|
||||
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))
|
||||
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
|
||||
visible.value = false;
|
||||
return;
|
||||
}
|
||||
submitting.value = true
|
||||
submitting.value = true;
|
||||
try {
|
||||
if (addedIds.length > 0) {
|
||||
await addGroupAdmin({ id: groupId.value, userIds: addedIds })
|
||||
await addGroupAdmin({ id: groupId.value, userIds: addedIds });
|
||||
}
|
||||
if (removedIds.length > 0) {
|
||||
await removeGroupAdmin({ id: groupId.value, userIds: removedIds })
|
||||
await removeGroupAdmin({ id: groupId.value, userIds: removedIds });
|
||||
}
|
||||
message.success(`已更新群管理员(新增 ${addedIds.length} 位,撤销 ${removedIds.length} 位)`)
|
||||
emit('reload')
|
||||
visible.value = false
|
||||
message.success(
|
||||
`已更新群管理员(新增 ${addedIds.length} 位,撤销 ${removedIds.length} 位)`,
|
||||
);
|
||||
emit('reload');
|
||||
visible.value = false;
|
||||
} finally {
|
||||
submitting.value = false
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -107,7 +109,9 @@ async function handleOk() {
|
||||
|
||||
<template #footer>
|
||||
<Button @click="visible = false">取消</Button>
|
||||
<Button type="primary" :loading="submitting" @click="handleOk">确定</Button>
|
||||
<Button type="primary" :loading="submitting" @click="handleOk">
|
||||
确定
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
<script lang="ts" setup>
|
||||
import type { GroupMember } from '../../types'
|
||||
import type { GroupMember } from '../../types';
|
||||
|
||||
import { computed, ref, watch } from 'vue'
|
||||
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'
|
||||
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' })
|
||||
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?: boolean; // 是否可点击(默认 false,列表里仅展示)
|
||||
groupId: number; // 群编号;用于查 store 拿成员头像
|
||||
name?: string; // 群名;色卡兜底文字
|
||||
previewable?: boolean; // 是否点头像直接放大预览(群详情大头像位用)
|
||||
previewZIndex?: number; // 预览层 z-index
|
||||
radius?: string; // 圆角,CSS 长度
|
||||
size?: number; // 尺寸(px),正方形
|
||||
url?: string; // 服务端已设置的群头像 URL;非空则直接用,不拼图
|
||||
}>(),
|
||||
{
|
||||
clickable: false,
|
||||
@@ -33,88 +33,88 @@ const props = withDefaults(
|
||||
previewZIndex: 2000,
|
||||
radius: '15%',
|
||||
size: 42,
|
||||
url: ''
|
||||
}
|
||||
)
|
||||
url: '',
|
||||
},
|
||||
);
|
||||
|
||||
const friendStore = useFriendStore()
|
||||
const groupStore = useGroupStore()
|
||||
const mergedUrl = ref('')
|
||||
const friendStore = useFriendStore();
|
||||
const groupStore = useGroupStore();
|
||||
const mergedUrl = ref('');
|
||||
// 竞态保护:丢弃过期 await 结果
|
||||
let mergeToken = 0
|
||||
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)
|
||||
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)
|
||||
const g = groupStore.getGroup(props.groupId);
|
||||
if (!g?.membersLoaded || !g.members) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
return g.members
|
||||
})
|
||||
return g.members;
|
||||
});
|
||||
|
||||
/** 前 9 个成员的拼图入参;name 走 getMemberDisplayName 口径(好友备注 > 群昵称 > 真实昵称) */
|
||||
const memberItems = computed(() => {
|
||||
const members = loadedMembers.value
|
||||
const members = loadedMembers.value;
|
||||
if (!members) {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
return members.slice(0, 9).map((m) => ({
|
||||
avatar: m.avatar || '',
|
||||
name: getMemberDisplayName(m, friendStore.getFriend(m.userId))
|
||||
}))
|
||||
})
|
||||
name: getMemberDisplayName(m, friendStore.getFriend(m.userId)),
|
||||
}));
|
||||
});
|
||||
|
||||
/** 成员快照签名:拼 (avatar, name) 字段,原地修改任一字段都会让 watch 重算 */
|
||||
const memberSignature = computed(() =>
|
||||
memberItems.value.map((it) => `${it.avatar}#${it.name}`).join('|')
|
||||
)
|
||||
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)
|
||||
const myToken = ++mergeToken;
|
||||
const cached = getCachedGroupAvatar(key);
|
||||
if (cached) {
|
||||
mergedUrl.value = cached
|
||||
return
|
||||
mergedUrl.value = cached;
|
||||
return;
|
||||
}
|
||||
const dataUrl = await buildGroupAvatar(memberItems.value, { targetSize })
|
||||
const dataUrl = await buildGroupAvatar(memberItems.value, { targetSize });
|
||||
if (myToken !== mergeToken) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (dataUrl) {
|
||||
setCachedGroupAvatar(key, dataUrl)
|
||||
setCachedGroupAvatar(key, dataUrl);
|
||||
}
|
||||
mergedUrl.value = dataUrl
|
||||
mergedUrl.value = dataUrl;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.url, props.groupId, props.size, memberSignature.value] as const,
|
||||
([url, groupId, size, signature]) => {
|
||||
if (url) {
|
||||
mergedUrl.value = ''
|
||||
return
|
||||
mergedUrl.value = '';
|
||||
return;
|
||||
}
|
||||
if (!signature) {
|
||||
mergeToken++
|
||||
mergedUrl.value = ''
|
||||
groupStore.loadGroupMemberList(groupId)
|
||||
return
|
||||
mergeToken++;
|
||||
mergedUrl.value = '';
|
||||
groupStore.loadGroupMemberList(groupId);
|
||||
return;
|
||||
}
|
||||
const targetSize = getTargetSize(size)
|
||||
const key = `${groupId}:${targetSize}:${signature}`
|
||||
applyMerge(key, targetSize)
|
||||
const targetSize = getTargetSize(size);
|
||||
const key = `${groupId}:${targetSize}:${signature}`;
|
||||
applyMerge(key, targetSize);
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
/** 最终展示 url:服务端 url 优先 → 拼图 → 空字符串(让 UserAvatar 走色卡) */
|
||||
const finalUrl = computed(() => props.url || mergedUrl.value)
|
||||
const finalUrl = computed(() => props.url || mergedUrl.value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,89 +1,95 @@
|
||||
<script lang="ts" setup>
|
||||
import type { FriendLite } from '../../types'
|
||||
import type { FriendLite } from '../../types';
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Button, message, Modal } from 'ant-design-vue'
|
||||
import { Button, message, Modal } from 'ant-design-vue';
|
||||
|
||||
import { createGroup } from '#/api/im/group'
|
||||
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'
|
||||
import { buildDefaultGroupName } from '../../../utils/group';
|
||||
import { useFriendStore } from '../../store/friendStore';
|
||||
import { useGroupStore } from '../../store/groupStore';
|
||||
import { FriendPickerPanel } from '../picker';
|
||||
|
||||
defineOptions({ name: 'ImGroupCreateDialog' })
|
||||
defineOptions({ name: 'ImGroupCreateDialog' });
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 创建成功,携带新群编号;父侧通常用来跳转到新群会话 */
|
||||
created: [groupId: number]
|
||||
}>()
|
||||
created: [groupId: number];
|
||||
}>();
|
||||
|
||||
const friendStore = useFriendStore()
|
||||
const groupStore = useGroupStore()
|
||||
const friendStore = useFriendStore();
|
||||
const groupStore = useGroupStore();
|
||||
|
||||
const visible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const lockedIds = ref<number[]>([])
|
||||
const selectedIds = ref<number[]>([])
|
||||
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
|
||||
}
|
||||
})
|
||||
lockedIds.value = opts?.lockedIds ? [...opts.lockedIds] : [];
|
||||
selectedIds.value = [];
|
||||
submitting.value = false;
|
||||
visible.value = true;
|
||||
},
|
||||
});
|
||||
|
||||
/** 全量好友:直接复用 friendStore Lite 视图(带拼音字段供分桶用) */
|
||||
const friends = computed<FriendLite[]>(() => friendStore.getActiveFriendLiteList)
|
||||
const friends = computed<FriendLite[]>(
|
||||
() => friendStore.getActiveFriendLiteList,
|
||||
);
|
||||
|
||||
/** 完成按钮可点:至少有 1 个非 locked 勾选(locked 是入口锁定项,不算"用户主动选择") */
|
||||
const canSubmit = computed(() => selectedIds.value.length > 0)
|
||||
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]))
|
||||
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
|
||||
continue;
|
||||
}
|
||||
const friend = byId.get(id)
|
||||
const friend = byId.get(id);
|
||||
if (friend) {
|
||||
seen.add(id)
|
||||
result.push(friend)
|
||||
seen.add(id);
|
||||
result.push(friend);
|
||||
}
|
||||
}
|
||||
for (const id of selectedIds.value) {
|
||||
if (seen.has(id)) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
const friend = byId.get(id)
|
||||
const friend = byId.get(id);
|
||||
if (friend) {
|
||||
seen.add(id)
|
||||
result.push(friend)
|
||||
seen.add(id);
|
||||
result.push(friend);
|
||||
}
|
||||
}
|
||||
return result
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 创建群聊:建群(同时邀请初始成员)→ upsert groupStore → emit created 让父页跳转新会话 */
|
||||
async function handleOk() {
|
||||
const members = resolveMembersToInvite()
|
||||
const members = resolveMembersToInvite();
|
||||
if (members.length === 0) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
submitting.value = true
|
||||
submitting.value = true;
|
||||
try {
|
||||
const memberUserIds = members.map((m) => m.id)
|
||||
const name = buildDefaultGroupName(members)
|
||||
const group = await createGroup({ name, memberUserIds, joinApproval: false })
|
||||
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('创建群失败:未返回群编号')
|
||||
throw new Error('创建群失败:未返回群编号');
|
||||
}
|
||||
// 直接 upsert 进 groupStore,省一次 fetchGroupList —— 服务端返回 VO 已经够建会话了
|
||||
groupStore.upsertGroup({
|
||||
@@ -91,13 +97,13 @@ async function handleOk() {
|
||||
name: group.name,
|
||||
avatar: group.avatar,
|
||||
notice: group.notice,
|
||||
ownerUserId: group.ownerUserId
|
||||
})
|
||||
message.success('群聊创建成功')
|
||||
emit('created', group.id)
|
||||
visible.value = false
|
||||
ownerUserId: group.ownerUserId,
|
||||
});
|
||||
message.success('群聊创建成功');
|
||||
emit('created', group.id);
|
||||
visible.value = false;
|
||||
} finally {
|
||||
submitting.value = false
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -127,7 +133,12 @@ async function handleOk() {
|
||||
|
||||
<template #footer>
|
||||
<Button @click="visible = false">取消</Button>
|
||||
<Button type="primary" :loading="submitting" :disabled="!canSubmit" @click="handleOk">
|
||||
<Button
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
:disabled="!canSubmit"
|
||||
@click="handleOk"
|
||||
>
|
||||
完成
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@@ -1,97 +1,97 @@
|
||||
<script lang="ts" setup>
|
||||
import type { GroupLite } from '../../types'
|
||||
import type { GroupLite } from '../../types';
|
||||
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, onMounted, onUnmounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { prompt } from '@vben/common-ui'
|
||||
import { prompt } from '@vben/common-ui';
|
||||
|
||||
import { Input, message } from 'ant-design-vue'
|
||||
import { Input, message } from 'ant-design-vue';
|
||||
|
||||
import { applyJoinGroup } from '#/api/im/group/request'
|
||||
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'
|
||||
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' })
|
||||
defineOptions({ name: 'ImGroupInfoCard' });
|
||||
|
||||
const uiStore = useImUiStore()
|
||||
const conversationStore = useConversationStore()
|
||||
const groupStore = useGroupStore()
|
||||
const router = useRouter()
|
||||
const uiStore = useImUiStore();
|
||||
const conversationStore = useConversationStore();
|
||||
const groupStore = useGroupStore();
|
||||
const router = useRouter();
|
||||
|
||||
const card = computed(() => uiStore.groupInfoCard)
|
||||
const card = computed(() => uiStore.groupInfoCard);
|
||||
|
||||
/** 关闭浮层 */
|
||||
function handleClose() {
|
||||
uiStore.closeGroupInfoCard()
|
||||
uiStore.closeGroupInfoCard();
|
||||
}
|
||||
|
||||
/** Esc 关闭 */
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && card.value.show) {
|
||||
handleClose()
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', handleKeydown))
|
||||
onUnmounted(() => window.removeEventListener('keydown', handleKeydown))
|
||||
onMounted(() => window.addEventListener('keydown', handleKeydown));
|
||||
onUnmounted(() => window.removeEventListener('keydown', handleKeydown));
|
||||
|
||||
/** 进入群聊:取本地最新群信息(含 silent / 群备注),新建或激活会话 + 跳路由 */
|
||||
function handleChat(group: GroupLite) {
|
||||
const cached = groupStore.getGroup(group.id)
|
||||
const cached = groupStore.getGroup(group.id);
|
||||
// cached 命中走 getGroupDisplayName 让群备注优先(与 contact / 会话列表的展示名一致);缺 cached 时回落 showGroupName / 原群名
|
||||
const displayName = cached
|
||||
? getGroupDisplayName(cached)
|
||||
: group.showGroupName || group.name || ''
|
||||
: group.showGroupName || group.name || '';
|
||||
// 打开或新建会话
|
||||
conversationStore.openConversation(
|
||||
group.id,
|
||||
ImConversationType.GROUP,
|
||||
displayName,
|
||||
cached?.avatar || group.showImage || '',
|
||||
{ silent: !!cached?.silent }
|
||||
)
|
||||
{ silent: !!cached?.silent },
|
||||
);
|
||||
|
||||
// 如果不在会话页,先跳过去(如果在了,MessagePanel 会自己感知会话变化刷新)
|
||||
if (router.currentRoute.value.name !== 'ImHomeConversation') {
|
||||
router.push({ name: 'ImHomeConversation' })
|
||||
router.push({ name: 'ImHomeConversation' });
|
||||
}
|
||||
handleClose()
|
||||
handleClose();
|
||||
}
|
||||
|
||||
/** 加入群聊:先关浮层(避免与 prompt 的 mask 互相遮挡)→ 弹申请理由(可选)→ applyJoinGroup */
|
||||
async function handleApply(group: GroupLite) {
|
||||
handleClose()
|
||||
let applyContent: string
|
||||
handleClose();
|
||||
let applyContent: string;
|
||||
try {
|
||||
const result = await prompt<string>({
|
||||
cancelText: '取消',
|
||||
component: Input,
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
placeholder: '请填写验证消息(可选)'
|
||||
placeholder: '请填写验证消息(可选)',
|
||||
},
|
||||
content: '',
|
||||
defaultValue: '',
|
||||
confirmText: '发送申请',
|
||||
modelPropName: 'value',
|
||||
title: `申请加入「${group.name || ''}」`
|
||||
})
|
||||
applyContent = (result || '').trim()
|
||||
title: `申请加入「${group.name || ''}」`,
|
||||
});
|
||||
applyContent = (result || '').trim();
|
||||
} catch {
|
||||
return
|
||||
return;
|
||||
}
|
||||
await applyJoinGroup({
|
||||
groupId: group.id,
|
||||
applyContent: applyContent || undefined,
|
||||
addSource: ImGroupAddSource.SHARE_LINK
|
||||
})
|
||||
message.success('加群申请已发送')
|
||||
addSource: ImGroupAddSource.SHARE_LINK,
|
||||
});
|
||||
message.success('加群申请已发送');
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -103,13 +103,22 @@ async function handleApply(group: GroupLite) {
|
||||
- GroupInfo 内部按 groupStore 缓存推导 member / stranger,浮层只负责接 chat / apply 事件做业务
|
||||
-->
|
||||
<teleport to="body">
|
||||
<div v-if="card.show" class="fixed inset-0 z-9998" @click.self="handleClose">
|
||||
<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` }"
|
||||
: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" />
|
||||
<GroupInfo
|
||||
v-if="card.group"
|
||||
:group="card.group"
|
||||
@chat="handleChat"
|
||||
@apply="handleApply"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Friend, GroupLite, GroupMember } from '../../types'
|
||||
import type { GroupMemberLite } from './group-member.vue'
|
||||
import type { Friend, GroupLite, GroupMember } from '../../types';
|
||||
import type { GroupMemberLite } from './group-member.vue';
|
||||
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { CommonStatusEnum } from '@vben/constants'
|
||||
import { CommonStatusEnum } from '@vben/constants';
|
||||
|
||||
import { Button } from 'ant-design-vue'
|
||||
import { Button } from 'ant-design-vue';
|
||||
|
||||
import { getCurrentUserId } from '#/views/im/utils/auth'
|
||||
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'
|
||||
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' })
|
||||
defineOptions({ name: 'ImGroupInfo' });
|
||||
|
||||
const props = defineProps<{
|
||||
group: GroupLite
|
||||
}>()
|
||||
group: GroupLite;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** stranger 点「加入群聊」;父级负责弹申请理由 + 调 applyJoinGroup */
|
||||
apply: [group: GroupLite]
|
||||
apply: [group: GroupLite];
|
||||
/** member 点「进入群聊」;父级负责切会话 + 关浮层 */
|
||||
chat: [group: GroupLite]
|
||||
}>()
|
||||
chat: [group: GroupLite];
|
||||
}>();
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
const friendStore = useFriendStore()
|
||||
const groupStore = useGroupStore();
|
||||
const friendStore = useFriendStore();
|
||||
|
||||
const members = ref<GroupMemberLite[]>([])
|
||||
const members = ref<GroupMemberLite[]>([]);
|
||||
|
||||
/**
|
||||
* 是否已加群:基于"自己确实在成员列表里"判断
|
||||
@@ -42,65 +42,74 @@ const members = ref<GroupMemberLite[]>([])
|
||||
*/
|
||||
const isMember = computed(() => {
|
||||
if (!props.group?.id) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
const cached = groupStore.getGroup(props.group.id)
|
||||
const cached = groupStore.getGroup(props.group.id);
|
||||
if (!cached) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
// 历史退群群:直接判 false,避免成员未加载时误显示「进入群聊」
|
||||
if (isGroupQuit(cached)) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
if (cached.membersLoaded && cached.members) {
|
||||
const myId = getCurrentUserId()
|
||||
return cached.members.some((m) => m.userId === myId && m.status === CommonStatusEnum.ENABLE)
|
||||
const myId = getCurrentUserId();
|
||||
return cached.members.some(
|
||||
(m) => m.userId === myId && m.status === CommonStatusEnum.ENABLE,
|
||||
);
|
||||
}
|
||||
return true
|
||||
})
|
||||
return true;
|
||||
});
|
||||
/** 历史退群群:只读,动作区两个按钮都不渲染(既不「进入群聊」也不「加入群聊」) */
|
||||
const isQuitGroup = computed(() => {
|
||||
const id = props.group?.id
|
||||
return id != null && isGroupQuit(groupStore.getGroup(id))
|
||||
})
|
||||
const id = props.group?.id;
|
||||
return id !== null && isGroupQuit(groupStore.getGroup(id));
|
||||
});
|
||||
/** 是否未加群:有 id、非成员、且非历史退群群;只有真·陌生人才给「加入群聊」 */
|
||||
const isStranger = computed(() => !!props.group?.id && !isMember.value && !isQuitGroup.value)
|
||||
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} 位成员` : ''
|
||||
})
|
||||
? props.group.memberCount || members.value.length > 0
|
||||
: props.group.memberCount;
|
||||
return count ? `${count} 位成员` : '';
|
||||
});
|
||||
|
||||
/** member 切群 / 首挂:拉成员;竞态用 group.id 比对丢弃陈旧响应避免上一条群成员错位 */
|
||||
watch(
|
||||
() => [props.group?.id, isMember.value] as const,
|
||||
async ([id, member]) => {
|
||||
members.value = []
|
||||
members.value = [];
|
||||
if (!id || !member) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
const list = await groupStore.fetchGroupMemberList(id, true)
|
||||
const list = await groupStore.fetchGroupMemberList(id, true);
|
||||
if (props.group?.id !== id) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
members.value = list.map((m) => convertGroupMemberLite(m, friendStore.getFriend(m.userId)))
|
||||
members.value = list.map((m) =>
|
||||
convertGroupMemberLite(m, friendStore.getFriend(m.userId)),
|
||||
);
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
/** 群成员 → 列表项 */
|
||||
function convertGroupMemberLite(member: GroupMember, friend: Friend | undefined): GroupMemberLite {
|
||||
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
|
||||
}
|
||||
role: member.role,
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -125,7 +134,10 @@ function convertGroupMemberLite(member: GroupMember, friend: Friend | undefined)
|
||||
>
|
||||
{{ group.showGroupName || group.name }}
|
||||
</div>
|
||||
<div v-if="memberCountText" class="text-13px text-[var(--ant-color-text-secondary)]">
|
||||
<div
|
||||
v-if="memberCountText"
|
||||
class="text-13px text-[var(--ant-color-text-secondary)]"
|
||||
>
|
||||
{{ memberCountText }}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<script lang="ts" setup>
|
||||
import type { GroupLite } from '../../types'
|
||||
import type { GroupLite } from '../../types';
|
||||
|
||||
import GroupAvatar from './group-avatar.vue'
|
||||
import GroupAvatar from './group-avatar.vue';
|
||||
|
||||
defineOptions({ name: 'ImGroupItem' })
|
||||
defineOptions({ name: 'ImGroupItem' });
|
||||
|
||||
defineProps<{
|
||||
active?: boolean
|
||||
group: GroupLite
|
||||
}>()
|
||||
active?: boolean;
|
||||
group: GroupLite;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
click: [group: GroupLite]
|
||||
}>()
|
||||
click: [group: GroupLite];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -23,7 +23,9 @@ defineEmits<{
|
||||
-->
|
||||
<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 }"
|
||||
:class="{
|
||||
'!bg-[#d9ecff] dark:!bg-[var(--ant-color-primary-bg-hover)]': active,
|
||||
}"
|
||||
@click="$emit('click', group)"
|
||||
>
|
||||
<GroupAvatar
|
||||
@@ -34,7 +36,9 @@ defineEmits<{
|
||||
/>
|
||||
<div class="flex flex-1 min-w-0">
|
||||
<!-- 单行展示群名;成员数仅在群详情面板展示,列表里不重复 -->
|
||||
<div class="overflow-hidden text-sm truncate text-[var(--ant-color-text)]">
|
||||
<div
|
||||
class="overflow-hidden text-sm truncate text-[var(--ant-color-text)]"
|
||||
>
|
||||
{{ group.showGroupName || group.name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,127 +1,133 @@
|
||||
<script lang="ts" setup>
|
||||
import type { GroupMemberLite } from './group-member.vue'
|
||||
import type { GroupMemberLite } from './group-member.vue';
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { CommonStatusEnum } from '@vben/constants'
|
||||
import { CommonStatusEnum } from '@vben/constants';
|
||||
|
||||
import { Button, message, Modal } from 'ant-design-vue'
|
||||
import { Button, message, Modal } from 'ant-design-vue';
|
||||
|
||||
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 { 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'
|
||||
import { useFriendStore } from '../../store/friendStore';
|
||||
import { useGroupStore } from '../../store/groupStore';
|
||||
import { FriendPickerPanel } from '../picker';
|
||||
|
||||
defineOptions({ name: 'ImGroupMemberAddDialog' })
|
||||
defineOptions({ name: 'ImGroupMemberAddDialog' });
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 邀请成功,携带被邀请的好友 id 列表;父侧通常用来 reload 群成员 */
|
||||
reload: [friendIds: number[]]
|
||||
}>()
|
||||
reload: [friendIds: number[]];
|
||||
}>();
|
||||
|
||||
const friendStore = useFriendStore()
|
||||
const groupStore = useGroupStore()
|
||||
const friendStore = useFriendStore();
|
||||
const groupStore = useGroupStore();
|
||||
|
||||
const visible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const groupId = ref(0)
|
||||
const selectedIds = ref<number[]>([])
|
||||
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
|
||||
}
|
||||
})
|
||||
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)
|
||||
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
|
||||
}))
|
||||
})
|
||||
role: member.role,
|
||||
}));
|
||||
});
|
||||
|
||||
/** 全量好友:直接复用 friendStore Lite 视图 */
|
||||
const friends = computed(() => friendStore.getActiveFriendLiteList)
|
||||
const friends = computed(() => friendStore.getActiveFriendLiteList);
|
||||
|
||||
/** 已在群里的好友 id:传给 Panel 的 disabledIds 置灰 + 不计入已选 */
|
||||
const disabledIds = computed<number[]>(() =>
|
||||
members.value
|
||||
.filter((member) => member.status !== CommonStatusEnum.DISABLE)
|
||||
.map((member) => member.userId)
|
||||
)
|
||||
.map((member) => member.userId),
|
||||
);
|
||||
|
||||
/** 是否走审批分支:群开启 joinApproval + 当前用户是普通成员(群主 / 管理员邀请直进) */
|
||||
const willGoApproval = computed(() => {
|
||||
const group = groupStore.getGroup(groupId.value)
|
||||
const group = groupStore.getGroup(groupId.value);
|
||||
if (!group?.joinApproval) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
const myId = getCurrentUserId()
|
||||
const myId = getCurrentUserId();
|
||||
if (!myId) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
// 群主直判,避开 members 异步加载的窗口;admin 仍依赖 members
|
||||
if (group.ownerUserId === myId) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
// members 未到位时无法判定 admin,保守按非审批处理,宁可漏报「等待审批」也不误报给真实管理员
|
||||
const myRole = members.value.find((member) => member.userId === myId)?.role
|
||||
if (myRole == null) {
|
||||
return false
|
||||
const myRole = members.value.find((member) => member.userId === myId)?.role;
|
||||
if (myRole === null) {
|
||||
return false;
|
||||
}
|
||||
return myRole !== ImGroupMemberRole.ADMIN
|
||||
})
|
||||
return myRole !== ImGroupMemberRole.ADMIN;
|
||||
});
|
||||
|
||||
/** 当前群已启用成员数(DISABLE 即退群 / 被踢不计入),用于上限判定 */
|
||||
const activeMemberCount = computed(
|
||||
() => members.value.filter((member) => member.status !== CommonStatusEnum.DISABLE).length
|
||||
)
|
||||
() =>
|
||||
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
|
||||
)
|
||||
() => activeMemberCount.value + selectedIds.value.length > GROUP_MAX_MEMBER,
|
||||
);
|
||||
|
||||
/** 添加按钮可点:至少有 1 个新邀请的好友 + 不超群人数上限 */
|
||||
const canSubmit = computed(() => selectedIds.value.length > 0 && !willExceedLimit.value)
|
||||
const canSubmit = computed(
|
||||
() => selectedIds.value.length > 0 && !willExceedLimit.value,
|
||||
);
|
||||
|
||||
/** 邀请入群:调 /im/group/invite,成功后 emit reload 让父侧刷新群成员 */
|
||||
async function handleOk() {
|
||||
if (!groupId.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
const memberUserIds = [...selectedIds.value]
|
||||
const memberUserIds = [...selectedIds.value];
|
||||
if (memberUserIds.length === 0) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
// 群人数上限冗余防御:与 canSubmit 重复判一次,防止状态在 await 间隙变化或调用方绕过按钮直接调
|
||||
if (activeMemberCount.value + memberUserIds.length > GROUP_MAX_MEMBER) {
|
||||
message.warning(`群成员上限为 ${GROUP_MAX_MEMBER} 人`)
|
||||
return
|
||||
message.warning(`群成员上限为 ${GROUP_MAX_MEMBER} 人`);
|
||||
return;
|
||||
}
|
||||
submitting.value = true
|
||||
submitting.value = true;
|
||||
try {
|
||||
await inviteGroupMember({ groupId: groupId.value, memberUserIds })
|
||||
await inviteGroupMember({ groupId: groupId.value, memberUserIds });
|
||||
// 审批分支:后端仅落审批记录,未入群
|
||||
message.success(willGoApproval.value ? '邀请已发起,等待群主 / 管理员审批' : '邀请成功')
|
||||
emit('reload', memberUserIds)
|
||||
visible.value = false
|
||||
message.success(
|
||||
willGoApproval.value ? '邀请已发起,等待群主 / 管理员审批' : '邀请成功',
|
||||
);
|
||||
emit('reload', memberUserIds);
|
||||
visible.value = false;
|
||||
} finally {
|
||||
submitting.value = false
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -150,7 +156,12 @@ async function handleOk() {
|
||||
|
||||
<template #footer>
|
||||
<Button @click="visible = false">取消</Button>
|
||||
<Button type="primary" :loading="submitting" :disabled="!canSubmit" @click="handleOk">
|
||||
<Button
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
:disabled="!canSubmit"
|
||||
@click="handleOk"
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<script lang="ts" setup>
|
||||
import type { GroupMemberLite } from './group-member.vue'
|
||||
import type { GroupMemberLite } from './group-member.vue';
|
||||
|
||||
import { ImFriendAddSource } from '../../../utils/constants'
|
||||
import { UserAvatar } from '../user'
|
||||
import { ImFriendAddSource } from '../../../utils/constants';
|
||||
import { UserAvatar } from '../user';
|
||||
|
||||
defineOptions({ name: 'ImGroupMemberGrid' })
|
||||
defineOptions({ name: 'ImGroupMemberGrid' });
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
clickable?: boolean // 头像点击是否弹 UserInfoCard;选择器宫格里需要保持关闭,避免和勾选交互冲突
|
||||
groupName?: string // 群名:加好友时拼「我是 'XX 群' 的 YY」话术,落库 add_source=GROUP
|
||||
member: GroupMemberLite
|
||||
size?: number // 头像像素大小;默认 38(兼容选择器右侧已选区),群信息抽屉传 50 对齐微信 PC
|
||||
clickable?: boolean; // 头像点击是否弹 UserInfoCard;选择器宫格里需要保持关闭,避免和勾选交互冲突
|
||||
groupName?: string; // 群名:加好友时拼「我是 'XX 群' 的 YY」话术,落库 add_source=GROUP
|
||||
member: GroupMemberLite;
|
||||
size?: number; // 头像像素大小;默认 38(兼容选择器右侧已选区),群信息抽屉传 50 对齐微信 PC
|
||||
}>(),
|
||||
{
|
||||
clickable: false,
|
||||
size: 38,
|
||||
groupName: ''
|
||||
}
|
||||
)
|
||||
groupName: '',
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,49 +1,52 @@
|
||||
<script lang="ts" setup>
|
||||
import type { GroupMemberLite } from './group-member.vue'
|
||||
import type { GroupMemberLite } from './group-member.vue';
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants'
|
||||
import { getDictLabel } from '@vben/hooks'
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictLabel } from '@vben/hooks';
|
||||
|
||||
import { ImGroupMemberRole } from '../../../utils/constants'
|
||||
import { UserAvatar } from '../user'
|
||||
import { ImGroupMemberRole } from '../../../utils/constants';
|
||||
import { UserAvatar } from '../user';
|
||||
|
||||
defineOptions({ name: 'ImGroupMemberItem' })
|
||||
defineOptions({ name: 'ImGroupMemberItem' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
active?: boolean
|
||||
height?: number
|
||||
member: GroupMemberLite
|
||||
active?: boolean;
|
||||
height?: number;
|
||||
member: GroupMemberLite;
|
||||
}>(),
|
||||
{
|
||||
height: 50,
|
||||
active: false
|
||||
}
|
||||
)
|
||||
active: false,
|
||||
},
|
||||
);
|
||||
|
||||
defineEmits<{
|
||||
click: [member: GroupMemberLite]
|
||||
}>()
|
||||
click: [member: GroupMemberLite];
|
||||
}>();
|
||||
|
||||
const avatarSize = computed(() => Math.ceil(props.height * 0.75))
|
||||
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 ''
|
||||
if (
|
||||
props.member.role === null ||
|
||||
props.member.role === ImGroupMemberRole.NORMAL
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
return getDictLabel(DICT_TYPE.IM_GROUP_MEMBER_ROLE, props.member.role)
|
||||
})
|
||||
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-primary)] bg-[var(--ant-color-primary-bg)]';
|
||||
}
|
||||
return 'text-[var(--ant-color-info)] bg-[var(--ant-color-fill)]'
|
||||
})
|
||||
return 'text-[var(--ant-color-info)] bg-[var(--ant-color-fill)]';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -54,8 +57,10 @@ const roleLabelClass = computed(() => {
|
||||
-->
|
||||
<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` }"
|
||||
:class="{
|
||||
'!bg-[#e1eaf7] dark:!bg-[var(--ant-color-primary-bg-hover)]': active,
|
||||
}"
|
||||
:style="{ height: `${height}px` }"
|
||||
@click="$emit('click', member)"
|
||||
>
|
||||
<UserAvatar
|
||||
@@ -67,7 +72,7 @@ const roleLabelClass = computed(() => {
|
||||
/>
|
||||
<div
|
||||
class="flex-1 h-full pl-1 overflow-hidden text-sm truncate text-[var(--ant-color-text)]"
|
||||
:style="{ lineHeight: `${height }px` }"
|
||||
:style="{ lineHeight: `${height}px` }"
|
||||
>
|
||||
{{ member.showName }}
|
||||
</div>
|
||||
|
||||
@@ -1,59 +1,59 @@
|
||||
<script lang="ts" setup>
|
||||
import type { GroupMemberLite } from './group-member.vue'
|
||||
import type { GroupMemberLite } from './group-member.vue';
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Button, message, Modal } from 'ant-design-vue'
|
||||
import { Button, message, Modal } from 'ant-design-vue';
|
||||
|
||||
import { removeGroupMember } from '#/api/im/group/member'
|
||||
import { removeGroupMember } from '#/api/im/group/member';
|
||||
|
||||
import { GroupMemberPickerPanel } from '../picker'
|
||||
import { GroupMemberPickerPanel } from '../picker';
|
||||
|
||||
defineOptions({ name: 'ImGroupMemberRemoveDialog' })
|
||||
defineOptions({ name: 'ImGroupMemberRemoveDialog' });
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 移出成功;父侧通常用来 reload 群数据 */
|
||||
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[]>([])
|
||||
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
|
||||
groupId: number;
|
||||
/** 隐藏 userId:群主始终隐藏;管理员视角额外隐藏其它管理员 */
|
||||
hideIds?: number[]
|
||||
members: GroupMemberLite[]
|
||||
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
|
||||
}
|
||||
})
|
||||
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
|
||||
return;
|
||||
}
|
||||
submitting.value = true
|
||||
submitting.value = true;
|
||||
try {
|
||||
const memberUserIds = [...selectedIds.value]
|
||||
await removeGroupMember({ groupId: groupId.value, memberUserIds })
|
||||
message.success(`已移除 ${memberUserIds.length} 位成员`)
|
||||
emit('reload')
|
||||
visible.value = false
|
||||
const memberUserIds = [...selectedIds.value];
|
||||
await removeGroupMember({ groupId: groupId.value, memberUserIds });
|
||||
message.success(`已移除 ${memberUserIds.length} 位成员`);
|
||||
emit('reload');
|
||||
visible.value = false;
|
||||
} finally {
|
||||
submitting.value = false
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { ImFriendAddSource } from '../../../utils/constants'
|
||||
import { UserAvatar } from '../user'
|
||||
import { ImFriendAddSource } from '../../../utils/constants';
|
||||
import { UserAvatar } from '../user';
|
||||
|
||||
defineOptions({ name: 'ImGroupMember' })
|
||||
defineOptions({ name: 'ImGroupMember' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
active?: boolean // 选中态(@候选键盘高亮等)
|
||||
clickable?: boolean // 头像点击是否弹 UserInfoCard;@候选场景通常禁用(避免嵌套交互)
|
||||
groupName?: string // 群名:加好友时拼「我是 'XX 群' 的 YY」话术,落库 add_source=GROUP
|
||||
height?: number // 行高(px),影响头像大小
|
||||
member: GroupMemberLite
|
||||
active?: boolean; // 选中态(@候选键盘高亮等)
|
||||
clickable?: boolean; // 头像点击是否弹 UserInfoCard;@候选场景通常禁用(避免嵌套交互)
|
||||
groupName?: string; // 群名:加好友时拼「我是 'XX 群' 的 YY」话术,落库 add_source=GROUP
|
||||
height?: number; // 行高(px),影响头像大小
|
||||
member: GroupMemberLite;
|
||||
}>(),
|
||||
{
|
||||
height: 50,
|
||||
active: false,
|
||||
clickable: false,
|
||||
groupName: ''
|
||||
}
|
||||
)
|
||||
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 // 成员角色,仅在群信息抽屉等需要展示角色标签的场景透传;@候选 / 已读列表等场景可不传
|
||||
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))
|
||||
const avatarSize = computed(() => Math.ceil(props.height * 0.75));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -43,7 +43,7 @@ const avatarSize = computed(() => Math.ceil(props.height * 0.75))
|
||||
<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` }"
|
||||
:style="{ height: `${height}px` }"
|
||||
>
|
||||
<UserAvatar
|
||||
:size="avatarSize"
|
||||
@@ -56,7 +56,7 @@ const avatarSize = computed(() => Math.ceil(props.height * 0.75))
|
||||
/>
|
||||
<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` }"
|
||||
:style="{ lineHeight: `${height}px` }"
|
||||
>
|
||||
{{ member.showName }}
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Button, message, Modal } from 'ant-design-vue'
|
||||
import { Button, message, Modal } from 'ant-design-vue';
|
||||
|
||||
import { muteMember } from '#/api/im/group'
|
||||
import { muteMember } from '#/api/im/group';
|
||||
|
||||
defineOptions({ name: 'ImGroupMuteMemberDialog' })
|
||||
defineOptions({ name: 'ImGroupMuteMemberDialog' });
|
||||
|
||||
const emit = defineEmits<{
|
||||
success: []
|
||||
}>()
|
||||
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 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 },
|
||||
@@ -25,57 +25,64 @@ const presets = [
|
||||
{ label: '1 天', value: 86_400 },
|
||||
{ label: '7 天', value: 604_800 },
|
||||
{ label: '30 天', value: 2_592_000 },
|
||||
{ label: '永久', value: 0 }
|
||||
]
|
||||
{ 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
|
||||
groupId.value = gid;
|
||||
userId.value = uid;
|
||||
memberName.value = name;
|
||||
selected.value = 600;
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
/** 确认禁言 */
|
||||
async function handleConfirm() {
|
||||
loading.value = true
|
||||
loading.value = true;
|
||||
try {
|
||||
await muteMember({
|
||||
id: groupId.value,
|
||||
userId: userId.value,
|
||||
mutedSeconds: selected.value
|
||||
})
|
||||
message.success('禁言成功')
|
||||
visible.value = false
|
||||
emit('success')
|
||||
mutedSeconds: selected.value,
|
||||
});
|
||||
message.success('禁言成功');
|
||||
visible.value = false;
|
||||
emit('success');
|
||||
} finally {
|
||||
loading.value = false
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open })
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 禁言时长选择弹窗 -->
|
||||
<Modal v-model:open="visible" title="设置禁言" width="560px" :mask-closable="false">
|
||||
<Modal
|
||||
v-model:open="visible"
|
||||
title="设置禁言"
|
||||
width="560px"
|
||||
:mask-closable="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"
|
||||
>
|
||||
<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="mb-2 text-13px text-[var(--ant-color-text-secondary)]">
|
||||
禁言时长
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<Button
|
||||
v-for="opt in presets"
|
||||
@@ -91,7 +98,9 @@ defineExpose({ open })
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button @click="visible = false">取消</Button>
|
||||
<Button type="primary" :loading="loading" @click="handleConfirm">确定</Button>
|
||||
<Button type="primary" :loading="loading" @click="handleConfirm">
|
||||
确定
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -1,79 +1,79 @@
|
||||
<script lang="ts" setup>
|
||||
import type { GroupMemberLite } from './group-member.vue'
|
||||
import type { GroupMemberLite } from './group-member.vue';
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { confirm } from '@vben/common-ui'
|
||||
import { confirm } from '@vben/common-ui';
|
||||
|
||||
import { Button, message, Modal } from 'ant-design-vue'
|
||||
import { Button, message, Modal } from 'ant-design-vue';
|
||||
|
||||
import { transferGroupOwner } from '#/api/im/group'
|
||||
import { transferGroupOwner } from '#/api/im/group';
|
||||
|
||||
import { GroupMemberPickerPanel } from '../picker'
|
||||
import { GroupMemberPickerPanel } from '../picker';
|
||||
|
||||
defineOptions({ name: 'ImGroupOwnerTransferDialog' })
|
||||
defineOptions({ name: 'ImGroupOwnerTransferDialog' });
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 转让成功;父侧通常用来 reload 群数据 */
|
||||
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[]>([])
|
||||
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
|
||||
groupId: number;
|
||||
/** 隐藏 userId:当前用户(不能转给自己) */
|
||||
hideIds?: number[]
|
||||
members: GroupMemberLite[]
|
||||
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
|
||||
}
|
||||
})
|
||||
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 undefined;
|
||||
}
|
||||
return members.value.find((member) => member.userId === selectedIds.value[0])
|
||||
})
|
||||
return members.value.find((member) => member.userId === selectedIds.value[0]);
|
||||
});
|
||||
|
||||
/** 二次确认转让:转让后旧群主降为普通成员,无法撤销 */
|
||||
async function handleOk() {
|
||||
if (!groupId.value || !newOwner.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await confirm(
|
||||
`确定将群主转让给 ${newOwner.value.showName}?转让后你将变为普通成员,无法撤销。`,
|
||||
'确认转让群主'
|
||||
)
|
||||
'确认转让群主',
|
||||
);
|
||||
} catch {
|
||||
return
|
||||
return;
|
||||
}
|
||||
submitting.value = true
|
||||
submitting.value = true;
|
||||
try {
|
||||
await transferGroupOwner({
|
||||
id: groupId.value,
|
||||
newOwnerUserId: newOwner.value.userId
|
||||
})
|
||||
message.success('群主转让成功')
|
||||
emit('reload')
|
||||
visible.value = false
|
||||
newOwnerUserId: newOwner.value.userId,
|
||||
});
|
||||
message.success('群主转让成功');
|
||||
emit('reload');
|
||||
visible.value = false;
|
||||
} finally {
|
||||
submitting.value = false
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,59 +1,59 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ImGroupRequestApi } from '#/api/im/group/request'
|
||||
import type { ImGroupRequestApi } from '#/api/im/group/request';
|
||||
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { prompt } from '@vben/common-ui'
|
||||
import { prompt } from '@vben/common-ui';
|
||||
|
||||
import { Empty, Input, message, Modal, Spin } from 'ant-design-vue'
|
||||
import { Empty, Input, message, Modal, Spin } from 'ant-design-vue';
|
||||
|
||||
import { getGroupRequestListByGroupId } from '#/api/im/group/request'
|
||||
import { ImGroupRequestHandleResult } from '#/views/im/utils/constants'
|
||||
import { getGroupRequestListByGroupId } from '#/api/im/group/request';
|
||||
import { ImGroupRequestHandleResult } from '#/views/im/utils/constants';
|
||||
|
||||
import { useGroupRequestStore } from '../../store/groupRequestStore'
|
||||
import { UserAvatar } from '../user'
|
||||
import { useGroupRequestStore } from '../../store/groupRequestStore';
|
||||
import { UserAvatar } from '../user';
|
||||
|
||||
defineOptions({ name: 'ImGroupRequestListDialog' })
|
||||
defineOptions({ name: 'ImGroupRequestListDialog' });
|
||||
|
||||
const groupRequestStore = useGroupRequestStore()
|
||||
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)
|
||||
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
|
||||
}
|
||||
})
|
||||
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
|
||||
)
|
||||
groupId.value ? groupList.value : groupRequestStore.unhandledList,
|
||||
);
|
||||
|
||||
/** 顶部卡片:最新一条;空数组时为 null */
|
||||
const latest = computed(() => list.value[0] || null)
|
||||
const latest = computed(() => list.value[0] || null);
|
||||
/** 历史列表:除最新一条外的其余 */
|
||||
const histories = computed(() => list.value.slice(1))
|
||||
const histories = computed(() => list.value.slice(1));
|
||||
|
||||
/** 打开 dialog 时拉数据:单群拉 API;全局直接读 store;关闭时清掉单群缓存 */
|
||||
watch(
|
||||
[visible, groupId],
|
||||
([isVisible, currentGroupId]) => {
|
||||
if (isVisible && currentGroupId) {
|
||||
void fetchList(currentGroupId)
|
||||
void fetchList(currentGroupId);
|
||||
} else if (!isVisible) {
|
||||
groupList.value = []
|
||||
groupList.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
/**
|
||||
* 单群模式下订阅 store 中归属本群的未处理列表变化:远端事件(WS 1503 新申请 / 其他管理员处理)触发时 refetch
|
||||
@@ -68,89 +68,92 @@ watch(
|
||||
.filter((request) => request.groupId === groupId.value)
|
||||
.map(
|
||||
(request) =>
|
||||
`${request.id}:${request.inviterUserId ?? ''}:${request.applyContent ?? ''}`
|
||||
`${request.id}:${request.inviterUserId ?? ''}:${request.applyContent ?? ''}`,
|
||||
)
|
||||
.join(',')
|
||||
: null,
|
||||
(current, previous) => {
|
||||
if (current === null || previous === undefined || current === previous) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (actingId.value !== null) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (groupId.value) {
|
||||
void fetchList(groupId.value)
|
||||
void fetchList(groupId.value);
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
let fetchSeq = 0 // 单调递增请求序号;同群也会因为 WS 1503 推送触发额外 fetch,乱序返回时旧响应不能覆盖新数据
|
||||
let fetchSeq = 0; // 单调递增请求序号;同群也会因为 WS 1503 推送触发额外 fetch,乱序返回时旧响应不能覆盖新数据
|
||||
async function fetchList(targetGroupId: number) {
|
||||
const seq = ++fetchSeq
|
||||
loading.value = true
|
||||
const seq = ++fetchSeq;
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = (await getGroupRequestListByGroupId(targetGroupId)) || []
|
||||
const data = (await getGroupRequestListByGroupId(targetGroupId)) || [];
|
||||
// 期间切群 / 关弹窗 / 又触发更新 fetch:丢响应
|
||||
if (seq !== fetchSeq || !visible.value || groupId.value !== targetGroupId) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
groupList.value = data
|
||||
groupList.value = data;
|
||||
} finally {
|
||||
// 旧请求 finally 命中时新请求仍在跑,跳过避免提前关 loading
|
||||
if (seq === fetchSeq) {
|
||||
loading.value = false
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 同意:走 store 同步全局未处理列表 + 本地更新 handleResult 让按钮变灰 */
|
||||
async function handleAgree(item: ImGroupRequestApi.GroupRequestRespVO) {
|
||||
if (actingId.value !== null) return
|
||||
actingId.value = item.id
|
||||
if (actingId.value !== null) return;
|
||||
actingId.value = item.id;
|
||||
try {
|
||||
await groupRequestStore.agreeGroupRequest(item.id)
|
||||
updateLocalResult(item.id, ImGroupRequestHandleResult.AGREED)
|
||||
message.success('已同意')
|
||||
await groupRequestStore.agreeGroupRequest(item.id);
|
||||
updateLocalResult(item.id, ImGroupRequestHandleResult.AGREED);
|
||||
message.success('已同意');
|
||||
} finally {
|
||||
actingId.value = null
|
||||
actingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 拒绝:弹理由输入框;为空则不带 handleContent */
|
||||
async function handleRefuse(item: ImGroupRequestApi.GroupRequestRespVO) {
|
||||
if (actingId.value !== null) return
|
||||
let handleContent: string
|
||||
if (actingId.value !== null) return;
|
||||
let handleContent: string;
|
||||
try {
|
||||
const result = await prompt<string>({
|
||||
component: Input,
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
placeholder: '请输入拒绝理由(可选)'
|
||||
placeholder: '请输入拒绝理由(可选)',
|
||||
},
|
||||
content: '',
|
||||
modelPropName: 'value',
|
||||
title: '拒绝申请'
|
||||
})
|
||||
handleContent = result || ''
|
||||
title: '拒绝申请',
|
||||
});
|
||||
handleContent = result || '';
|
||||
} catch {
|
||||
return
|
||||
return;
|
||||
}
|
||||
actingId.value = item.id
|
||||
actingId.value = item.id;
|
||||
try {
|
||||
await groupRequestStore.refuseGroupRequest(item.id, handleContent || undefined)
|
||||
updateLocalResult(item.id, ImGroupRequestHandleResult.REFUSED)
|
||||
message.success('已拒绝')
|
||||
await groupRequestStore.refuseGroupRequest(
|
||||
item.id,
|
||||
handleContent || undefined,
|
||||
);
|
||||
updateLocalResult(item.id, ImGroupRequestHandleResult.REFUSED);
|
||||
message.success('已拒绝');
|
||||
} finally {
|
||||
actingId.value = null
|
||||
actingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 单群模式下处理后更新 groupList 里的 handleResult,按钮转「已同意 / 已拒绝」灰态;全局模式 store 直接移除该项无需更新 */
|
||||
function updateLocalResult(id: number, handleResult: number) {
|
||||
const target = groupList.value.find((r) => r.id === id)
|
||||
const target = groupList.value.find((r) => r.id === id);
|
||||
if (target) {
|
||||
target.handleResult = handleResult
|
||||
target.handleResult = handleResult;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -173,85 +176,92 @@ function updateLocalResult(id: number, handleResult: number) {
|
||||
<Spin :spinning="loading" wrapper-class-name="w-full">
|
||||
<div class="flex flex-col gap-3 max-h-[60vh] overflow-y-auto pr-1">
|
||||
<!-- 空态 -->
|
||||
<Empty v-if="!loading && list.length === 0" description="暂无进群申请" />
|
||||
<Empty
|
||||
v-if="!loading && list.length === 0"
|
||||
description="暂无进群申请"
|
||||
/>
|
||||
|
||||
<!-- 顶部卡片:最新一条 -->
|
||||
<!-- 顶部卡片:最新一条 -->
|
||||
<div
|
||||
v-if="latest"
|
||||
class="flex flex-col gap-2.5 p-3.5 rounded-[10px] border border-solid border-[var(--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 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>
|
||||
<div
|
||||
class="truncate mt-[2px] text-12px leading-[1.5] text-[var(--ant-color-text-secondary)]"
|
||||
<span
|
||||
v-if="latest.handleResult === ImGroupRequestHandleResult.AGREED"
|
||||
class="flex-shrink-0 text-[13px] text-[var(--ant-color-text-placeholder)]"
|
||||
>
|
||||
<template v-if="latest.inviterUserId">
|
||||
通过
|
||||
<span class="text-[var(--ant-color-primary)]">
|
||||
{{ latest.inviterNickname || `用户 ${latest.inviterUserId}` }}
|
||||
</span>
|
||||
的邀请进群
|
||||
</template>
|
||||
<template v-else>申请加入</template>
|
||||
已同意
|
||||
</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>
|
||||
<span
|
||||
v-if="latest.handleResult === ImGroupRequestHandleResult.AGREED"
|
||||
class="flex-shrink-0 text-[13px] text-[var(--ant-color-text-placeholder)]"
|
||||
<!-- 申请理由:邀请场景显示邀请人 + 留言;主动申请显示申请人 + 留言 -->
|
||||
<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>
|
||||
<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>
|
||||
<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="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)]"
|
||||
@@ -265,61 +275,63 @@ function updateLocalResult(id: number, handleResult: number) {
|
||||
: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 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>
|
||||
<div
|
||||
class="truncate mt-[2px] text-12px leading-[1.5] text-[var(--ant-color-text-secondary)]"
|
||||
<span
|
||||
v-if="item.handleResult === ImGroupRequestHandleResult.AGREED"
|
||||
class="flex-shrink-0 text-[13px] text-[var(--ant-color-text-placeholder)]"
|
||||
>
|
||||
<template v-if="item.inviterUserId">
|
||||
通过
|
||||
<span class="text-[var(--ant-color-primary)]">
|
||||
{{ item.inviterNickname || `用户 ${item.inviterUserId}` }}
|
||||
</span>
|
||||
的邀请进群
|
||||
</template>
|
||||
<template v-else>申请加入</template>
|
||||
已同意
|
||||
</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>
|
||||
<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>
|
||||
</Spin>
|
||||
@@ -334,17 +346,18 @@ function updateLocalResult(id: number, handleResult: number) {
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
transition:
|
||||
background-color 0.15s,
|
||||
border-color 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.im-group-request-list__btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.im-group-request-list__btn--primary {
|
||||
@@ -352,6 +365,7 @@ function updateLocalResult(id: number, handleResult: number) {
|
||||
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);
|
||||
@@ -362,6 +376,7 @@ function updateLocalResult(id: number, handleResult: number) {
|
||||
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);
|
||||
|
||||
@@ -1,89 +1,100 @@
|
||||
<script lang="ts" setup generic="T">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
useTemplateRef,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
defineOptions({ name: 'ImPagedScroller' })
|
||||
defineOptions({ name: 'ImPagedScroller' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
itemKey?: string // 业务 id 字段名(如 'userId' / 'id');不传 / 字段值非 string|number 时回退 idx
|
||||
items: T[] // 全量数据
|
||||
pageSize?: number // 每页渲染条数
|
||||
threshold?: number // 距底多少 px 触发下一页
|
||||
itemKey?: string; // 业务 id 字段名(如 'userId' / 'id');不传 / 字段值非 string|number 时回退 idx
|
||||
items: T[]; // 全量数据
|
||||
pageSize?: number; // 每页渲染条数
|
||||
threshold?: number; // 距底多少 px 触发下一页
|
||||
}>(),
|
||||
{
|
||||
itemKey: undefined,
|
||||
pageSize: 30,
|
||||
threshold: 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
|
||||
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 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 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 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 allLoaded = computed(
|
||||
() => displayItems.value.length >= props.items.length,
|
||||
);
|
||||
|
||||
/** 仅当超过一页时才显示「已到底部」,避免短列表也出现这条提示 */
|
||||
const showFooter = computed(() => allLoaded.value && props.items.length > props.pageSize)
|
||||
const showFooter = computed(
|
||||
() => allLoaded.value && props.items.length > props.pageSize,
|
||||
);
|
||||
|
||||
let wrapEl: HTMLElement | null = null
|
||||
let wrapEl: HTMLElement | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
wrapEl = scrollbarRef.value
|
||||
wrapEl?.addEventListener('scroll', onScroll)
|
||||
})
|
||||
wrapEl = scrollbarRef.value;
|
||||
wrapEl?.addEventListener('scroll', onScroll);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
wrapEl?.removeEventListener('scroll', onScroll)
|
||||
})
|
||||
wrapEl?.removeEventListener('scroll', onScroll);
|
||||
});
|
||||
|
||||
/** 切换数据源(如切会话)时重置分页:避免新列表沿用旧 page,首屏出现空段 */
|
||||
watch(
|
||||
() => props.items,
|
||||
() => {
|
||||
page.value = 1
|
||||
}
|
||||
)
|
||||
page.value = 1;
|
||||
},
|
||||
);
|
||||
|
||||
/** 滚到距底 threshold 内时自增 page,扩出下一段切片 */
|
||||
function onScroll(e: Event) {
|
||||
const el = e.target as HTMLElement
|
||||
const el = e.target as HTMLElement;
|
||||
if (el.scrollTop + el.clientHeight < el.scrollHeight - props.threshold) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (allLoaded.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
page.value++
|
||||
page.value++;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
/** 手动滚到顶部 */
|
||||
scrollTop: () => {
|
||||
if (wrapEl) {
|
||||
wrapEl.scrollTop = 0
|
||||
wrapEl.scrollTop = 0;
|
||||
}
|
||||
},
|
||||
/** 手动滚到底部 */
|
||||
scrollBottom: () => {
|
||||
if (wrapEl) {
|
||||
wrapEl.scrollTop = wrapEl.scrollHeight
|
||||
wrapEl.scrollTop = wrapEl.scrollHeight;
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,88 +1,94 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Conversation } from '../../types'
|
||||
import type { Conversation } from '../../types';
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { IconifyIcon as Icon } from '@vben/icons'
|
||||
import { IconifyIcon as Icon } from '@vben/icons';
|
||||
|
||||
import { Input, message } from 'ant-design-vue'
|
||||
import { Input, message } from 'ant-design-vue';
|
||||
|
||||
import { ImConversationType } from '../../../utils/constants'
|
||||
import { filterConversationsByKeyword, getConversationKey } from '../../../utils/conversation'
|
||||
import { GroupAvatar } from '../group'
|
||||
import { UserAvatar } from '../user'
|
||||
import { ImConversationType } from '../../../utils/constants';
|
||||
import {
|
||||
filterConversationsByKeyword,
|
||||
getConversationKey,
|
||||
} from '../../../utils/conversation';
|
||||
import { GroupAvatar } from '../group';
|
||||
import { UserAvatar } from '../user';
|
||||
|
||||
defineOptions({ name: 'ImConversationPickerPanel' })
|
||||
defineOptions({ name: 'ImConversationPickerPanel' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 全量会话列表 */
|
||||
conversations: Conversation[]
|
||||
conversations: Conversation[];
|
||||
/** 隐藏 key:从候选 / 已选 / 最近转发里都剔除(不能转发回自己、推荐名片自身的会话等) */
|
||||
hideKeys?: string[]
|
||||
hideKeys?: string[];
|
||||
/** 已选数上限;不传或 <=0 时不限 */
|
||||
maxSize?: number
|
||||
maxSize?: number;
|
||||
/** 最近转发会话 key 列表;展示在左栏顶部横向头像区 */
|
||||
recentForwardConversationKeys?: string[]
|
||||
recentForwardConversationKeys?: string[];
|
||||
/** 已选会话 key(v-model);key 由 getConversationKey 生成 */
|
||||
selectedKeys: string[]
|
||||
selectedKeys: string[];
|
||||
/** 是否展示「创建聊天」入口 */
|
||||
showCreateChat?: boolean
|
||||
showCreateChat?: boolean;
|
||||
}>(),
|
||||
{
|
||||
recentForwardConversationKeys: () => [],
|
||||
hideKeys: () => [],
|
||||
maxSize: 0,
|
||||
showCreateChat: false
|
||||
}
|
||||
)
|
||||
showCreateChat: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
createChat: []
|
||||
createChat: [];
|
||||
/** 用户在「最近转发」段进入移除模式后点 ×;业务壳收到后调 conversationStore.removeRecentForwardConversationKey 落盘 */
|
||||
removeRecent: [key: string]
|
||||
'update:selectedKeys': [value: string[]]
|
||||
}>()
|
||||
removeRecent: [key: string];
|
||||
'update:selectedKeys': [value: string[]];
|
||||
}>();
|
||||
|
||||
const keyword = ref('')
|
||||
const recentRemoveMode = ref(false) // 「最近转发」段是否处于移除模式:true 时头像右上角变 × 不再切勾选
|
||||
const keyword = ref('');
|
||||
const recentRemoveMode = ref(false); // 「最近转发」段是否处于移除模式:true 时头像右上角变 × 不再切勾选
|
||||
|
||||
/** 全量会话的 key→Conversation 映射,已选 / 最近转发反查共用,避免每次 O(N) 扫 */
|
||||
const byKey = computed(() => {
|
||||
const map = new Map<string, Conversation>()
|
||||
const map = new Map<string, Conversation>();
|
||||
for (const conversation of props.conversations) {
|
||||
map.set(getConversationKey(conversation), conversation)
|
||||
map.set(getConversationKey(conversation), conversation);
|
||||
}
|
||||
return map
|
||||
})
|
||||
return map;
|
||||
});
|
||||
|
||||
/** 隐藏集合:每次过滤复用 */
|
||||
const hideSet = computed(() => new Set(props.hideKeys))
|
||||
const hideSet = computed(() => new Set(props.hideKeys));
|
||||
|
||||
/** 已选集合:圆形指示器 isSelected 走 set 快查 */
|
||||
const selectedSet = computed(() => new Set(props.selectedKeys))
|
||||
const selectedSet = computed(() => new Set(props.selectedKeys));
|
||||
|
||||
/** 候选会话:剔除 hideKeys */
|
||||
const candidateConversations = computed(() =>
|
||||
props.conversations.filter((c) => !hideSet.value.has(getConversationKey(c)))
|
||||
)
|
||||
props.conversations.filter((c) => !hideSet.value.has(getConversationKey(c))),
|
||||
);
|
||||
|
||||
/** 左栏展示列表:在候选基础上按 keyword 过滤 */
|
||||
const shownConversations = computed(() =>
|
||||
filterConversationsByKeyword(candidateConversations.value, keyword.value)
|
||||
)
|
||||
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)))
|
||||
)
|
||||
.filter(
|
||||
(c): c is Conversation =>
|
||||
c !== null && !hideSet.value.has(getConversationKey(c)),
|
||||
),
|
||||
);
|
||||
|
||||
/** 是否展示「最近转发」段:keyword 为空 + 有数据时才展示,搜索时让位 */
|
||||
const showRecentSection = computed(
|
||||
() => !keyword.value.trim() && recentForwardConversations.value.length > 0
|
||||
)
|
||||
() => !keyword.value.trim() && recentForwardConversations.value.length > 0,
|
||||
);
|
||||
|
||||
/** 已选会话列表:按 selectedKeys 数组顺序(即点击顺序)反查;过滤 hideSet 避免父组件动态隐藏的会话仍在右侧渲染 / 提交 */
|
||||
const selectedConversations = computed(() =>
|
||||
@@ -90,45 +96,48 @@ const selectedConversations = computed(() =>
|
||||
.map((key) => byKey.value.get(key))
|
||||
.filter(
|
||||
(conversation): conversation is Conversation =>
|
||||
conversation != null && !hideSet.value.has(getConversationKey(conversation))
|
||||
)
|
||||
)
|
||||
conversation !== null &&
|
||||
!hideSet.value.has(getConversationKey(conversation)),
|
||||
),
|
||||
);
|
||||
|
||||
/** 右栏标题文案:单选「发送给」、多选「分别发送给」 */
|
||||
const sendTitle = computed(() => (props.selectedKeys.length > 1 ? '分别发送给' : '发送给'))
|
||||
const sendTitle = computed(() =>
|
||||
props.selectedKeys.length > 1 ? '分别发送给' : '发送给',
|
||||
);
|
||||
|
||||
/** 是否已选中:左栏圆形指示器 / 最近转发头像角标共用 */
|
||||
function isSelected(conversation: Conversation): boolean {
|
||||
return selectedSet.value.has(getConversationKey(conversation))
|
||||
return selectedSet.value.has(getConversationKey(conversation));
|
||||
}
|
||||
|
||||
/** 「最近转发」头像点击:移除模式下不切勾选(移除由 × 角标处理) */
|
||||
function handleRecentTileClick(conversation: Conversation) {
|
||||
if (recentRemoveMode.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
handleToggle(conversation)
|
||||
handleToggle(conversation);
|
||||
}
|
||||
|
||||
/** 切换选中态:左栏 row / 最近转发头像 / 右栏 × 移除都走这里 */
|
||||
function handleToggle(conversation: Conversation) {
|
||||
const key = getConversationKey(conversation)
|
||||
const next = [...props.selectedKeys]
|
||||
const index = next.indexOf(key)
|
||||
const key = getConversationKey(conversation);
|
||||
const next = [...props.selectedKeys];
|
||||
const index = next.indexOf(key);
|
||||
if (index === -1) {
|
||||
// 父组件标记隐藏的会话即便有路径触达也不应入选
|
||||
if (hideSet.value.has(key)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (props.maxSize > 0 && next.length >= props.maxSize) {
|
||||
message.error(`最多选择 ${props.maxSize} 个会话`)
|
||||
return
|
||||
message.error(`最多选择 ${props.maxSize} 个会话`);
|
||||
return;
|
||||
}
|
||||
next.push(key)
|
||||
next.push(key);
|
||||
} else {
|
||||
next.splice(index, 1)
|
||||
next.splice(index, 1);
|
||||
}
|
||||
emit('update:selectedKeys', next)
|
||||
emit('update:selectedKeys', next);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -158,7 +167,9 @@ function handleToggle(conversation: Conversation) {
|
||||
<!-- 最近转发横向头像区: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="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"
|
||||
@@ -195,7 +206,9 @@ function handleToggle(conversation: Conversation) {
|
||||
<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))"
|
||||
@click.stop="
|
||||
emit('removeRecent', getConversationKey(conversation))
|
||||
"
|
||||
>
|
||||
<Icon icon="ant-design:close-outlined" :size="10" />
|
||||
</span>
|
||||
@@ -241,7 +254,11 @@ function handleToggle(conversation: Conversation) {
|
||||
</div>
|
||||
|
||||
<!-- 最近聊天分组标题 -->
|
||||
<div class="px-3 pb-1.5 text-13px text-[var(--ant-color-text-secondary)]">最近聊天</div>
|
||||
<div
|
||||
class="px-3 pb-1.5 text-13px text-[var(--ant-color-text-secondary)]"
|
||||
>
|
||||
最近聊天
|
||||
</div>
|
||||
|
||||
<!-- 会话列表 -->
|
||||
<div
|
||||
@@ -363,6 +380,7 @@ function handleToggle(conversation: Conversation) {
|
||||
.im-conversation-picker__recent::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.im-conversation-picker__recent::-webkit-scrollbar-thumb {
|
||||
background-color: var(--ant-color-border);
|
||||
border-radius: 2px;
|
||||
|
||||
@@ -1,123 +1,124 @@
|
||||
<script lang="ts" setup>
|
||||
import type { FriendLite } from '../../types'
|
||||
import type { FriendLite } from '../../types';
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { IconifyIcon as Icon } from '@vben/icons'
|
||||
import { IconifyIcon as Icon } from '@vben/icons';
|
||||
|
||||
import { Input, message } from 'ant-design-vue'
|
||||
import { Input, message } from 'ant-design-vue';
|
||||
|
||||
import { PagedScroller } from '..'
|
||||
import { useFriendBuckets } from '../../composables/useFriendBuckets'
|
||||
import { useSelectedItems } from '../../composables/useSelectedItems'
|
||||
import { UserAvatar } from '../user'
|
||||
import { PagedScroller } from '..';
|
||||
import { useFriendBuckets } from '../../composables/useFriendBuckets';
|
||||
import { useSelectedItems } from '../../composables/useSelectedItems';
|
||||
import { UserAvatar } from '../user';
|
||||
|
||||
defineOptions({ name: 'ImFriendPickerPanel' })
|
||||
defineOptions({ name: 'ImFriendPickerPanel' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 禁用 id:列表里展示置灰、不可勾选、不计入已选数(典型:邀请入群时已在群成员) */
|
||||
disabledIds?: number[]
|
||||
disabledIds?: number[];
|
||||
/** 全量好友列表 */
|
||||
friends: FriendLite[]
|
||||
friends: FriendLite[];
|
||||
/** 隐藏 id:不展示(hide > locked > disabled) */
|
||||
hideIds?: number[]
|
||||
hideIds?: number[];
|
||||
/** 锁定 id:默认勾选、不可取消、计入已选数(典型:私聊侧 +建群锁定对方) */
|
||||
lockedIds?: number[]
|
||||
lockedIds?: number[];
|
||||
/** 已选数上限;不传或 <=0 时不限 */
|
||||
maxSize?: number
|
||||
maxSize?: number;
|
||||
/** 已选好友 id(v-model);按数组顺序即为点击顺序 */
|
||||
selectedIds: number[]
|
||||
selectedIds: number[];
|
||||
}>(),
|
||||
{
|
||||
lockedIds: () => [],
|
||||
disabledIds: () => [],
|
||||
hideIds: () => [],
|
||||
maxSize: 0
|
||||
}
|
||||
)
|
||||
maxSize: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selectedIds': [value: number[]]
|
||||
}>()
|
||||
'update:selectedIds': [value: number[]];
|
||||
}>();
|
||||
|
||||
const keyword = ref('')
|
||||
const keyword = ref('');
|
||||
|
||||
/** id → friend 映射,已选反查 / 三态判定共用,避免每次 O(N) 扫 */
|
||||
const byId = computed(() => {
|
||||
const map = new Map<number, FriendLite>()
|
||||
const map = new Map<number, FriendLite>();
|
||||
for (const friend of props.friends) {
|
||||
map.set(friend.id, friend)
|
||||
map.set(friend.id, friend);
|
||||
}
|
||||
return map
|
||||
})
|
||||
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))
|
||||
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))
|
||||
)
|
||||
props.friends.filter((friend) => !hideSet.value.has(friend.id)),
|
||||
);
|
||||
|
||||
/** 委托 useFriendBuckets:搜索规则复用,左侧列表按滚动分页渲染 */
|
||||
const { filtered } = useFriendBuckets(candidates, keyword)
|
||||
const { filtered } = useFriendBuckets(candidates, keyword);
|
||||
|
||||
/** 已选数 + 已选好友列表:三态优先级 + 顺序拼接由 useSelectedItems 统一承担 */
|
||||
const { selectedCount, selectedItems: selectedFriends } = useSelectedItems<FriendLite>(
|
||||
() => props.selectedIds,
|
||||
() => props.lockedIds,
|
||||
() => props.disabledIds,
|
||||
() => props.hideIds,
|
||||
byId
|
||||
)
|
||||
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)
|
||||
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)
|
||||
return !lockedSet.value.has(friend.id) && disabledSet.value.has(friend.id);
|
||||
}
|
||||
|
||||
/** 是否选中:locked 视为永远选中 */
|
||||
function isSelected(friend: FriendLite): boolean {
|
||||
return selectedSet.value.has(friend.id)
|
||||
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]'
|
||||
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 '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)]'
|
||||
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
|
||||
return;
|
||||
}
|
||||
const next = [...props.selectedIds]
|
||||
const index = next.indexOf(friend.id)
|
||||
const next = [...props.selectedIds];
|
||||
const index = next.indexOf(friend.id);
|
||||
if (index === -1) {
|
||||
if (props.maxSize > 0 && selectedCount.value >= props.maxSize) {
|
||||
message.error(`最多选择 ${props.maxSize} 位好友`)
|
||||
return
|
||||
message.error(`最多选择 ${props.maxSize} 位好友`);
|
||||
return;
|
||||
}
|
||||
next.push(friend.id)
|
||||
next.push(friend.id);
|
||||
} else {
|
||||
next.splice(index, 1)
|
||||
next.splice(index, 1);
|
||||
}
|
||||
emit('update:selectedIds', next)
|
||||
emit('update:selectedIds', next);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -144,13 +145,19 @@ function handleToggle(friend: FriendLite) {
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-0">
|
||||
<PagedScroller v-if="filtered.length > 0" :items="filtered" :page-size="30" item-key="id">
|
||||
<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)
|
||||
'opacity-60 cursor-not-allowed hover:bg-transparent':
|
||||
isDisabled(item as FriendLite),
|
||||
}"
|
||||
@click="handleToggle(item as FriendLite)"
|
||||
>
|
||||
@@ -160,7 +167,10 @@ function handleToggle(friend: FriendLite) {
|
||||
:class="getCheckClass(item as FriendLite)"
|
||||
>
|
||||
<Icon
|
||||
v-if="isSelected(item as FriendLite) || isLocked(item as FriendLite)"
|
||||
v-if="
|
||||
isSelected(item as FriendLite) ||
|
||||
isLocked(item as FriendLite)
|
||||
"
|
||||
icon="ant-design:check-outlined"
|
||||
:size="12"
|
||||
color="#fff"
|
||||
@@ -177,12 +187,18 @@ function handleToggle(friend: FriendLite) {
|
||||
<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 }}
|
||||
{{
|
||||
(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)]">
|
||||
<div
|
||||
v-else
|
||||
class="py-10 text-13px text-center text-[var(--ant-color-text-disabled)]"
|
||||
>
|
||||
{{ keyword ? '没有匹配的好友' : '暂无好友' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,129 +1,132 @@
|
||||
<script lang="ts" setup>
|
||||
import type { GroupMemberLite } from '../group'
|
||||
import type { GroupMemberLite } from '../group';
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { CommonStatusEnum } from '@vben/constants'
|
||||
import { IconifyIcon as Icon } from '@vben/icons'
|
||||
import { CommonStatusEnum } from '@vben/constants';
|
||||
import { IconifyIcon as Icon } from '@vben/icons';
|
||||
|
||||
import { Input, message } from 'ant-design-vue'
|
||||
import { Input, message } from 'ant-design-vue';
|
||||
|
||||
import { PagedScroller } from '..'
|
||||
import { useSelectedItems } from '../../composables/useSelectedItems'
|
||||
import { GroupMemberGrid, GroupMemberItem } from '../group'
|
||||
import { UserAvatar } from '../user'
|
||||
import { PagedScroller } from '..';
|
||||
import { useSelectedItems } from '../../composables/useSelectedItems';
|
||||
import { GroupMemberGrid, GroupMemberItem } from '../group';
|
||||
import { UserAvatar } from '../user';
|
||||
|
||||
defineOptions({ name: 'ImGroupMemberPickerPanel' })
|
||||
defineOptions({ name: 'ImGroupMemberPickerPanel' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 禁用 userId:列表里展示置灰、不可勾选、不计入已选数 */
|
||||
disabledIds?: number[]
|
||||
disabledIds?: number[];
|
||||
/** 隐藏 userId:不展示(hide > locked > disabled) */
|
||||
hideIds?: number[]
|
||||
hideIds?: number[];
|
||||
/** 锁定 userId:默认勾选、不可取消、计入已选数 */
|
||||
lockedIds?: number[]
|
||||
lockedIds?: number[];
|
||||
/** 已选数上限;不传或 <=0 时不限 */
|
||||
maxSize?: number
|
||||
maxSize?: number;
|
||||
/** 群成员列表 */
|
||||
members: GroupMemberLite[]
|
||||
members: GroupMemberLite[];
|
||||
/** 已选区展示形态:默认 list 对齐微信新视觉 */
|
||||
selectedDisplay?: 'grid' | 'list'
|
||||
selectedDisplay?: 'grid' | 'list';
|
||||
/** 已选 userId(v-model);按数组顺序即为点击顺序 */
|
||||
selectedIds: number[]
|
||||
selectedIds: number[];
|
||||
}>(),
|
||||
{
|
||||
lockedIds: () => [],
|
||||
disabledIds: () => [],
|
||||
hideIds: () => [],
|
||||
maxSize: 0,
|
||||
selectedDisplay: 'list'
|
||||
}
|
||||
)
|
||||
selectedDisplay: 'list',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selectedIds': [value: number[]]
|
||||
}>()
|
||||
'update:selectedIds': [value: number[]];
|
||||
}>();
|
||||
|
||||
const keyword = ref('')
|
||||
const keyword = ref('');
|
||||
|
||||
/** userId → member 映射,已选反查 / 三态判定共用 */
|
||||
const byId = computed(() => {
|
||||
const map = new Map<number, GroupMemberLite>()
|
||||
const map = new Map<number, GroupMemberLite>();
|
||||
for (const member of props.members) {
|
||||
map.set(member.userId, member)
|
||||
map.set(member.userId, member);
|
||||
}
|
||||
return map
|
||||
})
|
||||
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))
|
||||
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()
|
||||
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))
|
||||
)
|
||||
})
|
||||
(!keywordLower || member.showName.toLowerCase().includes(keywordLower)),
|
||||
);
|
||||
});
|
||||
|
||||
/** 已选数 + 已选成员列表:三态优先级 + 顺序拼接由 useSelectedItems 统一承担 */
|
||||
const { selectedCount, selectedItems: selectedMembers } = useSelectedItems<GroupMemberLite>(
|
||||
() => props.selectedIds,
|
||||
() => props.lockedIds,
|
||||
() => props.disabledIds,
|
||||
() => props.hideIds,
|
||||
byId
|
||||
)
|
||||
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)
|
||||
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)
|
||||
return (
|
||||
!lockedSet.value.has(member.userId) && disabledSet.value.has(member.userId)
|
||||
);
|
||||
}
|
||||
|
||||
/** 是否选中:locked 视为永远选中 */
|
||||
function isSelected(member: GroupMemberLite): boolean {
|
||||
return selectedSet.value.has(member.userId)
|
||||
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]'
|
||||
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 '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)]'
|
||||
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
|
||||
return;
|
||||
}
|
||||
const next = [...props.selectedIds]
|
||||
const index = next.indexOf(member.userId)
|
||||
const next = [...props.selectedIds];
|
||||
const index = next.indexOf(member.userId);
|
||||
if (index === -1) {
|
||||
if (props.maxSize > 0 && selectedCount.value >= props.maxSize) {
|
||||
message.error(`最多选择 ${props.maxSize} 位成员`)
|
||||
return
|
||||
message.error(`最多选择 ${props.maxSize} 位成员`);
|
||||
return;
|
||||
}
|
||||
next.push(member.userId)
|
||||
next.push(member.userId);
|
||||
} else {
|
||||
next.splice(index, 1)
|
||||
next.splice(index, 1);
|
||||
}
|
||||
emit('update:selectedIds', next)
|
||||
emit('update:selectedIds', next);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -162,7 +165,10 @@ function handleToggle(member: GroupMemberLite) {
|
||||
:class="getCheckClass(item as GroupMemberLite)"
|
||||
>
|
||||
<Icon
|
||||
v-if="isSelected(item as GroupMemberLite) || isLocked(item as GroupMemberLite)"
|
||||
v-if="
|
||||
isSelected(item as GroupMemberLite) ||
|
||||
isLocked(item as GroupMemberLite)
|
||||
"
|
||||
icon="ant-design:check-outlined"
|
||||
:size="12"
|
||||
color="#fff"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// IM 选择类弹窗的公共样式 mixin
|
||||
// 每个业务壳在自己的 <style scoped lang="scss"> 内 @use + @include 即可,避免全局污染
|
||||
/*
|
||||
* IM 选择类弹窗的公共样式 mixin
|
||||
* 每个业务壳在自己的 <style scoped lang="scss"> 内 @use + @include 即可,避免全局污染
|
||||
*/
|
||||
|
||||
@mixin styles {
|
||||
:deep(.el-dialog__body) {
|
||||
padding: 0;
|
||||
@@ -7,7 +10,7 @@
|
||||
}
|
||||
|
||||
:deep(.el-dialog__header) {
|
||||
margin-right: 0;
|
||||
padding-bottom: 16px;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,81 +1,81 @@
|
||||
<script lang="ts" setup>
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
defineOptions({ name: 'ImResizableAside' })
|
||||
defineOptions({ name: 'ImResizableAside' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
defaultWidth?: number // 默认宽度
|
||||
maxWidth?: number // 最大宽度
|
||||
minWidth?: number // 最小宽度
|
||||
storageKey: string // localStorage 存储 key,必填;调用方传 StorageKeys.localStorage.asideWidth
|
||||
defaultWidth?: number; // 默认宽度
|
||||
maxWidth?: number; // 最大宽度
|
||||
minWidth?: number; // 最小宽度
|
||||
storageKey: string; // localStorage 存储 key,必填;调用方传 StorageKeys.localStorage.asideWidth
|
||||
}>(),
|
||||
{
|
||||
defaultWidth: 260,
|
||||
minWidth: 200,
|
||||
maxWidth: 500
|
||||
}
|
||||
)
|
||||
maxWidth: 500,
|
||||
},
|
||||
);
|
||||
|
||||
const asideWidth = ref<number>(props.defaultWidth)
|
||||
const isResizing = ref(false)
|
||||
let startX = 0
|
||||
let startWidth = 0
|
||||
const asideWidth = ref<number>(props.defaultWidth);
|
||||
const isResizing = ref(false);
|
||||
let startX = 0;
|
||||
let startWidth = 0;
|
||||
|
||||
onMounted(() => {
|
||||
const saved = localStorage.getItem(props.storageKey)
|
||||
const saved = localStorage.getItem(props.storageKey);
|
||||
if (saved) {
|
||||
const w = Number.parseInt(saved, 10)
|
||||
const w = Number.parseInt(saved, 10);
|
||||
if (!Number.isNaN(w)) {
|
||||
asideWidth.value = clamp(w)
|
||||
asideWidth.value = clamp(w);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousemove', handleResize)
|
||||
document.addEventListener('mouseup', stopResize)
|
||||
})
|
||||
document.addEventListener('mousemove', handleResize);
|
||||
document.addEventListener('mouseup', stopResize);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// 拖拽中卸载:复用 stopResize 复位 body cursor/userSelect 并写回当前宽度,避免全局状态泄漏
|
||||
if (isResizing.value) {
|
||||
stopResize()
|
||||
stopResize();
|
||||
}
|
||||
document.removeEventListener('mousemove', handleResize)
|
||||
document.removeEventListener('mouseup', 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))
|
||||
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()
|
||||
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
|
||||
return;
|
||||
}
|
||||
const deltaX = e.clientX - startX
|
||||
asideWidth.value = clamp(startWidth + deltaX)
|
||||
const deltaX = e.clientX - startX;
|
||||
asideWidth.value = clamp(startWidth + deltaX);
|
||||
}
|
||||
|
||||
/** 松开鼠标:解锁 body 全局态并把当前宽度写入 localStorage 持久化 */
|
||||
function stopResize() {
|
||||
if (!isResizing.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
isResizing.value = false
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
localStorage.setItem(props.storageKey, String(asideWidth.value))
|
||||
isResizing.value = false;
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
localStorage.setItem(props.storageKey, String(asideWidth.value));
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -87,7 +87,7 @@ function stopResize() {
|
||||
-->
|
||||
<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` }"
|
||||
:style="{ width: `${asideWidth}px` }"
|
||||
>
|
||||
<slot></slot>
|
||||
<div
|
||||
@@ -96,7 +96,9 @@ function stopResize() {
|
||||
title="拖拽调整宽度"
|
||||
@mousedown="startResize"
|
||||
>
|
||||
<div class="im-resizable-aside__line w-0.5 h-full rounded-0.5 bg-transparent transition-all"></div>
|
||||
<div
|
||||
class="im-resizable-aside__line w-0.5 h-full rounded-0.5 bg-transparent transition-all"
|
||||
></div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import type { CallParticipantVM } from './rtc-call-participant-tile.vue'
|
||||
import type { CallParticipantVM } from './rtc-call-participant-tile.vue';
|
||||
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { useIntervalFn } from '@vueuse/core'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { Track } from 'livekit-client'
|
||||
import { useIntervalFn } from '@vueuse/core';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { Track } from 'livekit-client';
|
||||
|
||||
import {
|
||||
acceptCall,
|
||||
@@ -13,35 +13,35 @@ import {
|
||||
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'
|
||||
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'
|
||||
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'
|
||||
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' })
|
||||
defineOptions({ name: 'ImRtcCallContainer' });
|
||||
|
||||
const rtcStore = useRtcStore()
|
||||
const lk = useLiveKitRoom()
|
||||
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 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);
|
||||
|
||||
// ==================== 视图模型 ====================
|
||||
|
||||
@@ -50,123 +50,139 @@ const isVideo = computed(() => {
|
||||
const t =
|
||||
rtcStore.call?.mediaType ||
|
||||
rtcStore.incomingPayload?.mediaType ||
|
||||
ImRtcCallMediaType.VOICE
|
||||
return t === ImRtcCallMediaType.VIDEO
|
||||
})
|
||||
ImRtcCallMediaType.VOICE;
|
||||
return t === ImRtcCallMediaType.VIDEO;
|
||||
});
|
||||
|
||||
/** 当前是否群通话;决定浮动窗大小 */
|
||||
const isGroup = computed(
|
||||
() =>
|
||||
(rtcStore.call?.conversationType ??
|
||||
rtcStore.incomingPayload?.conversationType) === ImConversationType.GROUP
|
||||
)
|
||||
rtcStore.incomingPayload?.conversationType) === ImConversationType.GROUP,
|
||||
);
|
||||
|
||||
/** 初始摄像头是否打开;群通话默认全部关闭,进入后用户主动开 */
|
||||
const initialCamera = computed(() => {
|
||||
if (rtcStore.call?.conversationType === ImConversationType.GROUP) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
return isVideo.value
|
||||
})
|
||||
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
|
||||
void lk.screenShareEnabled.value;
|
||||
void lk.cameraEnabled.value;
|
||||
const lp = lk.localParticipant.value;
|
||||
if (!lp) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
return lk.pickStream(lp, Track.Source.ScreenShare) || lk.pickStream(lp, Track.Source.Camera)
|
||||
})
|
||||
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
|
||||
return null;
|
||||
}
|
||||
for (const rp of lk.remoteParticipants.value) {
|
||||
const screen = lk.pickStream(rp, Track.Source.ScreenShare)
|
||||
const screen = lk.pickStream(rp, Track.Source.ScreenShare);
|
||||
if (screen) {
|
||||
return screen
|
||||
return screen;
|
||||
}
|
||||
const camera = lk.pickStream(rp, Track.Source.Camera)
|
||||
const camera = lk.pickStream(rp, Track.Source.Camera);
|
||||
if (camera) {
|
||||
return camera
|
||||
return camera;
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
return null;
|
||||
});
|
||||
|
||||
/** 远端音频流(仅 1v1 用) */
|
||||
const remoteAudioStream = computed<MediaStream | null>(() => {
|
||||
if (isGroup.value) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
for (const rp of lk.remoteParticipants.value) {
|
||||
const stream = lk.pickStream(rp, Track.Source.Microphone)
|
||||
const stream = lk.pickStream(rp, Track.Source.Microphone);
|
||||
if (stream) {
|
||||
return stream
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
return null;
|
||||
});
|
||||
|
||||
/** 群通话网格用:自己 + 远端在房 + 待加入成员;昵称 / 头像走 user.ts helper 自动处理 self / 群成员 / 好友 / 兜底 */
|
||||
const participants = computed<CallParticipantVM[]>(() => {
|
||||
const call = rtcStore.call
|
||||
const call = rtcStore.call;
|
||||
if (!call) {
|
||||
return []
|
||||
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
|
||||
}]
|
||||
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>()
|
||||
const joined = new Set<number>();
|
||||
for (const rp of lk.remoteParticipants.value) {
|
||||
const userId = Number(rp.identity)
|
||||
const userId = Number(rp.identity);
|
||||
if (Number.isNaN(userId)) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
joined.add(userId)
|
||||
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)
|
||||
})
|
||||
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 || []
|
||||
const inviteeIds = call.inviteeIds || [];
|
||||
for (const userId of inviteeIds) {
|
||||
if (userId === myId || joined.has(userId) || rtcStore.isUserLeft(userId)) {
|
||||
continue
|
||||
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,
|
||||
nickname: getSenderDisplayName(
|
||||
userId,
|
||||
ImConversationType.GROUP,
|
||||
targetId,
|
||||
),
|
||||
avatar:
|
||||
getSenderAvatar(userId, ImConversationType.GROUP, targetId) ||
|
||||
undefined,
|
||||
isLocal: false,
|
||||
pending: true
|
||||
})
|
||||
pending: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
return result;
|
||||
});
|
||||
|
||||
// ==================== LiveKit 连接 ====================
|
||||
|
||||
@@ -174,28 +190,31 @@ const participants = computed<CallParticipantVM[]>(() => {
|
||||
async function connectLiveKit(livekitUrl: string, token: string) {
|
||||
// 幂等:lk.connect 内部进入后就把 room.value 赋值;非空表示已经在连接或已连接;stage 多次切换时重复触发也跳过
|
||||
if (lk.room.value || connecting.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
connecting.value = true
|
||||
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 })
|
||||
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()
|
||||
maybeEnterRunning();
|
||||
}
|
||||
} finally {
|
||||
connecting.value = false
|
||||
connecting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 主叫端:从 INVITING 切到 RUNNING;其它阶段不处理 */
|
||||
function maybeEnterRunning() {
|
||||
if (rtcStore.stage === ImRtcCallStage.INVITING && rtcStore.call) {
|
||||
rtcStore.enterRunning(rtcStore.call)
|
||||
rtcStore.enterRunning(rtcStore.call);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,18 +227,22 @@ watch(
|
||||
rtcStore.call?.livekitUrl
|
||||
) {
|
||||
try {
|
||||
await connectLiveKit(rtcStore.call.livekitUrl, rtcStore.call.token)
|
||||
await connectLiveKit(rtcStore.call.livekitUrl, rtcStore.call.token);
|
||||
} catch (error) {
|
||||
console.error('[Call] connect 失败', { room: rtcStore.call?.room }, error)
|
||||
message.error('通话连接失败')
|
||||
await handleCancel()
|
||||
console.error(
|
||||
'[Call] connect 失败',
|
||||
{ room: rtcStore.call?.room },
|
||||
error,
|
||||
);
|
||||
message.error('通话连接失败');
|
||||
await handleCancel();
|
||||
}
|
||||
}
|
||||
if (stage === ImRtcCallStage.IDLE) {
|
||||
await lk.disconnect()
|
||||
await lk.disconnect();
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
/** 被叫端 accept 后会拿到 token;这里监听 stage + token 变化触发连接 */
|
||||
watch(
|
||||
@@ -233,169 +256,179 @@ watch(
|
||||
rtcStore.call?.livekitUrl
|
||||
) {
|
||||
try {
|
||||
await connectLiveKit(rtcStore.call.livekitUrl, token as string)
|
||||
await connectLiveKit(rtcStore.call.livekitUrl, token as string);
|
||||
} catch (error) {
|
||||
console.error('[Call] accept connect 失败', { room: rtcStore.call?.room }, error)
|
||||
message.error('通话连接失败')
|
||||
console.error(
|
||||
'[Call] accept connect 失败',
|
||||
{ room: rtcStore.call?.room },
|
||||
error,
|
||||
);
|
||||
message.error('通话连接失败');
|
||||
// 后端 accept 已写 JOINED;前端连接失败需调 leave 回滚,避免后端记录残留忙线
|
||||
if (rtcStore.call?.room) {
|
||||
leaveCall(rtcStore.call.room).catch(() => undefined)
|
||||
leaveCall(rtcStore.call.room).catch(() => undefined);
|
||||
}
|
||||
rtcStore.reset()
|
||||
rtcStore.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
// ==================== 通话生命周期 ====================
|
||||
|
||||
/** 主叫取消邀请 */
|
||||
async function handleCancel() {
|
||||
if (cancelling.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
cancelling.value = true
|
||||
const room = rtcStore.call?.room
|
||||
cancelling.value = true;
|
||||
const room = rtcStore.call?.room;
|
||||
try {
|
||||
if (room) {
|
||||
await cancelCall(room)
|
||||
await cancelCall(room);
|
||||
}
|
||||
await lk.disconnect()
|
||||
rtcStore.reset()
|
||||
await lk.disconnect();
|
||||
rtcStore.reset();
|
||||
} finally {
|
||||
cancelling.value = false
|
||||
cancelling.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 被叫拒绝来电 */
|
||||
async function handleReject() {
|
||||
if (rejecting.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
rejecting.value = true
|
||||
const payload = rtcStore.incomingPayload
|
||||
rejecting.value = true;
|
||||
const payload = rtcStore.incomingPayload;
|
||||
try {
|
||||
if (payload?.room) {
|
||||
await rejectCall(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()
|
||||
})
|
||||
operatorUserId: getCurrentUserId(),
|
||||
});
|
||||
}
|
||||
rtcStore.reset()
|
||||
rtcStore.reset();
|
||||
} finally {
|
||||
rejecting.value = false
|
||||
rejecting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 被叫接听来电 */
|
||||
async function handleAccept() {
|
||||
if (accepting.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
const payload = rtcStore.incomingPayload
|
||||
if (!payload) return
|
||||
accepting.value = true
|
||||
const payload = rtcStore.incomingPayload;
|
||||
if (!payload) return;
|
||||
accepting.value = true;
|
||||
try {
|
||||
const data = await acceptCall(payload.room)
|
||||
rtcStore.enterRunning(data)
|
||||
const data = await acceptCall(payload.room);
|
||||
rtcStore.enterRunning(data);
|
||||
} finally {
|
||||
accepting.value = false
|
||||
accepting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 通话中挂断 */
|
||||
async function handleHangup() {
|
||||
if (hangingUp.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
hangingUp.value = true
|
||||
const call = rtcStore.call
|
||||
hangingUp.value = true;
|
||||
const call = rtcStore.call;
|
||||
try {
|
||||
if (call?.room) {
|
||||
await leaveCall(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
|
||||
})
|
||||
groupId: call.groupId,
|
||||
});
|
||||
}
|
||||
await lk.disconnect()
|
||||
rtcStore.reset()
|
||||
await lk.disconnect();
|
||||
rtcStore.reset();
|
||||
} finally {
|
||||
hangingUp.value = false
|
||||
hangingUp.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** LiveKit Room 异常断开;多见于网络中断 */
|
||||
function handlePeerDisconnected() {
|
||||
if (!rtcStore.isActive) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
const room = rtcStore.call?.room
|
||||
const room = rtcStore.call?.room;
|
||||
// 给 RTC_CALL_END WebSocket 推送一个小窗口;私聊超时 / 主动挂断等场景下,后端 endSession 会先推 RTC_CALL_END,
|
||||
// 让前端按业务语义("对方未接听" / "已取消" 等)reset,避免错把业务断开 toast 成「通话已断开」
|
||||
setTimeout(() => {
|
||||
if (!rtcStore.isActive) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
// 上报离开房间
|
||||
if (room) {
|
||||
leaveCall(room).catch(() => undefined)
|
||||
leaveCall(room).catch(() => undefined);
|
||||
}
|
||||
// 清理本地通话状态
|
||||
message.warning('通话已断开')
|
||||
rtcStore.reset()
|
||||
}, 100)
|
||||
message.warning('通话已断开');
|
||||
rtcStore.reset();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// ==================== 振铃超时兜底 ====================
|
||||
|
||||
/** 通话存活期间(INVITING / INCOMING / RUNNING)周期性触发后端扫该 room 的超时 INVITING;保持 timer 是为了 inviteCall 追加新人后也能覆盖;阈值由后端配置决定,前端只负责 trigger */
|
||||
const { resume: resumeNoAnswerTimer, pause: pauseNoAnswerTimer } = useIntervalFn(
|
||||
triggerNoAnswerCallCheck, RTC_NO_ANSWER_CALL_CHECK_INTERVAL_MS, { immediate: false }
|
||||
)
|
||||
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 }
|
||||
)
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
/** 本地仍有 pending 才调;INVITING / RUNNING 取 call、INCOMING 取 incomingPayload;接口静默错误 fire-and-forget */
|
||||
function triggerNoAnswerCallCheck() {
|
||||
const source = rtcStore.call ?? rtcStore.incomingPayload
|
||||
const source = rtcStore.call ?? rtcStore.incomingPayload;
|
||||
if (!source?.room || !source.inviteeIds?.length) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
noAnswerCallCheck(source.room).catch(() => undefined)
|
||||
noAnswerCallCheck(source.room).catch(() => undefined);
|
||||
}
|
||||
|
||||
// ==================== 设备控制 ====================
|
||||
|
||||
async function toggleMic() {
|
||||
await lk.setMicEnabled(!lk.micEnabled.value)
|
||||
await lk.setMicEnabled(!lk.micEnabled.value);
|
||||
}
|
||||
async function toggleCamera() {
|
||||
await lk.setCameraEnabled(!lk.cameraEnabled.value)
|
||||
await lk.setCameraEnabled(!lk.cameraEnabled.value);
|
||||
}
|
||||
function toggleSpeaker() {
|
||||
lk.setSpeakerEnabled(!lk.speakerEnabled.value)
|
||||
lk.setSpeakerEnabled(!lk.speakerEnabled.value);
|
||||
}
|
||||
|
||||
/** 切屏幕共享;浏览器弹原生「选择共享内容」对话框,用户取消时会抛错,UI 不弹提示 */
|
||||
async function handleScreenShare() {
|
||||
const enabled = !lk.screenShareEnabled.value
|
||||
const enabled = !lk.screenShareEnabled.value;
|
||||
try {
|
||||
await lk.setScreenShareEnabled(enabled)
|
||||
await lk.setScreenShareEnabled(enabled);
|
||||
} catch (error: any) {
|
||||
// 用户取消选择,不当作错误;其它异常打日志
|
||||
if (error?.name !== 'NotAllowedError' && error?.message !== 'permission denied') {
|
||||
console.warn('[Call] screenShare 切换失败', { enabled }, error)
|
||||
if (
|
||||
error?.name !== 'NotAllowedError' &&
|
||||
error?.message !== 'permission denied'
|
||||
) {
|
||||
console.warn('[Call] screenShare 切换失败', { enabled }, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -404,27 +437,27 @@ async function handleScreenShare() {
|
||||
|
||||
/** 打开「添加成员」弹窗;占位群通话 + 接通中状态才允许 */
|
||||
function openAddMember() {
|
||||
const call = rtcStore.call
|
||||
const call = rtcStore.call;
|
||||
if (!call?.groupId) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
memberPickerRef.value?.open({
|
||||
groupId: call.groupId,
|
||||
mode: 'add',
|
||||
excludeUserIds: participants.value.map((p) => p.userId)
|
||||
})
|
||||
excludeUserIds: participants.value.map((p) => p.userId),
|
||||
});
|
||||
}
|
||||
|
||||
/** picker 选完成员;走 invite 追加邀请接口,后端推 RTC_INVITE 给新成员 */
|
||||
async function handleAddMemberSuccess(userIds: number[]) {
|
||||
const call = rtcStore.call
|
||||
const call = rtcStore.call;
|
||||
if (!call?.room || userIds.length === 0) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
await inviteCall({ room: call.room, inviteeIds: userIds })
|
||||
await inviteCall({ room: call.room, inviteeIds: userIds });
|
||||
// 同步本地 inviteeIds,让新成员立即作为 pending 占位出现在网格里
|
||||
rtcStore.appendInvitees(userIds)
|
||||
message.success('已发送邀请')
|
||||
rtcStore.appendInvitees(userIds);
|
||||
message.success('已发送邀请');
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -484,5 +517,8 @@ async function handleAddMemberSuccess(userIds: number[]) {
|
||||
/>
|
||||
</template>
|
||||
<!-- 通话中「添加成员」选人弹窗;挂在 isActive 外,避免 stage 切换瞬间弹窗被卸载 -->
|
||||
<RtcCallMemberPickerDialog ref="memberPickerRef" @success="handleAddMemberSuccess" />
|
||||
<RtcCallMemberPickerDialog
|
||||
ref="memberPickerRef"
|
||||
@success="handleAddMemberSuccess"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ImRtcCallNotification } from '../../store/rtcStore'
|
||||
import type { ImRtcCallNotification } from '../../store/rtcStore';
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants'
|
||||
import { getDictLabel } from '@vben/hooks'
|
||||
import { IconifyIcon as Icon } from '@vben/icons'
|
||||
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'
|
||||
import { useGroupCallMembers } from '../../composables/useGroupCallMembers';
|
||||
import { UserAvatar } from '../user';
|
||||
|
||||
const props = defineProps<{
|
||||
accepting?: boolean
|
||||
isGroup?: boolean
|
||||
payload: ImRtcCallNotification | null
|
||||
rejecting?: boolean
|
||||
}>()
|
||||
accepting?: boolean;
|
||||
isGroup?: boolean;
|
||||
payload: ImRtcCallNotification | null;
|
||||
rejecting?: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{ accept: []; reject: [] }>()
|
||||
defineEmits<{ accept: []; reject: [] }>();
|
||||
|
||||
/** 来电提示文案;区分语音 / 视频 */
|
||||
const tipText = computed(() => {
|
||||
if (!props.payload) return ''
|
||||
return `邀请你${getDictLabel(DICT_TYPE.IM_RTC_CALL_MEDIA_TYPE, props.payload.mediaType)}通话`
|
||||
})
|
||||
if (!props.payload) return '';
|
||||
return `邀请你${getDictLabel(DICT_TYPE.IM_RTC_CALL_MEDIA_TYPE, props.payload.mediaType)}通话`;
|
||||
});
|
||||
|
||||
/** 接听按钮禁用态 */
|
||||
const acceptDisabled = computed(() => !!props.accepting || !!props.rejecting)
|
||||
const acceptDisabled = computed(() => !!props.accepting || !!props.rejecting);
|
||||
|
||||
/** 拒绝按钮禁用态 */
|
||||
const rejectDisabled = computed(() => !!props.rejecting || !!props.accepting)
|
||||
const rejectDisabled = computed(() => !!props.rejecting || !!props.accepting);
|
||||
|
||||
// 群通话成员;缓存为空时用 INVITE 载荷里的主叫兜底,避免空白
|
||||
const callMembers = useGroupCallMembers(
|
||||
computed(() => (props.isGroup ? props.payload?.groupId : undefined)),
|
||||
computed(() => props.payload?.inviterUserId)
|
||||
)
|
||||
computed(() => props.payload?.inviterUserId),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -62,11 +62,15 @@ const callMembers = useGroupCallMembers(
|
||||
<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="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-sm font-medium truncate">
|
||||
{{ payload?.inviterNickname || '对方' }}
|
||||
</div>
|
||||
<div class="text-13px text-white/60 truncate">{{ tipText }}</div>
|
||||
</template>
|
||||
|
||||
@@ -83,7 +87,9 @@ const callMembers = useGroupCallMembers(
|
||||
radius="4px"
|
||||
:clickable="false"
|
||||
:class="{ 'opacity-50': member.pending }"
|
||||
:title="member.pending ? `${member.nickname}(接入中)` : member.nickname"
|
||||
:title="
|
||||
member.pending ? `${member.nickname}(接入中)` : member.nickname
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -97,7 +103,11 @@ const callMembers = useGroupCallMembers(
|
||||
:disabled="rejectDisabled"
|
||||
@click="$emit('reject')"
|
||||
>
|
||||
<Icon icon="ant-design:phone-outlined" :size="18" class="rotate-[135deg]" />
|
||||
<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"
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
<script lang="ts" setup>
|
||||
import { IconifyIcon as Icon } from '@vben/icons'
|
||||
import { IconifyIcon as Icon } from '@vben/icons';
|
||||
|
||||
import { useMediaStreamElement } from '../../composables/useMediaStreamElement'
|
||||
import { UserAvatar } from '../user'
|
||||
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
|
||||
}>()
|
||||
cameraEnabled: boolean;
|
||||
isGroup?: boolean;
|
||||
isVideo: boolean;
|
||||
localStream?: MediaStream | null; // 本地视频流;视频呼叫预览铺底
|
||||
micEnabled: boolean;
|
||||
peerAvatar?: string;
|
||||
peerNickname?: string;
|
||||
speakerEnabled: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
cancel: []
|
||||
toggleCamera: []
|
||||
toggleMic: []
|
||||
toggleSpeaker: []
|
||||
}>()
|
||||
cancel: [];
|
||||
toggleCamera: [];
|
||||
toggleMic: [];
|
||||
toggleSpeaker: [];
|
||||
}>();
|
||||
|
||||
const setLocalVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localStream)
|
||||
const setLocalVideoRef = useMediaStreamElement<HTMLVideoElement>(
|
||||
() => props.localStream,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -58,7 +60,9 @@ const setLocalVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.loc
|
||||
</div>
|
||||
|
||||
<!-- 底部操作区:麦克风 / 取消 / (摄像头 | 扬声器) -->
|
||||
<div class="flex flex-shrink-0 gap-4 justify-around items-center pt-4 px-5 pb-5">
|
||||
<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')"
|
||||
@@ -66,10 +70,16 @@ const setLocalVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.loc
|
||||
<!-- 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'"
|
||||
:class="
|
||||
micEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'
|
||||
"
|
||||
>
|
||||
<Icon
|
||||
:icon="micEnabled ? 'ant-design:audio-outlined' : 'ant-design:audio-muted-outlined'"
|
||||
:icon="
|
||||
micEnabled
|
||||
? 'ant-design:audio-outlined'
|
||||
: 'ant-design:audio-muted-outlined'
|
||||
"
|
||||
:size="22"
|
||||
/>
|
||||
</span>
|
||||
@@ -84,7 +94,11 @@ const setLocalVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.loc
|
||||
<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]" />
|
||||
<Icon
|
||||
icon="ant-design:phone-outlined"
|
||||
:size="22"
|
||||
class="rotate-[135deg]"
|
||||
/>
|
||||
</span>
|
||||
<span class="text-xs text-white/70 whitespace-nowrap">取消</span>
|
||||
</div>
|
||||
@@ -95,10 +109,16 @@ const setLocalVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.loc
|
||||
>
|
||||
<span
|
||||
class="flex justify-center items-center w-12 h-12 rounded-full"
|
||||
:class="cameraEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'"
|
||||
:class="
|
||||
cameraEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'
|
||||
"
|
||||
>
|
||||
<Icon
|
||||
:icon="cameraEnabled ? 'ant-design:video-camera-outlined' : 'tabler:video-off'"
|
||||
:icon="
|
||||
cameraEnabled
|
||||
? 'ant-design:video-camera-outlined'
|
||||
: 'tabler:video-off'
|
||||
"
|
||||
:size="22"
|
||||
/>
|
||||
</span>
|
||||
@@ -113,10 +133,16 @@ const setLocalVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.loc
|
||||
>
|
||||
<span
|
||||
class="flex justify-center items-center w-12 h-12 rounded-full"
|
||||
:class="speakerEnabled ? 'bg-white text-[#1a1a1c]' : 'bg-white/15 text-white'"
|
||||
:class="
|
||||
speakerEnabled
|
||||
? 'bg-white text-[#1a1a1c]'
|
||||
: 'bg-white/15 text-white'
|
||||
"
|
||||
>
|
||||
<Icon
|
||||
:icon="speakerEnabled ? 'ant-design:sound-outlined' : 'tabler:volume-off'"
|
||||
:icon="
|
||||
speakerEnabled ? 'ant-design:sound-outlined' : 'tabler:volume-off'
|
||||
"
|
||||
:size="22"
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -1,74 +1,78 @@
|
||||
<script lang="ts" setup>
|
||||
import type { GroupMemberLite } from '../group'
|
||||
import type { GroupMemberLite } from '../group';
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Button, Modal } from 'ant-design-vue'
|
||||
import { Button, Modal } from 'ant-design-vue';
|
||||
|
||||
import { getCurrentUserId } from '#/views/im/utils/auth'
|
||||
import { getCurrentUserId } from '#/views/im/utils/auth';
|
||||
|
||||
import { useGroupStore } from '../../store/groupStore'
|
||||
import { GroupMemberPickerPanel } from '../picker'
|
||||
import { useGroupStore } from '../../store/groupStore';
|
||||
import { GroupMemberPickerPanel } from '../picker';
|
||||
|
||||
defineOptions({ name: 'ImRtcCallMemberPickerDialog' })
|
||||
defineOptions({ name: 'ImRtcCallMemberPickerDialog' });
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 选完点完成;携带选中的 userId 列表 */
|
||||
success: [selectedIds: number[]]
|
||||
}>()
|
||||
success: [selectedIds: number[]];
|
||||
}>();
|
||||
|
||||
type PickerMode = 'add' | 'invite'
|
||||
type PickerMode = 'add' | 'invite';
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
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 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' ? '添加成员' : '选择成员'))
|
||||
const title = computed(() => (mode.value === 'add' ? '添加成员' : '选择成员'));
|
||||
|
||||
/** 群成员列表;从 groupStore 现取,map 成 GroupMemberLite */
|
||||
const members = computed<GroupMemberLite[]>(() => {
|
||||
const group = groupStore.getGroup(groupId.value)
|
||||
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
|
||||
}))
|
||||
})
|
||||
role: member.role,
|
||||
}));
|
||||
});
|
||||
|
||||
/** 自己不出现在选项里 */
|
||||
const hideIds = computed<number[]>(() => {
|
||||
const myId = getCurrentUserId()
|
||||
return myId ? [myId] : []
|
||||
})
|
||||
const myId = getCurrentUserId();
|
||||
return myId ? [myId] : [];
|
||||
});
|
||||
|
||||
/** 已在通话内的成员置灰 */
|
||||
const disabledIds = computed<number[]>(() => excludeUserIds.value)
|
||||
const disabledIds = computed<number[]>(() => excludeUserIds.value);
|
||||
|
||||
/** 是否可提交:至少选 1 个 */
|
||||
const canSubmit = computed(() => selectedIds.value.length > 0)
|
||||
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
|
||||
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 方法,用于打开弹窗
|
||||
defineExpose({ open }); // 提供 open 方法,用于打开弹窗
|
||||
|
||||
/** 点完成:emit 选中 ID 列表给父级 + 关闭弹窗;提交按钮 disabled 已保证 selectedIds 非空 */
|
||||
function handleOk() {
|
||||
emit('success', [...selectedIds.value])
|
||||
visible.value = false
|
||||
emit('success', [...selectedIds.value]);
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -94,7 +98,9 @@ function handleOk() {
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button @click="visible = false">取消</Button>
|
||||
<Button type="primary" :disabled="!canSubmit" @click="handleOk">完成</Button>
|
||||
<Button type="primary" :disabled="!canSubmit" @click="handleOk">
|
||||
完成
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
<script lang="ts" setup>
|
||||
import { useMediaStreamElement } from '../../composables/useMediaStreamElement'
|
||||
import { UserAvatar } from '../user'
|
||||
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
|
||||
userId: number;
|
||||
nickname: string;
|
||||
avatar?: string;
|
||||
isLocal: boolean;
|
||||
videoStream?: MediaStream | null;
|
||||
audioStream?: MediaStream | null;
|
||||
/** 等待加入;UI 显示三点动画 */
|
||||
pending?: boolean
|
||||
pending?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
participant: CallParticipantVM
|
||||
participant: CallParticipantVM;
|
||||
/** 扬声器开关;为 false 时静音该格子的远端音频 */
|
||||
speakerEnabled: boolean
|
||||
}>()
|
||||
speakerEnabled: boolean;
|
||||
}>();
|
||||
|
||||
const setVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.participant.videoStream)
|
||||
const setAudioRef = useMediaStreamElement<HTMLAudioElement>(() => props.participant.audioStream)
|
||||
const setVideoRef = useMediaStreamElement<HTMLVideoElement>(
|
||||
() => props.participant.videoStream,
|
||||
);
|
||||
const setAudioRef = useMediaStreamElement<HTMLAudioElement>(
|
||||
() => props.participant.audioStream,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -97,12 +101,14 @@ const setAudioRef = useMediaStreamElement<HTMLAudioElement>(() => props.particip
|
||||
.tile-dot {
|
||||
animation: tile-dot 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes tile-dot {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
40% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -1,40 +1,43 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
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 { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictLabel } from '@vben/hooks';
|
||||
import { IconifyIcon as Icon } from '@vben/icons';
|
||||
|
||||
import { message, Popover } from 'ant-design-vue'
|
||||
import { message, Popover } from 'ant-design-vue';
|
||||
|
||||
import { getActiveCall, joinCall } from '#/api/im/rtc'
|
||||
import { getCurrentUserId } from '#/views/im/utils/auth'
|
||||
import { getActiveCall, joinCall } from '#/api/im/rtc';
|
||||
import { getCurrentUserId } from '#/views/im/utils/auth';
|
||||
|
||||
import { useGroupCallMembers } from '../../composables/useGroupCallMembers'
|
||||
import { useGroupStore } from '../../store/groupStore'
|
||||
import { useRtcStore } from '../../store/rtcStore'
|
||||
import { UserAvatar } from '../user'
|
||||
import { useGroupCallMembers } from '../../composables/useGroupCallMembers';
|
||||
import { useGroupStore } from '../../store/groupStore';
|
||||
import { useRtcStore } from '../../store/rtcStore';
|
||||
import { UserAvatar } from '../user';
|
||||
|
||||
defineOptions({ name: 'ImRtcGroupCallBanner' })
|
||||
defineOptions({ name: 'ImRtcGroupCallBanner' });
|
||||
|
||||
const props = defineProps<{
|
||||
groupId: number
|
||||
}>()
|
||||
groupId: number;
|
||||
}>();
|
||||
|
||||
const rtcStore = useRtcStore()
|
||||
const groupStore = useGroupStore()
|
||||
const rtcStore = useRtcStore();
|
||||
const groupStore = useGroupStore();
|
||||
|
||||
const popoverVisible = ref(false)
|
||||
const popoverVisible = ref(false);
|
||||
|
||||
/** 当前群的活跃通话;rtcStore 维护,参与者加入 / 离开通知增删 joinedUserIds,通话结束移除 */
|
||||
const activeCall = computed(() => rtcStore.getGroupCall(props.groupId))
|
||||
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}通话`
|
||||
})
|
||||
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 变化时拉一次最新参与者列表;
|
||||
@@ -46,104 +49,112 @@ watch(
|
||||
[
|
||||
props.groupId,
|
||||
activeCall.value?.room,
|
||||
groupStore.isGroupActiveCallExpired(props.groupId)
|
||||
groupStore.isGroupActiveCallExpired(props.groupId),
|
||||
] as const,
|
||||
async ([groupId, room], oldValues) => {
|
||||
if (!groupId) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeCall.value) {
|
||||
if (!groupStore.isGroupActiveCallExpired(groupId)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await getActiveCall(groupId)
|
||||
const data = await getActiveCall(groupId);
|
||||
if (data) {
|
||||
rtcStore.setGroupCall(data, true)
|
||||
rtcStore.setGroupCall(data, true);
|
||||
} else {
|
||||
rtcStore.removeGroupCall(groupId)
|
||||
rtcStore.removeGroupCall(groupId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[GroupCallBanner] getActiveCall 失败', { groupId }, error)
|
||||
console.warn(
|
||||
'[GroupCallBanner] getActiveCall 失败',
|
||||
{ groupId },
|
||||
error,
|
||||
);
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// 决策是否需要拉取:补齐本地已有通话;没有本地通话时按群缓存过期状态懒探测一次
|
||||
const groupChanged = !oldValues || oldValues[0] !== groupId
|
||||
const roomChanged = oldValues && oldValues[1] !== room
|
||||
const participantsLoaded = (activeCall.value?.joinedUserIds?.length ?? 0) > 1
|
||||
const activeCallExpired = groupStore.isGroupActiveCallExpired(groupId)
|
||||
const groupChanged = !oldValues || oldValues[0] !== groupId;
|
||||
const roomChanged = oldValues && oldValues[1] !== room;
|
||||
const participantsLoaded =
|
||||
(activeCall.value?.joinedUserIds?.length ?? 0) > 1;
|
||||
const activeCallExpired = groupStore.isGroupActiveCallExpired(groupId);
|
||||
if (
|
||||
!activeCallExpired &&
|
||||
(rtcStore.isGroupCallParticipantsLoaded(groupId, room) ||
|
||||
(!groupChanged && !roomChanged && participantsLoaded))
|
||||
) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// 拉最新参与者写回 store;接口返回空 → 该群已无活跃通话,移除本地缓存
|
||||
try {
|
||||
const data = await getActiveCall(groupId)
|
||||
const data = await getActiveCall(groupId);
|
||||
if (data) {
|
||||
rtcStore.setGroupCall(data, true)
|
||||
rtcStore.setGroupCall(data, true);
|
||||
} else {
|
||||
rtcStore.removeGroupCall(groupId)
|
||||
rtcStore.removeGroupCall(groupId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[GroupCallBanner] getActiveCall 失败', { groupId }, error)
|
||||
console.warn('[GroupCallBanner] getActiveCall 失败', { groupId }, error);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
/** 在通话中的成员(已加入)+ 接入中的成员(已邀请未接通) */
|
||||
const memberList = useGroupCallMembers(computed(() => props.groupId))
|
||||
const memberList = useGroupCallMembers(computed(() => props.groupId));
|
||||
|
||||
/** 本端是否正在该房间通话(处于 INVITING / RUNNING) */
|
||||
const isInThisCall = computed(
|
||||
() => rtcStore.isActive && rtcStore.call?.room === activeCall.value?.room
|
||||
)
|
||||
() => 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
|
||||
})
|
||||
const myId = getCurrentUserId();
|
||||
return activeCall.value?.joinedUserIds?.includes(myId) ?? false;
|
||||
});
|
||||
|
||||
/** 加入按钮禁用:仅在本端实际持有 LiveKit 连接时禁用 */
|
||||
const joinDisabled = computed(() => isInThisCall.value)
|
||||
const joinDisabled = computed(() => isInThisCall.value);
|
||||
|
||||
/** 加入按钮文案;本端连着 → 已在通话中;服务端还残留我但本端断了 → 重新加入;其它 → 加入 */
|
||||
const joinLabel = computed(() => {
|
||||
if (isInThisCall.value) return '已在通话中'
|
||||
if (serverSaysJoined.value) return '重新加入'
|
||||
return '加入'
|
||||
})
|
||||
if (isInThisCall.value) return '已在通话中';
|
||||
if (serverSaysJoined.value) return '重新加入';
|
||||
return '加入';
|
||||
});
|
||||
|
||||
/** 主动加入:调 invite 命中已有 call 拿 token;rtcStore 按 status 自动进 RUNNING */
|
||||
async function handleJoin() {
|
||||
const call = activeCall.value
|
||||
const call = activeCall.value;
|
||||
if (!call || joinDisabled.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (rtcStore.isActive) {
|
||||
message.warning('您正在通话中')
|
||||
return
|
||||
message.warning('您正在通话中');
|
||||
return;
|
||||
}
|
||||
popoverVisible.value = false
|
||||
const data = await joinCall(call.room)
|
||||
rtcStore.startInviting(data)
|
||||
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)]">
|
||||
<div
|
||||
v-if="activeCall"
|
||||
class="flex-shrink-0 px-4 pb-2 bg-[var(--ant-color-fill-secondary)]"
|
||||
>
|
||||
<Popover
|
||||
v-model:open="popoverVisible"
|
||||
placement="bottomLeft"
|
||||
@@ -180,7 +191,11 @@ async function handleJoin() {
|
||||
radius="6px"
|
||||
:clickable="false"
|
||||
:class="{ 'opacity-50': member.pending }"
|
||||
:title="member.pending ? `${member.nickname}(接入中)` : member.nickname"
|
||||
:title="
|
||||
member.pending
|
||||
? `${member.nickname}(接入中)`
|
||||
: member.nickname
|
||||
"
|
||||
/>
|
||||
<!-- 首次填充时房内可能暂时 0 人;加入后由 ParticipantConnected 事件追加 -->
|
||||
<div
|
||||
|
||||
@@ -1,52 +1,54 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { IconifyIcon as Icon } from '@vben/icons'
|
||||
import { useUserStore } from '@vben/stores'
|
||||
import { IconifyIcon as Icon } from '@vben/icons';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import { Badge } from 'ant-design-vue'
|
||||
import { Badge } from 'ant-design-vue';
|
||||
|
||||
import { useConversationStore } from '../store/conversationStore'
|
||||
import { useFriendStore } from '../store/friendStore'
|
||||
import { useImUiStore } from '../store/uiStore'
|
||||
import { UserAvatar } from './user'
|
||||
import { useConversationStore } from '../store/conversationStore';
|
||||
import { useFriendStore } from '../store/friendStore';
|
||||
import { useImUiStore } from '../store/uiStore';
|
||||
import { UserAvatar } from './user';
|
||||
|
||||
defineOptions({ name: 'ImToolBar' })
|
||||
defineOptions({ name: 'ImToolBar' });
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const conversationStore = useConversationStore()
|
||||
const friendStore = useFriendStore()
|
||||
const uiStore = useImUiStore()
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const conversationStore = useConversationStore();
|
||||
const friendStore = useFriendStore();
|
||||
const uiStore = useImUiStore();
|
||||
|
||||
/** 消息 Tab 的红点:所有非免打扰会话的未读总和 */
|
||||
const totalUnread = computed(() => conversationStore.getTotalUnreadCount)
|
||||
const totalUnread = computed(() => conversationStore.getTotalUnreadCount);
|
||||
/** 通讯录 Tab 的红点:未处理好友申请数(接收方=我) */
|
||||
const unhandledRequestCount = computed(() => friendStore.getUnhandledRequestCount)
|
||||
const unhandledRequestCount = computed(
|
||||
() => friendStore.getUnhandledRequestCount,
|
||||
);
|
||||
|
||||
const tabs = [
|
||||
{ name: 'ImHomeConversation', icon: 'ep:chat-round' },
|
||||
{ name: 'ImHomeContact', icon: 'mingcute:contacts-line' }
|
||||
] // 两个主 Tab;用路由 name 而非 path,避免前缀 / 嵌套调整后失效
|
||||
{ name: 'ImHomeContact', icon: 'mingcute:contacts-line' },
|
||||
]; // 两个主 Tab;用路由 name 而非 path,避免前缀 / 嵌套调整后失效
|
||||
|
||||
// 当前路由是否命中 Tab:直接比对 route.name
|
||||
const isActive = (name: string) => route.name === 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()
|
||||
uiStore.requestNextUnreadJump();
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
router.push({ name })
|
||||
}
|
||||
router.push({ name });
|
||||
};
|
||||
|
||||
// 跳转个人中心(路由 name=Profile)
|
||||
const goProfile = () => router.push({ name: 'Profile' })
|
||||
const goProfile = () => router.push({ name: 'Profile' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { FriendLite } from '../types'
|
||||
import type { ComputedRef, Ref } from 'vue';
|
||||
|
||||
import { computed, type ComputedRef, type Ref } from 'vue'
|
||||
import type { FriendLite } from '../types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
/** 字母分桶结果:letter 取 'A'-'Z' 或兜底 '#';list 桶内按拼音 / 名字自然序 */
|
||||
export interface FriendBucket {
|
||||
letter: string
|
||||
list: FriendLite[]
|
||||
letter: string;
|
||||
list: FriendLite[];
|
||||
}
|
||||
|
||||
/** 取分桶 / 排序键:备注拼音优先 → 昵称拼音 → 名字本身(兜底英文 / 数字) */
|
||||
@@ -14,24 +16,24 @@ function getSortKey(friend: FriendLite): string {
|
||||
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() : '#'
|
||||
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 '';
|
||||
}
|
||||
return pinyin
|
||||
.split(' ')
|
||||
.map((word) => word.charAt(0))
|
||||
.join('')
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,19 +46,19 @@ function pinyinInitials(pinyin?: string): string {
|
||||
*/
|
||||
export function useFriendBuckets(
|
||||
friends: ComputedRef<FriendLite[]> | Ref<FriendLite[]>,
|
||||
keyword: Ref<string>
|
||||
keyword: Ref<string>,
|
||||
): {
|
||||
buckets: ComputedRef<FriendBucket[]>
|
||||
filtered: ComputedRef<FriendLite[]>
|
||||
buckets: ComputedRef<FriendBucket[]>;
|
||||
filtered: ComputedRef<FriendLite[]>;
|
||||
} {
|
||||
const filtered = computed(() => {
|
||||
const keywordLower = keyword.value.trim().toLowerCase()
|
||||
const keywordLower = keyword.value.trim().toLowerCase();
|
||||
if (!keywordLower) {
|
||||
return friends.value
|
||||
return friends.value;
|
||||
}
|
||||
return friends.value.filter((friend) => {
|
||||
const nicknamePinyin = friend.nicknamePinyin || ''
|
||||
const displayNamePinyin = friend.displayNamePinyin || ''
|
||||
const nicknamePinyin = friend.nicknamePinyin || '';
|
||||
const displayNamePinyin = friend.displayNamePinyin || '';
|
||||
// 全拼搜索去掉空格,让「laozhang」也能命中「lao zhang」
|
||||
return (
|
||||
(friend.nickname || '').toLowerCase().includes(keywordLower) ||
|
||||
@@ -65,35 +67,35 @@ export function useFriendBuckets(
|
||||
displayNamePinyin.replaceAll(/\s/g, '').includes(keywordLower) ||
|
||||
pinyinInitials(nicknamePinyin).includes(keywordLower) ||
|
||||
pinyinInitials(displayNamePinyin).includes(keywordLower)
|
||||
)
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const buckets = computed<FriendBucket[]>(() => {
|
||||
const map = new Map<string, FriendLite[]>()
|
||||
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 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
|
||||
return 1;
|
||||
}
|
||||
if (b === '#') {
|
||||
return -1
|
||||
return -1;
|
||||
}
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
return letters.map((letter) => ({
|
||||
letter,
|
||||
list: (map.get(letter) ?? []).toSorted((a, b) =>
|
||||
getSortKey(a).localeCompare(getSortKey(b))
|
||||
)
|
||||
}))
|
||||
})
|
||||
getSortKey(a).localeCompare(getSortKey(b)),
|
||||
),
|
||||
}));
|
||||
});
|
||||
|
||||
return { filtered, buckets }
|
||||
return { filtered, buckets };
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { computed, type ComputedRef, type Ref } from 'vue'
|
||||
import type { ComputedRef, Ref } from 'vue';
|
||||
|
||||
import { ImConversationType } from '../../utils/constants'
|
||||
import { getSenderAvatar, getSenderDisplayName } from '../../utils/user'
|
||||
import { useRtcStore } from '../store/rtcStore'
|
||||
import { computed } 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
|
||||
userId: number;
|
||||
nickname: string;
|
||||
avatar?: string;
|
||||
pending: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,33 +23,43 @@ export interface GroupCallMember {
|
||||
*/
|
||||
export function useGroupCallMembers(
|
||||
groupId: Ref<number | undefined>,
|
||||
fallbackInviterId?: Ref<number | undefined>
|
||||
fallbackInviterId?: Ref<number | undefined>,
|
||||
): ComputedRef<GroupCallMember[]> {
|
||||
const rtcStore = useRtcStore()
|
||||
const rtcStore = useRtcStore();
|
||||
return computed(() => {
|
||||
const gid = groupId.value
|
||||
const gid = groupId.value;
|
||||
if (!gid) {
|
||||
return []
|
||||
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))]
|
||||
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)))
|
||||
return orderedIds.map((userId) =>
|
||||
toVM(userId, gid, !joinedSet.has(userId)),
|
||||
);
|
||||
}
|
||||
const fallback = fallbackInviterId?.value
|
||||
return fallback ? [toVM(fallback, gid, false)] : []
|
||||
})
|
||||
const fallback = fallbackInviterId?.value;
|
||||
return fallback ? [toVM(fallback, gid, false)] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** 把 userId 翻译成视图模型,统一走 user.ts helper 解析昵称 / 头像 */
|
||||
function toVM(userId: number, groupId: number, pending: boolean): GroupCallMember {
|
||||
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
|
||||
}
|
||||
avatar:
|
||||
getSenderAvatar(userId, ImConversationType.GROUP, groupId) || undefined,
|
||||
pending,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,59 +1,66 @@
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
import type {
|
||||
LocalParticipant,
|
||||
Participant,
|
||||
RemoteParticipant,
|
||||
} from 'livekit-client';
|
||||
|
||||
import { computed, ref, shallowRef } from 'vue';
|
||||
|
||||
import {
|
||||
ConnectionQuality,
|
||||
type LocalParticipant,
|
||||
type Participant,
|
||||
type RemoteParticipant,
|
||||
Room,
|
||||
RoomEvent,
|
||||
Track,
|
||||
VideoPresets
|
||||
} from 'livekit-client'
|
||||
VideoPresets,
|
||||
} from 'livekit-client';
|
||||
|
||||
type ParticipantEventHandler = (userId: number) => void
|
||||
type ParticipantEventHandler = (userId: number) => void;
|
||||
|
||||
/** LiveKit Room 连接 / 设备 / 事件的薄封装;UI 组件只关心响应式状态 */
|
||||
export function useLiveKitRoom() {
|
||||
/** Room 实例;模块内部状态,不对外暴露,避免调用方误写 */
|
||||
const _room = shallowRef<null | Room>(null)
|
||||
const _room = shallowRef<null | Room>(null);
|
||||
/** 只读 room 引用;调用方仅用于幂等判定 */
|
||||
const room = computed(() => _room.value)
|
||||
const room = computed(() => _room.value);
|
||||
/** 本地参与者;连接成功后赋值 */
|
||||
const localParticipant = shallowRef<LocalParticipant | null>(null)
|
||||
const localParticipant = shallowRef<LocalParticipant | null>(null);
|
||||
/** 远端参与者列表;ParticipantConnected / Disconnected 时刷新;shallowRef 避免 Vue 深度代理 SDK class 内部 */
|
||||
const remoteParticipants = shallowRef<RemoteParticipant[]>([])
|
||||
const remoteParticipants = shallowRef<RemoteParticipant[]>([]);
|
||||
/** 连接状态 */
|
||||
const isConnected = ref(false)
|
||||
const isConnected = ref(false);
|
||||
/** 连接质量 */
|
||||
const connectionQuality = ref<ConnectionQuality>(ConnectionQuality.Unknown)
|
||||
const connectionQuality = ref<ConnectionQuality>(ConnectionQuality.Unknown);
|
||||
/** 麦克风开关 */
|
||||
const micEnabled = ref(true)
|
||||
const micEnabled = ref(true);
|
||||
/** 摄像头开关 */
|
||||
const cameraEnabled = ref(false)
|
||||
const cameraEnabled = ref(false);
|
||||
/** 扬声器开关;浏览器无系统级 API,通过 audio 元素 muted 属性实现远端音频静音 */
|
||||
const speakerEnabled = ref(true)
|
||||
const speakerEnabled = ref(true);
|
||||
/** 屏幕共享开关 */
|
||||
const screenShareEnabled = ref(false)
|
||||
const screenShareEnabled = ref(false);
|
||||
/** 当前是否处于「重连中」;瞬断时 UI 显示提示而不强制结束通话 */
|
||||
const reconnecting = ref(false)
|
||||
const reconnecting = ref(false);
|
||||
/** 远端断开订阅者;通话结束时统一清空 */
|
||||
const disconnectedHandlers = new Set<() => void>()
|
||||
const disconnectedHandlers = new Set<() => void>();
|
||||
/** 房内某人加入订阅者;主叫端用于从 INVITING 切到 RUNNING */
|
||||
const participantConnectedHandlers = new Set<ParticipantEventHandler>()
|
||||
const participantConnectedHandlers = new Set<ParticipantEventHandler>();
|
||||
/** 房内某人离开订阅者;用于把 userId 标记为「已退出」从 pending 占位中移除 */
|
||||
const participantDisconnectedHandlers = new Set<ParticipantEventHandler>()
|
||||
const participantDisconnectedHandlers = new Set<ParticipantEventHandler>();
|
||||
|
||||
/** 同步远端参与者列表到响应式数组 */
|
||||
function syncRemotes(r: Room) {
|
||||
remoteParticipants.value = [...r.remoteParticipants.values()]
|
||||
remoteParticipants.value = [...r.remoteParticipants.values()];
|
||||
}
|
||||
|
||||
/** 连接 LiveKit Server;audio / video 控制初始默认开关 */
|
||||
async function connect(url: string, token: string, opts: { audio?: boolean; video?: boolean }) {
|
||||
async function connect(
|
||||
url: string,
|
||||
token: string,
|
||||
opts: { audio?: boolean; video?: boolean },
|
||||
) {
|
||||
// 新连接前先断开旧 Room;保留本次注册的事件回调
|
||||
if (_room.value) {
|
||||
await disconnectRoom(false)
|
||||
await disconnectRoom(false);
|
||||
}
|
||||
const r = new Room({
|
||||
// 按格子尺寸自动选 simulcast 层
|
||||
@@ -62,43 +69,43 @@ export function useLiveKitRoom() {
|
||||
dynacast: true,
|
||||
// 采集分辨率 720p,确保大格子清晰
|
||||
videoCaptureDefaults: {
|
||||
resolution: VideoPresets.h720.resolution
|
||||
resolution: VideoPresets.h720.resolution,
|
||||
},
|
||||
// 发布编码上限 1.5 Mbps / 30fps;保留默认 simulcast 三层(180p / 360p / 720p)
|
||||
publishDefaults: {
|
||||
videoEncoding: {
|
||||
maxBitrate: 1_500_000,
|
||||
maxFramerate: 30,
|
||||
priority: 'high'
|
||||
priority: 'high',
|
||||
},
|
||||
// 屏幕共享码率 3 Mbps,文字界面清晰
|
||||
screenShareEncoding: {
|
||||
maxBitrate: 3_000_000,
|
||||
maxFramerate: 15,
|
||||
priority: 'medium'
|
||||
}
|
||||
}
|
||||
})
|
||||
_room.value = r
|
||||
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))
|
||||
}
|
||||
})
|
||||
syncRemotes(r);
|
||||
const userId = parseUserId(rp.identity);
|
||||
if (userId !== null) {
|
||||
participantConnectedHandlers.forEach((cb) => cb(userId));
|
||||
}
|
||||
})
|
||||
.on(RoomEvent.ParticipantDisconnected, (rp) => {
|
||||
syncRemotes(r)
|
||||
syncRemotes(r);
|
||||
// 离开的参与者缓存清掉,避免下次同 sid 重连命中失效引用
|
||||
for (const key of streamCache.keys()) {
|
||||
if (key.startsWith(`${rp.sid}:`)) {
|
||||
streamCache.delete(key)
|
||||
streamCache.delete(key);
|
||||
}
|
||||
}
|
||||
const userId = parseUserId(rp.identity)
|
||||
if (userId != null) {
|
||||
participantDisconnectedHandlers.forEach((cb) => cb(userId))
|
||||
const userId = parseUserId(rp.identity);
|
||||
if (userId !== null) {
|
||||
participantDisconnectedHandlers.forEach((cb) => cb(userId));
|
||||
}
|
||||
})
|
||||
.on(RoomEvent.TrackSubscribed, () => syncRemotes(r))
|
||||
@@ -107,69 +114,72 @@ export function useLiveKitRoom() {
|
||||
.on(RoomEvent.TrackMuted, () => syncRemotes(r))
|
||||
.on(RoomEvent.TrackUnmuted, () => syncRemotes(r))
|
||||
.on(RoomEvent.ConnectionQualityChanged, (quality) => {
|
||||
connectionQuality.value = quality
|
||||
connectionQuality.value = quality;
|
||||
})
|
||||
// 瞬断 → 显示「重连中」;不关通话窗,由 SDK 内部重连机制恢复
|
||||
.on(RoomEvent.Reconnecting, () => {
|
||||
reconnecting.value = true
|
||||
reconnecting.value = true;
|
||||
})
|
||||
.on(RoomEvent.Reconnected, () => {
|
||||
reconnecting.value = false
|
||||
reconnecting.value = false;
|
||||
})
|
||||
// 重连失败 / 主动断 / 被踢时触发清理
|
||||
.on(RoomEvent.Disconnected, () => {
|
||||
isConnected.value = false
|
||||
reconnecting.value = false
|
||||
disconnectedHandlers.forEach((cb) => cb())
|
||||
})
|
||||
isConnected.value = false;
|
||||
reconnecting.value = false;
|
||||
disconnectedHandlers.forEach((cb) => cb());
|
||||
});
|
||||
|
||||
// 预热 getUserMedia 与 WebSocket 握手并行,省 100~300ms 串行延迟;
|
||||
// 拿到的 stream 仅用于触发权限弹窗 + 设备就绪,握手完成后由 LiveKit 内部重新请求设备发布轨
|
||||
const warmup = prewarmMedia(opts)
|
||||
const warmup = prewarmMedia(opts);
|
||||
// 建立 WebSocket 信令 + WebRTC 媒体通道;完成后 localParticipant 可用,已在房参与者会通过 ParticipantConnected 事件批量推送
|
||||
await r.connect(url, token)
|
||||
await r.connect(url, token);
|
||||
// 期间被外部 disconnect 替换;中止后续 publish,避免摄像头被重新启用
|
||||
if (_room.value !== r) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
localParticipant.value = r.localParticipant
|
||||
isConnected.value = true
|
||||
localParticipant.value = r.localParticipant;
|
||||
isConnected.value = true;
|
||||
|
||||
// 预热结果不直接发布(避免 SDK 与外部 track 生命周期纠缠),仅等待权限就绪后再走标准 setXxxEnabled
|
||||
await warmup
|
||||
await warmup;
|
||||
if (_room.value !== r) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
// 麦克风与摄像头权限相互独立,并行启用发布
|
||||
const inits: Promise<unknown>[] = []
|
||||
const inits: Promise<unknown>[] = [];
|
||||
if (opts.audio) {
|
||||
inits.push(r.localParticipant.setMicrophoneEnabled(true))
|
||||
inits.push(r.localParticipant.setMicrophoneEnabled(true));
|
||||
}
|
||||
if (opts.video) {
|
||||
inits.push(r.localParticipant.setCameraEnabled(true))
|
||||
inits.push(r.localParticipant.setCameraEnabled(true));
|
||||
}
|
||||
if (inits.length > 0) {
|
||||
await Promise.all(inits)
|
||||
await Promise.all(inits);
|
||||
}
|
||||
micEnabled.value = !!opts.audio
|
||||
cameraEnabled.value = !!opts.video
|
||||
micEnabled.value = !!opts.audio;
|
||||
cameraEnabled.value = !!opts.video;
|
||||
|
||||
// 兜底同步一次远端列表:r.connect 期间 ParticipantConnected 事件可能在 handler 绑定前触发被吞,导致首屏漏人
|
||||
syncRemotes(r)
|
||||
syncRemotes(r);
|
||||
}
|
||||
|
||||
/** 提前触发权限弹窗 + 设备唤起,串行延迟在 r.connect 期间一起跑;失败静默(连接后会再试一次) */
|
||||
async function prewarmMedia(opts: { audio?: boolean; video?: boolean }): Promise<void> {
|
||||
async function prewarmMedia(opts: {
|
||||
audio?: boolean;
|
||||
video?: boolean;
|
||||
}): Promise<void> {
|
||||
if (!opts.audio && !opts.video) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: !!opts.audio,
|
||||
video: !!opts.video
|
||||
})
|
||||
video: !!opts.video,
|
||||
});
|
||||
// 拿到权限即可,立即停掉所有 track 释放设备;正式发布走 SDK 流程重新请求
|
||||
stream.getTracks().forEach((t) => t.stop())
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
} catch {
|
||||
// 用户拒绝 / 设备占用等异常,交给后续 setXxxEnabled 再次尝试报错
|
||||
}
|
||||
@@ -178,24 +188,24 @@ export function useLiveKitRoom() {
|
||||
/** 切麦克风 */
|
||||
async function setMicEnabled(enabled: boolean) {
|
||||
if (!_room.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
await _room.value.localParticipant.setMicrophoneEnabled(enabled)
|
||||
micEnabled.value = enabled
|
||||
await _room.value.localParticipant.setMicrophoneEnabled(enabled);
|
||||
micEnabled.value = enabled;
|
||||
}
|
||||
|
||||
/** 切摄像头 */
|
||||
async function setCameraEnabled(enabled: boolean) {
|
||||
if (!_room.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
await _room.value.localParticipant.setCameraEnabled(enabled)
|
||||
cameraEnabled.value = enabled
|
||||
await _room.value.localParticipant.setCameraEnabled(enabled);
|
||||
cameraEnabled.value = enabled;
|
||||
}
|
||||
|
||||
/** 切扬声器;仅切响应式状态,实际静音由模板上 audio 元素 :muted 绑定生效 */
|
||||
function setSpeakerEnabled(enabled: boolean) {
|
||||
speakerEnabled.value = enabled
|
||||
speakerEnabled.value = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,39 +214,40 @@ export function useLiveKitRoom() {
|
||||
* 浏览器会弹原生「选择共享内容」对话框,用户在弹窗里点取消时 setScreenShareEnabled 会抛错,捕获并把状态复位回 SDK 的实际值
|
||||
*/
|
||||
async function setScreenShareEnabled(enabled: boolean) {
|
||||
if (!_room.value) return
|
||||
if (!_room.value) return;
|
||||
try {
|
||||
await _room.value.localParticipant.setScreenShareEnabled(enabled)
|
||||
screenShareEnabled.value = enabled
|
||||
await _room.value.localParticipant.setScreenShareEnabled(enabled);
|
||||
screenShareEnabled.value = enabled;
|
||||
} catch (error) {
|
||||
// 用户在浏览器原生对话框里取消选择,不当作错误
|
||||
screenShareEnabled.value = _room.value.localParticipant.isScreenShareEnabled
|
||||
throw error
|
||||
screenShareEnabled.value =
|
||||
_room.value.localParticipant.isScreenShareEnabled;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** 注册「远端连接异常断开」回调;返回反注册函数 */
|
||||
function onDisconnected(cb: () => void): () => void {
|
||||
disconnectedHandlers.add(cb)
|
||||
return () => disconnectedHandlers.delete(cb)
|
||||
disconnectedHandlers.add(cb);
|
||||
return () => disconnectedHandlers.delete(cb);
|
||||
}
|
||||
|
||||
/** 注册「房内某人加入」回调;返回反注册函数 */
|
||||
function onParticipantConnected(cb: ParticipantEventHandler): () => void {
|
||||
participantConnectedHandlers.add(cb)
|
||||
return () => participantConnectedHandlers.delete(cb)
|
||||
participantConnectedHandlers.add(cb);
|
||||
return () => participantConnectedHandlers.delete(cb);
|
||||
}
|
||||
|
||||
/** 注册「房内某人离开」回调;返回反注册函数 */
|
||||
function onParticipantDisconnected(cb: ParticipantEventHandler): () => void {
|
||||
participantDisconnectedHandlers.add(cb)
|
||||
return () => participantDisconnectedHandlers.delete(cb)
|
||||
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
|
||||
const id = Number(identity);
|
||||
return Number.isNaN(id) ? null : id;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -244,7 +255,10 @@ export function useLiveKitRoom() {
|
||||
* 同一条 MediaStreamTrack 复用同一个 MediaStream,避免 <video>.srcObject 反复重挂导致解码管线重建(视频闪烁);
|
||||
* track 引用切换(重新订阅 / 切流)时按需新建并替换
|
||||
*/
|
||||
const streamCache = new Map<string, { stream: MediaStream; track: MediaStreamTrack; }>()
|
||||
const streamCache = new Map<
|
||||
string,
|
||||
{ stream: MediaStream; track: MediaStreamTrack }
|
||||
>();
|
||||
|
||||
/**
|
||||
* 取参与者指定来源的轨道并打包为 MediaStream;
|
||||
@@ -252,56 +266,59 @@ export function useLiveKitRoom() {
|
||||
* 命中缓存返回同一 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)
|
||||
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
|
||||
return null;
|
||||
}
|
||||
const track = pub.track?.mediaStreamTrack
|
||||
const track = pub.track?.mediaStreamTrack;
|
||||
if (!track) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
const key = `${p.sid}:${source}`
|
||||
const cached = streamCache.get(key)
|
||||
const key = `${p.sid}:${source}`;
|
||||
const cached = streamCache.get(key);
|
||||
if (cached && cached.track === track) {
|
||||
return cached.stream
|
||||
return cached.stream;
|
||||
}
|
||||
const stream = new MediaStream([track])
|
||||
streamCache.set(key, { track, stream })
|
||||
return 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()
|
||||
disconnectedHandlers.clear();
|
||||
participantConnectedHandlers.clear();
|
||||
participantDisconnectedHandlers.clear();
|
||||
}
|
||||
// 清理音视频轨道缓存
|
||||
streamCache.clear()
|
||||
streamCache.clear();
|
||||
if (_room.value) {
|
||||
// 卸载 Room 事件并断开连接
|
||||
_room.value.removeAllListeners()
|
||||
await _room.value.disconnect()
|
||||
_room.value = null
|
||||
_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
|
||||
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)
|
||||
await disconnectRoom(true);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -324,8 +341,8 @@ export function useLiveKitRoom() {
|
||||
pickStream,
|
||||
onDisconnected,
|
||||
onParticipantConnected,
|
||||
onParticipantDisconnected
|
||||
}
|
||||
onParticipantDisconnected,
|
||||
};
|
||||
}
|
||||
|
||||
export type ImLiveKitRoom = ReturnType<typeof useLiveKitRoom>
|
||||
export type ImLiveKitRoom = ReturnType<typeof useLiveKitRoom>;
|
||||
|
||||
@@ -1,40 +1,42 @@
|
||||
import { type VNodeRef, watch } from 'vue'
|
||||
import type { VNodeRef } from 'vue';
|
||||
|
||||
import { watch } from 'vue';
|
||||
|
||||
/**
|
||||
* 把响应式 MediaStream 挂到 `<video>` / `<audio>` 元素的 srcObject 上;
|
||||
* stream 为空时清掉 srcObject,避免设备关闭后画面卡在最后一帧
|
||||
*/
|
||||
export function useMediaStreamElement<T extends HTMLMediaElement>(
|
||||
streamSource: () => MediaStream | null | undefined
|
||||
streamSource: () => MediaStream | null | undefined,
|
||||
): VNodeRef {
|
||||
let el: T | null = null
|
||||
let currentStream: MediaStream | null | undefined
|
||||
let el: null | T = null;
|
||||
let currentStream: MediaStream | null | undefined;
|
||||
|
||||
const syncStream = () => {
|
||||
if (el) {
|
||||
el.srcObject = currentStream || null
|
||||
el.srcObject = currentStream || null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
streamSource,
|
||||
(stream) => {
|
||||
currentStream = stream
|
||||
syncStream()
|
||||
currentStream = stream;
|
||||
syncStream();
|
||||
},
|
||||
{ flush: 'post', immediate: true }
|
||||
)
|
||||
{ flush: 'post', immediate: true },
|
||||
);
|
||||
|
||||
return (value) => {
|
||||
if (value instanceof HTMLMediaElement) {
|
||||
el = value as T
|
||||
syncStream()
|
||||
return
|
||||
el = value as T;
|
||||
syncStream();
|
||||
return;
|
||||
}
|
||||
|
||||
if (el) {
|
||||
el.srcObject = null
|
||||
el.srcObject = null;
|
||||
}
|
||||
el = null
|
||||
}
|
||||
el = null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,43 +1,49 @@
|
||||
import type { Conversation, Message } from '../types'
|
||||
import type {
|
||||
AudioMessage,
|
||||
FileMessage,
|
||||
ImageMessage,
|
||||
QuoteMessage,
|
||||
VideoMessage,
|
||||
} from '../../utils/message';
|
||||
import type { Conversation, Message } from '../types';
|
||||
|
||||
import type { AxiosProgressEvent } from '#/api/infra/file'
|
||||
import type { AxiosProgressEvent } from '#/api/infra/file';
|
||||
|
||||
import { isOpenableUrl } from '@vben/utils'
|
||||
import { isOpenableUrl } from '@vben/utils';
|
||||
|
||||
import { message } from 'ant-design-vue'
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { uploadFile } from '#/api/infra/file'
|
||||
import { getCurrentUserId } from '#/views/im/utils/auth'
|
||||
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'
|
||||
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'
|
||||
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]
|
||||
type UploadProgressEvent = Parameters<NonNullable<AxiosProgressEvent>>[0];
|
||||
|
||||
/** 单条媒体 payload 联合(覆盖 IMAGE / FILE / VOICE / VIDEO 四种) */
|
||||
export type MediaPayload = AudioMessage | FileMessage | ImageMessage | VideoMessage
|
||||
export type MediaPayload =
|
||||
| AudioMessage
|
||||
| FileMessage
|
||||
| ImageMessage
|
||||
| VideoMessage;
|
||||
|
||||
/**
|
||||
* 媒体特定的元数据上下文:首发 / 重传共用入参;不同 type 关心不同字段
|
||||
@@ -47,18 +53,18 @@ export type MediaPayload = AudioMessage | FileMessage | ImageMessage | VideoMess
|
||||
* - videoCoverUrl:视频封面真实 URL;占位阶段不设(避免传 blob 当 poster 在部分浏览器退化),commit 阶段由 cover 上传结果填入;重传时从旧 VideoMessage.coverUrl 复用,旧值若是 blob 会被跳过
|
||||
*/
|
||||
export interface MediaTypeContext {
|
||||
voiceDuration?: number
|
||||
videoProbe?: { duration?: number; height?: number; width?: number; }
|
||||
videoCoverUrl?: string
|
||||
voiceDuration?: number;
|
||||
videoProbe?: { duration?: number; height?: number; width?: number };
|
||||
videoCoverUrl?: string;
|
||||
}
|
||||
|
||||
export interface MediaTypeHandler {
|
||||
/** 中文名,仅日志用(替代之前散落 9 处的 kind 字符串) */
|
||||
kind: string
|
||||
kind: string;
|
||||
/** 由 file + url + context 生成 payload;占位时 url 是 blob URL,commit 时是真实 url */
|
||||
build: (file: File, url: string, context: MediaTypeContext) => MediaPayload
|
||||
build: (file: File, url: string, context: MediaTypeContext) => MediaPayload;
|
||||
/** 重传场景:从旧 content 提取 context(让重传不需要重做 probe / 重录语音) */
|
||||
extractResendContext: (oldContent: string) => MediaTypeContext
|
||||
extractResendContext: (oldContent: string) => MediaTypeContext;
|
||||
}
|
||||
|
||||
/** 媒体类型注册表:image / file / voice / video 各自的 kind + 首发 / 重传共用的 build / extract */
|
||||
@@ -66,20 +72,22 @@ export const mediaTypeHandlers: Partial<Record<number, MediaTypeHandler>> = {
|
||||
[ImContentType.IMAGE]: {
|
||||
kind: '图片',
|
||||
build: (_file, url) => ({ url }) as ImageMessage,
|
||||
extractResendContext: () => ({})
|
||||
extractResendContext: () => ({}),
|
||||
},
|
||||
[ImContentType.FILE]: {
|
||||
kind: '文件',
|
||||
build: (file, url) => ({ url, name: file.name, size: file.size }) as FileMessage,
|
||||
extractResendContext: () => ({})
|
||||
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,
|
||||
build: (_file, url, context) =>
|
||||
({ url, duration: context.voiceDuration ?? 0 }) as AudioMessage,
|
||||
extractResendContext: (oldContent) => {
|
||||
const old = parseMessage<AudioMessage>(oldContent)
|
||||
return { voiceDuration: old?.duration ?? 0 }
|
||||
}
|
||||
const old = parseMessage<AudioMessage>(oldContent);
|
||||
return { voiceDuration: old?.duration ?? 0 };
|
||||
},
|
||||
},
|
||||
[ImContentType.VIDEO]: {
|
||||
kind: '视频',
|
||||
@@ -90,34 +98,40 @@ export const mediaTypeHandlers: Partial<Record<number, MediaTypeHandler>> = {
|
||||
duration: context.videoProbe?.duration,
|
||||
width: context.videoProbe?.width,
|
||||
height: context.videoProbe?.height,
|
||||
size: file.size
|
||||
size: file.size,
|
||||
}) as VideoMessage,
|
||||
extractResendContext: (oldContent) => {
|
||||
const old = parseMessage<VideoMessage>(oldContent)
|
||||
const old = parseMessage<VideoMessage>(oldContent);
|
||||
// 旧 coverUrl 是 blob 说明上传期失败(cover 没传成功),不复用;真实 URL 直接复用,省一次封面上传
|
||||
const reuseCover =
|
||||
old?.coverUrl && !old.coverUrl.startsWith(BLOB_URL_PREFIX) ? old.coverUrl : undefined
|
||||
old?.coverUrl && !old.coverUrl.startsWith(BLOB_URL_PREFIX)
|
||||
? old.coverUrl
|
||||
: undefined;
|
||||
return {
|
||||
videoProbe: { duration: old?.duration, width: old?.width, height: old?.height },
|
||||
videoCoverUrl: reuseCover
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
videoProbe: {
|
||||
duration: old?.duration,
|
||||
width: old?.width,
|
||||
height: old?.height,
|
||||
},
|
||||
videoCoverUrl: reuseCover,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** 单次媒体上传的入参(image / file / voice 走 uploadAndSendMedia;video 走低层 helper 自行组装) */
|
||||
export interface UploadAndSendMediaOptions {
|
||||
file: File
|
||||
file: File;
|
||||
/** 对齐 ImContentType;mediaTypeHandlers 必须有对应项 */
|
||||
type: number
|
||||
type: number;
|
||||
/** 媒体特定的元数据(如语音时长 / 视频元信息);不传按空对象处理 */
|
||||
context?: MediaTypeContext
|
||||
context?: MediaTypeContext;
|
||||
/** 引用消息(若有),写进 payload.quote */
|
||||
quote?: QuoteMessage
|
||||
quote?: QuoteMessage;
|
||||
/** 锁定起始会话,上传期间会话切走则放弃发送 */
|
||||
conversation: Conversation
|
||||
conversation: Conversation;
|
||||
/** 重试已有占位消息时复用的客户端消息编号 */
|
||||
existingClientMessageId?: string
|
||||
existingClientMessageId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,19 +149,19 @@ export interface UploadAndSendMediaOptions {
|
||||
function resolveMediaMaxMb(type: number): number {
|
||||
switch (type) {
|
||||
case ImContentType.FILE: {
|
||||
return MESSAGE_FILE_MAX_MB
|
||||
return MESSAGE_FILE_MAX_MB;
|
||||
}
|
||||
case ImContentType.IMAGE: {
|
||||
return MESSAGE_IMAGE_MAX_MB
|
||||
return MESSAGE_IMAGE_MAX_MB;
|
||||
}
|
||||
case ImContentType.VIDEO: {
|
||||
return MESSAGE_VIDEO_MAX_MB
|
||||
return MESSAGE_VIDEO_MAX_MB;
|
||||
}
|
||||
case ImContentType.VOICE: {
|
||||
return MESSAGE_VOICE_MAX_MB
|
||||
return MESSAGE_VOICE_MAX_MB;
|
||||
}
|
||||
default: {
|
||||
return 0
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,21 +170,21 @@ function resolveMediaMaxMb(type: number): number {
|
||||
export function ensureMediaSizeWithinLimit(
|
||||
file: File,
|
||||
type: number,
|
||||
warn: (text: string) => void
|
||||
warn: (text: string) => void,
|
||||
): boolean {
|
||||
const maxMb = resolveMediaMaxMb(type)
|
||||
const maxMb = resolveMediaMaxMb(type);
|
||||
if (maxMb && file.size > maxMb * 1024 * 1024) {
|
||||
warn(`文件大小超过上限 ${maxMb}MB,请压缩后再发`)
|
||||
return false
|
||||
warn(`文件大小超过上限 ${maxMb}MB,请压缩后再发`);
|
||||
return false;
|
||||
}
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
|
||||
export const useMediaUploader = () => {
|
||||
const conversationStore = useConversationStore()
|
||||
const messageStore = useMessageStore()
|
||||
const muteOverlay = useMuteOverlay()
|
||||
const { sendRaw } = useMessageSender()
|
||||
const conversationStore = useConversationStore();
|
||||
const messageStore = useMessageStore();
|
||||
const muteOverlay = useMuteOverlay();
|
||||
const { sendRaw } = useMessageSender();
|
||||
|
||||
/**
|
||||
* 立即写入媒体占位消息(低层 helper;image/file/voice 走 uploadAndSendMedia 包装,video 直接用本函数)
|
||||
@@ -179,23 +193,29 @@ export const useMediaUploader = () => {
|
||||
* 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()
|
||||
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 }
|
||||
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,
|
||||
@@ -207,19 +227,21 @@ export const useMediaUploader = () => {
|
||||
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 }
|
||||
}
|
||||
_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(上传失败 / 会话切走 / 禁言期到点 等场景统一收尾)
|
||||
@@ -230,13 +252,13 @@ export const useMediaUploader = () => {
|
||||
const markMediaFailed = (
|
||||
conversationType: number,
|
||||
targetId: number,
|
||||
clientMessageId: string
|
||||
clientMessageId: string,
|
||||
): void => {
|
||||
messageStore.patchMessage(conversationType, targetId, clientMessageId, {
|
||||
status: ImMessageStatus.FAILED,
|
||||
uploadProgress: undefined
|
||||
})
|
||||
}
|
||||
uploadProgress: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成 axios `onUploadProgress` 回调:用 closure 缓存上次百分比,未变化直接 return 不进 store
|
||||
@@ -244,25 +266,34 @@ export const useMediaUploader = () => {
|
||||
* XHR onProgress 大文件下每秒触发 10-50 次,但 Math.round 后百分比有大量重复(一秒内可能十几次同一个数字);
|
||||
* 在源头去重,能省掉 store 的 find + Object.assign + Vue reactivity 触发链
|
||||
*/
|
||||
const createUploadProgressHandler = (conversation: Conversation, clientMessageId: string) => {
|
||||
let lastPercent = -1
|
||||
const createUploadProgressHandler = (
|
||||
conversation: Conversation,
|
||||
clientMessageId: string,
|
||||
) => {
|
||||
let lastPercent = -1;
|
||||
return (event: UploadProgressEvent): void => {
|
||||
if (!event.total) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
const percent = Math.round((event.loaded / event.total) * 100)
|
||||
const percent = Math.round((event.loaded / event.total) * 100);
|
||||
if (percent === lastPercent) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
lastPercent = percent
|
||||
messageStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, {
|
||||
uploadProgress: percent
|
||||
})
|
||||
}
|
||||
}
|
||||
lastPercent = percent;
|
||||
messageStore.patchMessage(
|
||||
conversation.type,
|
||||
conversation.targetId,
|
||||
clientMessageId,
|
||||
{
|
||||
uploadProgress: percent,
|
||||
},
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
/** 取媒体类型中文名(仅日志用);未注册 type 退化为通用「媒体」 */
|
||||
const getMediaKind = (type: number): string => mediaTypeHandlers[type]?.kind ?? '媒体'
|
||||
const getMediaKind = (type: number): string =>
|
||||
mediaTypeHandlers[type]?.kind ?? '媒体';
|
||||
|
||||
/**
|
||||
* 按 type 取 handler,缺则抛错(程序错误集中在这一处)
|
||||
@@ -271,12 +302,12 @@ export const useMediaUploader = () => {
|
||||
* 仅给「确定 type 在表里」的调用方用 —— image/file/voice/video 四类入口;通用 dispatcher 仍可用 `mediaTypeHandlers[type]?.` optional chain
|
||||
*/
|
||||
const requireMediaHandler = (type: number): MediaTypeHandler => {
|
||||
const handler = mediaTypeHandlers[type]
|
||||
const handler = mediaTypeHandlers[type];
|
||||
if (!handler) {
|
||||
throw new Error(`[IM] 未注册的媒体类型 ${type}`)
|
||||
throw new Error(`[IM] 未注册的媒体类型 ${type}`);
|
||||
}
|
||||
return handler
|
||||
}
|
||||
return handler;
|
||||
};
|
||||
|
||||
/**
|
||||
* 上传完成后的收口校验:会话仍是占位时锁定的那个 + 当前未被禁言;任一不满足 markMediaFailed + 返回 false
|
||||
@@ -287,21 +318,36 @@ export const useMediaUploader = () => {
|
||||
conversation: Conversation,
|
||||
startKey: string,
|
||||
type: number,
|
||||
clientMessageId: string
|
||||
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
|
||||
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
|
||||
console.warn(`[IM] ${getMediaKind(type)}上传期间被禁言,放弃发送`, {
|
||||
startKey,
|
||||
});
|
||||
markMediaFailed(
|
||||
conversation.type,
|
||||
conversation.targetId,
|
||||
clientMessageId,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 占位完成后用真实 url 替换 content,再走 sendRaw 完成发送
|
||||
@@ -309,46 +355,52 @@ export const useMediaUploader = () => {
|
||||
* 上传成功 → patch content → sendRaw 复用 existingClientMessageId;store 内部 revoke 旧 blob URL
|
||||
*/
|
||||
const commitMediaPlaceholder = async (opts: {
|
||||
clientMessageId: string
|
||||
conversation: Conversation
|
||||
realContent: string
|
||||
type: number
|
||||
clientMessageId: string;
|
||||
conversation: Conversation;
|
||||
realContent: string;
|
||||
type: number;
|
||||
}): Promise<void> => {
|
||||
messageStore.patchMessage(
|
||||
opts.conversation.type,
|
||||
opts.conversation.targetId,
|
||||
opts.clientMessageId,
|
||||
{ content: opts.realContent }
|
||||
)
|
||||
{ content: opts.realContent },
|
||||
);
|
||||
// 显式传 conversation 而非依赖 sendRaw 内部取 active:
|
||||
// verifyMediaUploadStillAllowed 与 sendRaw 之间存在微秒窗口,期间用户切会话也能保证发到原会话
|
||||
await sendRaw(opts.type, opts.realContent, {
|
||||
existingClientMessageId: opts.clientMessageId,
|
||||
targetId: opts.conversation.targetId,
|
||||
conversation: opts.conversation
|
||||
})
|
||||
}
|
||||
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]
|
||||
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 ''
|
||||
console.warn('[IM] uploadAndSendMedia 收到未注册的媒体类型', {
|
||||
type: opts.type,
|
||||
});
|
||||
return '';
|
||||
}
|
||||
// 体积上限拦截:大文件浏览器内截帧 / 解码可致 OOM;超限直接 warning,不进入占位 / 上传链路
|
||||
if (!ensureMediaSizeWithinLimit(opts.file, opts.type, message.warning)) {
|
||||
return ''
|
||||
return '';
|
||||
}
|
||||
const startKey = getConversationKey(conversation)
|
||||
const context = opts.context ?? {}
|
||||
const startKey = getConversationKey(conversation);
|
||||
const context = opts.context ?? {};
|
||||
const buildContent = (url: string): string =>
|
||||
serializeMessage(withQuotePayload(handler.build(opts.file, url, context), opts.quote))
|
||||
serializeMessage(
|
||||
withQuotePayload(handler.build(opts.file, url, context), opts.quote),
|
||||
);
|
||||
|
||||
// 1. 立即占位
|
||||
const { clientMessageId } = insertMediaPlaceholder({
|
||||
@@ -356,33 +408,48 @@ export const useMediaUploader = () => {
|
||||
type: opts.type,
|
||||
conversation,
|
||||
buildContent,
|
||||
existingClientMessageId: opts.existingClientMessageId
|
||||
})
|
||||
existingClientMessageId: opts.existingClientMessageId,
|
||||
});
|
||||
|
||||
// 2. 上传:进度回调 patch uploadProgress;失败保留 _localFile 供重试
|
||||
let url: string | undefined
|
||||
let url: string | undefined;
|
||||
try {
|
||||
url = await uploadFile(
|
||||
{ file: opts.file },
|
||||
createUploadProgressHandler(conversation, clientMessageId)
|
||||
)
|
||||
createUploadProgressHandler(conversation, clientMessageId),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`[IM] ${handler.kind}上传失败`, error)
|
||||
console.error(`[IM] ${handler.kind}上传失败`, error);
|
||||
}
|
||||
if (!url) {
|
||||
markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
|
||||
return clientMessageId
|
||||
markMediaFailed(
|
||||
conversation.type,
|
||||
conversation.targetId,
|
||||
clientMessageId,
|
||||
);
|
||||
return clientMessageId;
|
||||
}
|
||||
if (!isOpenableUrl(url)) {
|
||||
console.warn(`[IM] ${handler.kind}上传返回了不支持打开的 URL`, { url })
|
||||
message.warning('上传返回的文件地址不支持打开')
|
||||
markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
|
||||
return clientMessageId
|
||||
console.warn(`[IM] ${handler.kind}上传返回了不支持打开的 URL`, { url });
|
||||
message.warning('上传返回的文件地址不支持打开');
|
||||
markMediaFailed(
|
||||
conversation.type,
|
||||
conversation.targetId,
|
||||
clientMessageId,
|
||||
);
|
||||
return clientMessageId;
|
||||
}
|
||||
|
||||
// 3. 上传期间会话切换 / 用户登出 / 被禁言:任一情况都放弃发送,占位置 FAILED
|
||||
if (!verifyMediaUploadStillAllowed(conversation, startKey, opts.type, clientMessageId)) {
|
||||
return clientMessageId
|
||||
if (
|
||||
!verifyMediaUploadStillAllowed(
|
||||
conversation,
|
||||
startKey,
|
||||
opts.type,
|
||||
clientMessageId,
|
||||
)
|
||||
) {
|
||||
return clientMessageId;
|
||||
}
|
||||
|
||||
// 4. patch content + sendRaw 收尾
|
||||
@@ -390,10 +457,10 @@ export const useMediaUploader = () => {
|
||||
type: opts.type,
|
||||
conversation,
|
||||
clientMessageId,
|
||||
realContent: buildContent(url)
|
||||
})
|
||||
return clientMessageId
|
||||
}
|
||||
realContent: buildContent(url),
|
||||
});
|
||||
return clientMessageId;
|
||||
};
|
||||
|
||||
return {
|
||||
uploadAndSendMedia,
|
||||
@@ -403,6 +470,6 @@ export const useMediaUploader = () => {
|
||||
createUploadProgressHandler,
|
||||
verifyMediaUploadStillAllowed,
|
||||
getMediaKind,
|
||||
requireMediaHandler
|
||||
}
|
||||
}
|
||||
requireMediaHandler,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Message } from '../types'
|
||||
import type { Message } from '../types';
|
||||
|
||||
import { computed, reactive } from 'vue'
|
||||
import { computed, reactive } from 'vue';
|
||||
|
||||
/**
|
||||
* 消息多选模式
|
||||
@@ -14,35 +14,37 @@ import { computed, reactive } from 'vue'
|
||||
const state = reactive({
|
||||
active: false,
|
||||
/** 已选 clientMessageId 列表,按选中顺序保序 */
|
||||
selectedClientMessageIds: [] as string[]
|
||||
})
|
||||
selectedClientMessageIds: [] as string[],
|
||||
});
|
||||
|
||||
/** 已选 clientMessageId 集合;MessageItem 大量 has 查询走它,避免 array.includes O(N²) */
|
||||
const selectedIdSet = computed(() => new Set(state.selectedClientMessageIds))
|
||||
const selectedIdSet = computed(() => new Set(state.selectedClientMessageIds));
|
||||
|
||||
/** 进入多选模式,可附带初始勾选项 */
|
||||
function enter(initialMessage?: Message) {
|
||||
state.active = true
|
||||
state.selectedClientMessageIds = initialMessage ? [initialMessage.clientMessageId] : []
|
||||
state.active = true;
|
||||
state.selectedClientMessageIds = initialMessage
|
||||
? [initialMessage.clientMessageId]
|
||||
: [];
|
||||
}
|
||||
|
||||
/** 退出多选模式 */
|
||||
function exit() {
|
||||
state.active = false
|
||||
state.selectedClientMessageIds = []
|
||||
state.active = false;
|
||||
state.selectedClientMessageIds = [];
|
||||
}
|
||||
|
||||
/** 切换某条消息的选中态 */
|
||||
function toggle(message: Message) {
|
||||
const ids = state.selectedClientMessageIds
|
||||
const index = ids.indexOf(message.clientMessageId)
|
||||
const ids = state.selectedClientMessageIds;
|
||||
const index = ids.indexOf(message.clientMessageId);
|
||||
if (index === -1) {
|
||||
ids.push(message.clientMessageId)
|
||||
ids.push(message.clientMessageId);
|
||||
} else {
|
||||
ids.splice(index, 1)
|
||||
ids.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
export function useMessageMultiSelect() {
|
||||
return { state, selectedIdSet, enter, exit, toggle }
|
||||
return { state, selectedIdSet, enter, exit, toggle };
|
||||
}
|
||||
|
||||
@@ -1,42 +1,52 @@
|
||||
import type { Message } from '../types'
|
||||
import type { PulledMessage } from '../store/messageStore';
|
||||
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 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 { watch } from 'vue';
|
||||
|
||||
import { pullChannelMessageList as apiPullChannelMessageList } from '#/api/im/message/channel'
|
||||
import { pullGroupMessageList as apiPullGroupMessageList } from '#/api/im/message/group'
|
||||
import { getPrivateMaxReadMessageId as apiGetPrivateMaxReadMessageId, pullPrivateMessageList as apiPullPrivateMessageList } from '#/api/im/message/private'
|
||||
import { getCurrentUserId } from '#/views/im/utils/auth'
|
||||
import { pullChannelMessageList as apiPullChannelMessageList } from '#/api/im/message/channel';
|
||||
import { pullGroupMessageList as apiPullGroupMessageList } from '#/api/im/message/group';
|
||||
import {
|
||||
getPrivateMaxReadMessageId as apiGetPrivateMaxReadMessageId,
|
||||
pullPrivateMessageList as apiPullPrivateMessageList,
|
||||
} from '#/api/im/message/private';
|
||||
import { getCurrentUserId } from '#/views/im/utils/auth';
|
||||
|
||||
import { buildChannelConversationStub } from '../../utils/channel'
|
||||
import { buildChannelConversationStub } from '../../utils/channel';
|
||||
import {
|
||||
MESSAGE_GROUP_PULL_SIZE,
|
||||
MESSAGE_PRIVATE_PULL_SIZE,
|
||||
MESSAGE_PRIVATE_READ_ENABLED
|
||||
} from '../../utils/config'
|
||||
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 { useRtcStore } from '../store/rtcStore'
|
||||
import { useImWebSocketStore } from '../store/websocketStore'
|
||||
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 { useMessageStore } from '../store/messageStore';
|
||||
import { useRtcStore } from '../store/rtcStore';
|
||||
import { useImWebSocketStore } from '../store/websocketStore';
|
||||
|
||||
/** 三类消息 pull 接口返回的原始 VO 联合类型;runMinIdPull 只需 id 推进游标,具体分发在 applyPage 内按类型 cast */
|
||||
type PulledRawMessage = ImChannelMessageApi.ChannelMessageRespVO | ImGroupMessageApi.GroupMessageRespVO | ImPrivateMessageApi.PrivateMessageRespVO
|
||||
type PulledRawMessage =
|
||||
| ImChannelMessageApi.ChannelMessageRespVO
|
||||
| ImGroupMessageApi.GroupMessageRespVO
|
||||
| ImPrivateMessageApi.PrivateMessageRespVO;
|
||||
|
||||
/**
|
||||
* 消息增量拉取:登录后分页拉取离线期间的新消息
|
||||
@@ -50,31 +60,34 @@ type PulledRawMessage = ImChannelMessageApi.ChannelMessageRespVO | ImGroupMessag
|
||||
* 4. WebSocket 重连后会再触发一次拉取,补齐断网期间错过的消息
|
||||
*/
|
||||
export const useMessagePuller = () => {
|
||||
const conversationStore = useConversationStore()
|
||||
const messageStore = useMessageStore()
|
||||
const wsStore = useImWebSocketStore()
|
||||
const friendStore = useFriendStore()
|
||||
const groupStore = useGroupStore()
|
||||
const groupRequestStore = useGroupRequestStore()
|
||||
const rtcStore = useRtcStore()
|
||||
const currentUserId = getCurrentUserId()
|
||||
const conversationStore = useConversationStore();
|
||||
const messageStore = useMessageStore();
|
||||
const wsStore = useImWebSocketStore();
|
||||
const friendStore = useFriendStore();
|
||||
const groupStore = useGroupStore();
|
||||
const groupRequestStore = useGroupRequestStore();
|
||||
const rtcStore = useRtcStore();
|
||||
const currentUserId = getCurrentUserId();
|
||||
|
||||
/** 判断请求是否被主动取消 */
|
||||
const isAbortError = (e: unknown): boolean => {
|
||||
const error = e as { code?: string; message?: string; name?: string; }
|
||||
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)
|
||||
const getPrivatePeerId = (
|
||||
message: ImPrivateMessageApi.PrivateMessageRespVO,
|
||||
) => getPrivateMessagePeerId(message, currentUserId);
|
||||
|
||||
/** 服务端私聊消息 -> 本地 Message:targetId 是会话主键(对端 userId) */
|
||||
const convertPrivateMessage = (message: ImPrivateMessageApi.PrivateMessageRespVO): Message => {
|
||||
const convertPrivateMessage = (
|
||||
message: ImPrivateMessageApi.PrivateMessageRespVO,
|
||||
): Message => {
|
||||
return {
|
||||
id: message.id,
|
||||
clientMessageId: message.clientMessageId || generateClientMessageId(),
|
||||
@@ -85,12 +98,14 @@ export const useMessagePuller = () => {
|
||||
sendTime: new Date(message.sendTime).getTime(),
|
||||
senderId: message.senderId,
|
||||
targetId: getPrivatePeerId(message),
|
||||
selfSend: message.senderId === currentUserId
|
||||
}
|
||||
}
|
||||
selfSend: message.senderId === currentUserId,
|
||||
};
|
||||
};
|
||||
|
||||
/** 服务端群聊消息 -> 本地 Message */
|
||||
const convertGroupMessage = (message: ImGroupMessageApi.GroupMessageRespVO): Message => {
|
||||
const convertGroupMessage = (
|
||||
message: ImGroupMessageApi.GroupMessageRespVO,
|
||||
): Message => {
|
||||
return {
|
||||
id: message.id,
|
||||
clientMessageId: message.clientMessageId || generateClientMessageId(),
|
||||
@@ -104,12 +119,14 @@ export const useMessagePuller = () => {
|
||||
atUserIds: message.atUserIds || [],
|
||||
receiverUserIds: message.receiverUserIds || [],
|
||||
receiptStatus: message.receiptStatus,
|
||||
readCount: message.readCount
|
||||
}
|
||||
}
|
||||
readCount: message.readCount,
|
||||
};
|
||||
};
|
||||
|
||||
/** 服务端频道消息 -> 本地 Message */
|
||||
const convertChannelMessage = (message: ImChannelMessageApi.ChannelMessageRespVO): Message => {
|
||||
const convertChannelMessage = (
|
||||
message: ImChannelMessageApi.ChannelMessageRespVO,
|
||||
): Message => {
|
||||
return {
|
||||
id: message.id,
|
||||
clientMessageId: message.clientMessageId || generateClientMessageId(),
|
||||
@@ -121,38 +138,43 @@ export const useMessagePuller = () => {
|
||||
senderId: 0, // 系统下发,无发送人
|
||||
targetId: message.channelId, // 会话归属到频道编号
|
||||
selfSend: false,
|
||||
materialId: message.materialId // 详情页拉富文本用
|
||||
}
|
||||
}
|
||||
materialId: message.materialId, // 详情页拉富文本用
|
||||
};
|
||||
};
|
||||
|
||||
/** 频道:会话归属到 channelId;name / avatar 暂用占位,将来接入 channelStore 后再填真值 */
|
||||
const convertChannelConversation = (message: ImChannelMessageApi.ChannelMessageRespVO) =>
|
||||
buildChannelConversationStub(message.channelId)
|
||||
const convertChannelConversation = (
|
||||
message: ImChannelMessageApi.ChannelMessageRespVO,
|
||||
) => buildChannelConversationStub(message.channelId);
|
||||
|
||||
/** 私聊:会话归属到对端 userId */
|
||||
const convertPrivateConversation = (message: ImPrivateMessageApi.PrivateMessageRespVO) => {
|
||||
const targetId = getPrivatePeerId(message)
|
||||
const friend = friendStore.getFriend(targetId)
|
||||
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
|
||||
}
|
||||
}
|
||||
silent: friend?.silent,
|
||||
};
|
||||
};
|
||||
|
||||
/** 群聊:会话归属到 groupId */
|
||||
const convertGroupConversation = (message: ImGroupMessageApi.GroupMessageRespVO) => {
|
||||
const group = groupStore.getGroup(message.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
|
||||
}
|
||||
}
|
||||
silent: group?.silent,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 分类型拉取离线消息:翻页 / minId 游标推进 / 空页停由 runMinIdPull 负责,这里只做接口分支 + 逐条业务分发
|
||||
@@ -167,98 +189,109 @@ export const useMessagePuller = () => {
|
||||
startMinId: number,
|
||||
startEpoch: number,
|
||||
startUserId: number,
|
||||
signal: AbortSignal
|
||||
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 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
|
||||
!signal.aborted &&
|
||||
pullEpoch === startEpoch &&
|
||||
getCurrentUserId() === startUserId;
|
||||
await runMinIdPull<PulledRawMessage>({
|
||||
initialMinId: startMinId,
|
||||
pageSize: size,
|
||||
isActive: isStillValid,
|
||||
fetchPage: ({ minId, size }) => {
|
||||
if (isPrivate) {
|
||||
return apiPullPrivateMessageList({ minId, size }, signal)
|
||||
return apiPullPrivateMessageList({ minId, size }, signal);
|
||||
}
|
||||
if (isChannel) {
|
||||
return apiPullChannelMessageList({ minId, size }, signal)
|
||||
return apiPullChannelMessageList({ minId, size }, signal);
|
||||
}
|
||||
return apiPullGroupMessageList({ minId, size }, signal)
|
||||
return apiPullGroupMessageList({ minId, size }, signal);
|
||||
},
|
||||
applyPage: async (list, nextMinId) => {
|
||||
const pulledMessages: PulledMessage[] = []
|
||||
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
|
||||
const message = raw as ImChannelMessageApi.ChannelMessageRespVO;
|
||||
pulledMessages.push({
|
||||
kind: 'insert',
|
||||
conversationInfo: convertChannelConversation(message),
|
||||
message: convertChannelMessage(message)
|
||||
})
|
||||
continue
|
||||
message: convertChannelMessage(message),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (isPrivate) {
|
||||
const message = raw as ImPrivateMessageApi.PrivateMessageRespVO
|
||||
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
|
||||
recallSignalContent: message.content,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// 特殊:历史好友事件只还原聊天气泡;好友主数据由好友增量补偿同步
|
||||
// 仅 FRIEND_ADD / FRIEND_DELETE 才作为会话气泡入消息列表
|
||||
if (isFriendNotification(message.type) && !isFriendChatTip(message.type)) {
|
||||
continue
|
||||
if (
|
||||
isFriendNotification(message.type) &&
|
||||
!isFriendChatTip(message.type)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// 其它消息正常入会话消息列表
|
||||
pulledMessages.push({
|
||||
kind: 'insert',
|
||||
conversationInfo: convertPrivateConversation(message),
|
||||
message: convertPrivateMessage(message)
|
||||
})
|
||||
message: convertPrivateMessage(message),
|
||||
});
|
||||
} else {
|
||||
const message = raw as ImGroupMessageApi.GroupMessageRespVO
|
||||
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
|
||||
recallSignalContent: message.content,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
pulledMessages.push({
|
||||
kind: 'insert',
|
||||
conversationInfo: convertGroupConversation(message),
|
||||
message: convertGroupMessage(message)
|
||||
})
|
||||
message: convertGroupMessage(message),
|
||||
});
|
||||
}
|
||||
}
|
||||
// 入库 + 推进 messageMaxId;nextMinId 为空(本批无有效 id)时不推进游标,与旧逻辑一致
|
||||
await messageStore.applyPulledMessageList(pulledMessages, conversationType, nextMinId)
|
||||
}
|
||||
})
|
||||
}
|
||||
await messageStore.applyPulledMessageList(
|
||||
pulledMessages,
|
||||
conversationType,
|
||||
nextMinId,
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** 同一时刻只允许一次 pull:index.vue 的手动调用与重连 watch 触发可能并发,共用同一个 promise 即可去重 */
|
||||
let pullPromise: null | Promise<void> = null
|
||||
let pullAbortController: AbortController | null = null
|
||||
let pullPromise: null | Promise<void> = null;
|
||||
let pullAbortController: AbortController | null = null;
|
||||
|
||||
/**
|
||||
* 首次 pull 是否已完成。仅在置 true 后,isConnected watch 才会触发 pull。
|
||||
* 防止 socket onopen 比 friendStore/groupStore 预拉先到达时,watcher 抢跑造成消息插入早于会话元数据可见
|
||||
*/
|
||||
let initialPulled = false
|
||||
let initialPulled = false;
|
||||
|
||||
/**
|
||||
* pull 轮次计数;切账号 / 离开 IM 时 cancelPull() 递增,旧 pullByType 循环按 epoch 自检后跳出
|
||||
@@ -267,18 +300,18 @@ export const useMessagePuller = () => {
|
||||
* 注意:普通断连(WS 短断)不取消 pull——网络抖动 / 服务端重启都属于本账号正常生命周期,
|
||||
* 取消会导致首拉被中断后 initialPulled 永远停在 false,后续重连 watcher 不再补拉
|
||||
*/
|
||||
let pullEpoch = 0
|
||||
let pullEpoch = 0;
|
||||
|
||||
/** 显式取消:仅由 index.vue onUnmounted(离开 IM / 切账号 / 路由跳出)调用 */
|
||||
const cancelPull = () => {
|
||||
pullEpoch++
|
||||
pullAbortController?.abort()
|
||||
pullAbortController = null
|
||||
pullEpoch++;
|
||||
pullAbortController?.abort();
|
||||
pullAbortController = null;
|
||||
// 旧 promise 仍在 finally 阶段跑,但 epoch 守卫已阻断后续副作用;这里立刻让 pullPromise = null 让新一轮可重入
|
||||
pullPromise = null
|
||||
pullPromise = null;
|
||||
// 同步丢弃 WS 缓冲帧;旧 pull 已不会 flushBuffer,若不清下次进 IM 第一次 pullOnce 会把旧 session 的帧回放进新 store
|
||||
wsStore.discardBuffer()
|
||||
}
|
||||
wsStore.discardBuffer();
|
||||
};
|
||||
|
||||
/**
|
||||
* 状态事件补偿:好友 / 好友申请走增量;群列表和群申请红点走快照刷新
|
||||
@@ -288,11 +321,11 @@ export const useMessagePuller = () => {
|
||||
*/
|
||||
const pullStateEvents = async (): Promise<void> => {
|
||||
// 1. 清理连接级缓存
|
||||
messageStore.clearPrivateReadMaxIdCache()
|
||||
rtcStore.clearGroupCallCache()
|
||||
groupStore.markAllGroupActiveCallsExpired()
|
||||
groupStore.markAllGroupInfoExpired()
|
||||
groupStore.markAllGroupMembersExpired()
|
||||
messageStore.clearPrivateReadMaxIdCache();
|
||||
rtcStore.clearGroupCallCache();
|
||||
groupStore.markAllGroupActiveCallsExpired();
|
||||
groupStore.markAllGroupInfoExpired();
|
||||
groupStore.markAllGroupMembersExpired();
|
||||
// 2. 并发补偿远端状态
|
||||
const results = await Promise.allSettled([
|
||||
friendStore.pullFriends(),
|
||||
@@ -300,41 +333,41 @@ export const useMessagePuller = () => {
|
||||
conversationStore.pullConversationReads(),
|
||||
groupStore.fetchGroupList(true),
|
||||
groupRequestStore.pullGroupRequests(),
|
||||
groupRequestStore.fetchUnhandledGroupRequestList()
|
||||
])
|
||||
groupRequestStore.fetchUnhandledGroupRequestList(),
|
||||
]);
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
console.warn('[IM] 状态事件增量补偿失败', result.reason)
|
||||
console.warn('[IM] 状态事件增量补偿失败', result.reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** 执行一次全量增量拉取(重入安全:进行中再次调用复用同一个 promise) */
|
||||
const pullOnce = (): Promise<void> => {
|
||||
if (!currentUserId) {
|
||||
return Promise.resolve()
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (pullPromise) {
|
||||
return pullPromise
|
||||
return pullPromise;
|
||||
}
|
||||
const startEpoch = pullEpoch
|
||||
const startEpoch = pullEpoch;
|
||||
// 启动时的用户快照;pullByType 每批 await 后比对当前登录用户,账号变了立刻丢弃
|
||||
const startUserId = currentUserId
|
||||
const abortController = new AbortController()
|
||||
pullAbortController = abortController
|
||||
const startUserId = currentUserId;
|
||||
const abortController = new AbortController();
|
||||
pullAbortController = abortController;
|
||||
// 本轮 pull 仍属于当前 session:epoch 未漂 + 用户未切;任何动新 store 状态的副作用都要先过这道关
|
||||
const isCurrentPull = () =>
|
||||
!abortController.signal.aborted &&
|
||||
pullEpoch === startEpoch &&
|
||||
getCurrentUserId() === startUserId
|
||||
getCurrentUserId() === startUserId;
|
||||
pullPromise = (async () => {
|
||||
try {
|
||||
// 旧 puller 在 cancelPull 未触发的异常路径上再进来时,先于任何副作用退出,避免污染新 session 的 loading
|
||||
if (!isCurrentPull()) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
conversationStore.loading = true
|
||||
let messagePullSucceeded = false
|
||||
conversationStore.loading = true;
|
||||
let messagePullSucceeded = false;
|
||||
try {
|
||||
// 并发拉取私聊 + 群聊 + 频道消息,降低初始加载耗时
|
||||
await Promise.all([
|
||||
@@ -343,107 +376,117 @@ export const useMessagePuller = () => {
|
||||
messageStore.privateMessageMaxId,
|
||||
startEpoch,
|
||||
startUserId,
|
||||
abortController.signal
|
||||
abortController.signal,
|
||||
),
|
||||
pullByType(
|
||||
ImConversationType.GROUP,
|
||||
messageStore.groupMessageMaxId,
|
||||
startEpoch,
|
||||
startUserId,
|
||||
abortController.signal
|
||||
abortController.signal,
|
||||
),
|
||||
pullByType(
|
||||
ImConversationType.CHANNEL,
|
||||
messageStore.channelMessageMaxId,
|
||||
startEpoch,
|
||||
startUserId,
|
||||
abortController.signal
|
||||
)
|
||||
])
|
||||
messagePullSucceeded = true
|
||||
abortController.signal,
|
||||
),
|
||||
]);
|
||||
messagePullSucceeded = true;
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
console.error('[IM] 拉取离线消息失败:', error)
|
||||
console.error('[IM] 拉取离线消息失败:', error);
|
||||
} finally {
|
||||
// 仍属本轮才复位 loading;旧轮被 cancel / 切账号时由新一轮自管,避免覆盖新 session 的 true
|
||||
if (isCurrentPull()) {
|
||||
conversationStore.loading = false
|
||||
conversationStore.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 取消 / 切账号后跳过 flushBuffer / 排序 / 已读位置补齐
|
||||
if (!isCurrentPull()) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (!messagePullSucceeded) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// 回放 WebSocket 在 loading 期间收到的缓冲消息
|
||||
const buffered = wsStore.flushBuffer()
|
||||
const replayPersistPromises: Promise<void>[] = []
|
||||
const buffered = wsStore.flushBuffer();
|
||||
const replayPersistPromises: Promise<void>[] = [];
|
||||
for (const item of buffered) {
|
||||
if (item.conversationType === ImConversationType.PRIVATE) {
|
||||
replayPersistPromises.push(wsStore.handlePrivateMessage(item.payload))
|
||||
replayPersistPromises.push(
|
||||
wsStore.handlePrivateMessage(item.payload),
|
||||
);
|
||||
} else if (item.conversationType === ImConversationType.CHANNEL) {
|
||||
replayPersistPromises.push(wsStore.handleChannelMessage(item.payload))
|
||||
replayPersistPromises.push(
|
||||
wsStore.handleChannelMessage(item.payload),
|
||||
);
|
||||
} else {
|
||||
replayPersistPromises.push(wsStore.handleGroupMessage(item.payload))
|
||||
replayPersistPromises.push(
|
||||
wsStore.handleGroupMessage(item.payload),
|
||||
);
|
||||
}
|
||||
}
|
||||
await Promise.all(replayPersistPromises)
|
||||
await Promise.all(replayPersistPromises);
|
||||
|
||||
// pull + replay 都完成后再排序,避免回放消息打乱顺序
|
||||
conversationStore.sortConversationList()
|
||||
conversationStore.sortConversationList();
|
||||
|
||||
// 重连 / 冷启动后补齐当前激活私聊会话的「对方已读位置」
|
||||
// 离线期间错过的 RECEIPT 推送会被这里补回;其他私聊会话等用户点开时由 index.vue 的 watch 触发
|
||||
// 私聊已读关闭时跳过,避免打到已禁用接口触发错误日志
|
||||
const active = conversationStore.activeConversation
|
||||
if (MESSAGE_PRIVATE_READ_ENABLED && active && active.type === ImConversationType.PRIVATE) {
|
||||
const active = conversationStore.activeConversation;
|
||||
if (
|
||||
MESSAGE_PRIVATE_READ_ENABLED &&
|
||||
active &&
|
||||
active.type === ImConversationType.PRIVATE
|
||||
) {
|
||||
try {
|
||||
const maxReadId = await apiGetPrivateMaxReadMessageId(
|
||||
active.targetId,
|
||||
abortController.signal
|
||||
)
|
||||
abortController.signal,
|
||||
);
|
||||
if (!isCurrentPull()) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
messageStore.updatePrivateReadMaxId(active.targetId, maxReadId)
|
||||
messageStore.updatePrivateReadMaxId(active.targetId, maxReadId);
|
||||
if (maxReadId) {
|
||||
messageStore.applyMessageReadReceipt({
|
||||
conversationType: ImConversationType.PRIVATE,
|
||||
targetId: active.targetId,
|
||||
privateReadMaxId: maxReadId
|
||||
})
|
||||
privateReadMaxId: maxReadId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
console.warn('[IM] 拉取对方已读位置失败', error)
|
||||
console.warn('[IM] 拉取对方已读位置失败', error);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// 仍属本轮:正常完成首拉;epoch 等但 userId 切了:清 pullPromise 防卡死、不标首拉;epoch 漂:cancelPull 已清,no-op
|
||||
if (isCurrentPull()) {
|
||||
pullPromise = null
|
||||
initialPulled = true
|
||||
pullPromise = null;
|
||||
initialPulled = true;
|
||||
if (pullAbortController === abortController) {
|
||||
pullAbortController = null
|
||||
pullAbortController = null;
|
||||
}
|
||||
} else if (pullEpoch === startEpoch) {
|
||||
pullPromise = null
|
||||
pullPromise = null;
|
||||
if (pullAbortController === abortController) {
|
||||
pullAbortController = null
|
||||
pullAbortController = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
})()
|
||||
return pullPromise
|
||||
}
|
||||
})();
|
||||
return pullPromise;
|
||||
};
|
||||
|
||||
/**
|
||||
* 断网期间 WS 收不到推送:重连后既要按 minId 补齐消息,也要按 update_time + id 补齐好友 / 群 / 群申请状态。
|
||||
@@ -454,11 +497,11 @@ export const useMessagePuller = () => {
|
||||
() => wsStore.isConnected,
|
||||
(isConnected) => {
|
||||
if (isConnected && initialPulled) {
|
||||
void pullOnce()
|
||||
void pullStateEvents()
|
||||
void pullOnce();
|
||||
void pullStateEvents();
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
return { pullOnce, cancelPull, convertPrivateMessage, convertGroupMessage }
|
||||
}
|
||||
return { pullOnce, cancelPull, convertPrivateMessage, convertGroupMessage };
|
||||
};
|
||||
|
||||
@@ -1,53 +1,59 @@
|
||||
import type { Conversation, Message } from '../types'
|
||||
import type { QuoteMessage, TextMessage } from '../../utils/message';
|
||||
import type { Conversation, Message } from '../types';
|
||||
|
||||
import { readChannelMessages as apiReadChannelMessages } from '#/api/im/message/channel'
|
||||
import { readChannelMessages as apiReadChannelMessages } from '#/api/im/message/channel';
|
||||
import {
|
||||
readGroupMessages as apiReadGroupMessages,
|
||||
recallGroupMessage as apiRecallGroupMessage,
|
||||
sendGroupMessage as apiSendGroupMessage
|
||||
} from '#/api/im/message/group'
|
||||
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'
|
||||
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 {
|
||||
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'
|
||||
withQuotePayload,
|
||||
} from '../../utils/message';
|
||||
import { useConversationStore } from '../store/conversationStore';
|
||||
import { useMessageStore } from '../store/messageStore';
|
||||
|
||||
/** 非文本消息的扩展选项(通用) */
|
||||
interface SendExtOptions {
|
||||
atUserIds?: number[] // 群聊 @ 的用户编号列表
|
||||
receipt?: boolean // 是否需要群回执(默认 false)
|
||||
targetId?: number // 覆盖默认的 targetId
|
||||
atUserIds?: number[]; // 群聊 @ 的用户编号列表
|
||||
receipt?: boolean; // 是否需要群回执(默认 false)
|
||||
targetId?: number; // 覆盖默认的 targetId
|
||||
/**
|
||||
* 显式指定目标会话(转发 / 名片推荐场景)
|
||||
*
|
||||
* 不传时默认取 conversationStore.activeConversation;传入时按本值发送 + 乐观更新到对应会话,
|
||||
* 不要求该会话当前是激活状态(适合发给「非当前会话」的多个目标)
|
||||
*/
|
||||
conversation?: Conversation
|
||||
conversation?: Conversation;
|
||||
/** 被引用消息(可选):写进 content.quote 用于乐观渲染,服务端按 quote.messageId 反查重算覆盖 */
|
||||
quote?: QuoteMessage
|
||||
quote?: QuoteMessage;
|
||||
/**
|
||||
* 复用已存在的本地占位消息 clientMessageId(媒体上传场景)
|
||||
*
|
||||
* 媒体上传链路在请求服务端前已经 insertMessage 了占位(带 blob URL + 进度条),
|
||||
* 这里跳过 buildLocalMessage / insertMessage,直接拿这个 id 走 ackMessage 收尾,避免重复插入两条
|
||||
*/
|
||||
existingClientMessageId?: string
|
||||
existingClientMessageId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,16 +66,16 @@ interface SendExtOptions {
|
||||
* 4. 已读上报:本端立刻清未读数并记录本地读位置;接口失败仅记录日志
|
||||
*/
|
||||
export const useMessageSender = () => {
|
||||
const conversationStore = useConversationStore()
|
||||
const messageStore = useMessageStore()
|
||||
const conversationStore = useConversationStore();
|
||||
const messageStore = useMessageStore();
|
||||
|
||||
/** 构造本地乐观消息对象 */
|
||||
const buildLocalMessage = (opts: {
|
||||
atUserIds?: number[]
|
||||
clientMessageId: string
|
||||
content: string
|
||||
targetId: number
|
||||
type: number
|
||||
atUserIds?: number[];
|
||||
clientMessageId: string;
|
||||
content: string;
|
||||
targetId: number;
|
||||
type: number;
|
||||
}): Message => {
|
||||
return {
|
||||
clientMessageId: opts.clientMessageId,
|
||||
@@ -80,9 +86,9 @@ export const useMessageSender = () => {
|
||||
senderId: getCurrentUserId(),
|
||||
targetId: opts.targetId,
|
||||
selfSend: true,
|
||||
atUserIds: opts.atUserIds
|
||||
}
|
||||
}
|
||||
atUserIds: opts.atUserIds,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 发送任意类型的消息(底层实现)
|
||||
@@ -94,46 +100,52 @@ export const useMessageSender = () => {
|
||||
const sendRaw = async (
|
||||
type: number,
|
||||
content: string,
|
||||
options?: SendExtOptions
|
||||
options?: SendExtOptions,
|
||||
): Promise<boolean> => {
|
||||
// 1. 参数校验:优先用显式传入的 conversation(转发场景),否则取激活会话
|
||||
const conversation = options?.conversation ?? conversationStore.activeConversation
|
||||
const conversation =
|
||||
options?.conversation ?? conversationStore.activeConversation;
|
||||
if (!conversation) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
const realTarget = options?.targetId || conversation.targetId
|
||||
const realTarget = options?.targetId || conversation.targetId;
|
||||
if (!realTarget) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 准备 clientMessageId:媒体上传链路在 step 1 已经 insertMessage 占位,这里直接复用 id;其余场景走默认乐观插入
|
||||
let clientMessageId: string
|
||||
let clientMessageId: string;
|
||||
if (options?.existingClientMessageId) {
|
||||
clientMessageId = options.existingClientMessageId
|
||||
clientMessageId = options.existingClientMessageId;
|
||||
// 占位若已被删除(上传期间用户右键删除 / 撤回 / removeMessage 等)则放弃发送,
|
||||
// 否则 sendRaw 仍会把消息推到服务端,导致"本地无气泡 / 对方却收到一条"
|
||||
const stillExists = messageStore
|
||||
.getMessageList(conversation.type, realTarget)
|
||||
.some((message) => message.clientMessageId === clientMessageId && !message._ackMerging)
|
||||
.some(
|
||||
(message) =>
|
||||
message.clientMessageId === clientMessageId && !message._ackMerging,
|
||||
);
|
||||
if (!stillExists) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
clientMessageId = generateClientMessageId()
|
||||
clientMessageId = generateClientMessageId();
|
||||
const message = buildLocalMessage({
|
||||
clientMessageId,
|
||||
content,
|
||||
targetId: realTarget,
|
||||
type,
|
||||
atUserIds: options?.atUserIds
|
||||
})
|
||||
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)
|
||||
avatar: conversation.avatar || '',
|
||||
};
|
||||
void messageStore
|
||||
.insertMessage(conversationInfo, message)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
// 3. 发送请求:按会话类型分发到不同接口;成功后 ackMessage 更新为 NORMAL,失败更新为 FAILED
|
||||
@@ -143,17 +155,17 @@ export const useMessageSender = () => {
|
||||
clientMessageId,
|
||||
receiverId: realTarget,
|
||||
type,
|
||||
content
|
||||
})
|
||||
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
|
||||
content: data.content,
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.catch(() => undefined);
|
||||
} else if (conversation.type === ImConversationType.GROUP) {
|
||||
const data = await apiSendGroupMessage({
|
||||
clientMessageId,
|
||||
@@ -161,8 +173,8 @@ export const useMessageSender = () => {
|
||||
type,
|
||||
content,
|
||||
atUserIds: options?.atUserIds,
|
||||
receipt: options?.receipt
|
||||
})
|
||||
receipt: options?.receipt,
|
||||
});
|
||||
void messageStore
|
||||
.ackMessage(conversation.type, realTarget, clientMessageId, {
|
||||
id: data.id,
|
||||
@@ -170,33 +182,43 @@ export const useMessageSender = () => {
|
||||
status: data.status,
|
||||
receiptStatus: data.receiptStatus,
|
||||
readCount: data.readCount,
|
||||
content: data.content
|
||||
content: data.content,
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
return true
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[IM] 消息发送失败', { type, realTarget, clientMessageId }, error)
|
||||
console.error(
|
||||
'[IM] 消息发送失败',
|
||||
{ type, realTarget, clientMessageId },
|
||||
error,
|
||||
);
|
||||
void messageStore
|
||||
.ackMessage(conversation.type, realTarget, clientMessageId, {
|
||||
status: ImMessageStatus.FAILED
|
||||
status: ImMessageStatus.FAILED,
|
||||
})
|
||||
.catch(() => undefined)
|
||||
return false
|
||||
.catch(() => undefined);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 发送文本消息(最常用的快捷入口):message-input.vue 文本回车走这里
|
||||
* 返回值:成功 true / 失败 false / 空文本 false(与 sendRaw 对齐,转发场景按返回值判断)
|
||||
*/
|
||||
const send = async (text: string, options?: SendExtOptions): Promise<boolean> => {
|
||||
const send = async (
|
||||
text: string,
|
||||
options?: SendExtOptions,
|
||||
): Promise<boolean> => {
|
||||
if (!text.trim()) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
const payload = withQuotePayload<TextMessage>({ content: text }, options?.quote)
|
||||
return sendRaw(ImContentType.TEXT, serializeMessage(payload), options)
|
||||
}
|
||||
const payload = withQuotePayload<TextMessage>(
|
||||
{ content: text },
|
||||
options?.quote,
|
||||
);
|
||||
return sendRaw(ImContentType.TEXT, serializeMessage(payload), options);
|
||||
};
|
||||
|
||||
/**
|
||||
* 撤回某条消息
|
||||
@@ -206,20 +228,26 @@ export const useMessageSender = () => {
|
||||
const recall = async (message: Message) => {
|
||||
// 参数校验:本地占位消息不能撤回
|
||||
if (!message.id) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
const conversation = conversationStore.activeConversation
|
||||
const conversation = conversationStore.activeConversation;
|
||||
if (!conversation) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
// 私聊 / 群聊接口签名一致,按会话类型分发
|
||||
const isPrivate = conversation.type === ImConversationType.PRIVATE
|
||||
const isPrivate = conversation.type === ImConversationType.PRIVATE;
|
||||
try {
|
||||
await (isPrivate ? apiRecallPrivateMessage(message.id) : apiRecallGroupMessage(message.id))
|
||||
await (isPrivate
|
||||
? apiRecallPrivateMessage(message.id)
|
||||
: apiRecallGroupMessage(message.id));
|
||||
} catch (error) {
|
||||
console.error('[IM] 撤回失败', { messageId: message.id, type: conversation.type }, error)
|
||||
console.error(
|
||||
'[IM] 撤回失败',
|
||||
{ messageId: message.id, type: conversation.type },
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 触发当前会话的已读上报(切会话 / 进入页面时调用)
|
||||
@@ -227,67 +255,81 @@ export const useMessageSender = () => {
|
||||
* 2. 已读位置取已加载消息和会话末条消息的最大服务端 id
|
||||
*/
|
||||
const readActive = async () => {
|
||||
const conversation = conversationStore.activeConversation
|
||||
const conversation = conversationStore.activeConversation;
|
||||
if (!conversation) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
let loadedMaxMessageId = 0
|
||||
let loadedMaxMessageId = 0;
|
||||
for (const message of messageStore.getMessages(
|
||||
getClientConversationId(conversation.type, conversation.targetId)
|
||||
getClientConversationId(conversation.type, conversation.targetId),
|
||||
)) {
|
||||
if (message.id && message.id > loadedMaxMessageId) {
|
||||
loadedMaxMessageId = message.id
|
||||
loadedMaxMessageId = message.id;
|
||||
}
|
||||
}
|
||||
const maxMessageId = Math.max(loadedMaxMessageId, conversation.lastMessageId || 0)
|
||||
const maxMessageId = Math.max(
|
||||
loadedMaxMessageId,
|
||||
conversation.lastMessageId || 0,
|
||||
);
|
||||
const readReported = conversationStore.isReportedReadPositionCovered(
|
||||
conversation.type,
|
||||
conversation.targetId,
|
||||
maxMessageId
|
||||
)
|
||||
maxMessageId,
|
||||
);
|
||||
if (readReported) {
|
||||
conversationStore.markConversationRead(conversation.type, conversation.targetId)
|
||||
return
|
||||
conversationStore.markConversationRead(
|
||||
conversation.type,
|
||||
conversation.targetId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const isPrivate = conversation.type === ImConversationType.PRIVATE
|
||||
const isGroup = conversation.type === ImConversationType.GROUP
|
||||
const isChannel = conversation.type === ImConversationType.CHANNEL
|
||||
const isPrivate = conversation.type === ImConversationType.PRIVATE;
|
||||
const isGroup = conversation.type === ImConversationType.GROUP;
|
||||
const isChannel = conversation.type === ImConversationType.CHANNEL;
|
||||
// 本地标记已读:未读数清零(UI 立刻响应)
|
||||
conversationStore.markConversationRead(conversation.type, conversation.targetId, maxMessageId)
|
||||
conversationStore.markConversationRead(
|
||||
conversation.type,
|
||||
conversation.targetId,
|
||||
maxMessageId,
|
||||
);
|
||||
if (!maxMessageId) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
// 接口调用:按会话类型分发,并按对应已读开关控制
|
||||
if (!isPrivate && !isGroup && !isChannel) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (isPrivate && !MESSAGE_PRIVATE_READ_ENABLED) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (isGroup && !MESSAGE_GROUP_READ_ENABLED) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (isPrivate) {
|
||||
await apiReadPrivateMessages(conversation.targetId, maxMessageId)
|
||||
await apiReadPrivateMessages(conversation.targetId, maxMessageId);
|
||||
} else if (isGroup) {
|
||||
await apiReadGroupMessages(conversation.targetId, maxMessageId)
|
||||
await apiReadGroupMessages(conversation.targetId, maxMessageId);
|
||||
} else {
|
||||
await apiReadChannelMessages(conversation.targetId, maxMessageId)
|
||||
await apiReadChannelMessages(conversation.targetId, maxMessageId);
|
||||
}
|
||||
conversationStore.markConversationReadReported(
|
||||
conversation.type,
|
||||
conversation.targetId,
|
||||
maxMessageId
|
||||
)
|
||||
maxMessageId,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[IM] 标记已读失败',
|
||||
{ type: conversation.type, targetId: conversation.targetId, maxMessageId },
|
||||
error
|
||||
)
|
||||
{
|
||||
type: conversation.type,
|
||||
targetId: conversation.targetId,
|
||||
maxMessageId,
|
||||
},
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 拉取「对方已读到我哪条消息」并补齐本地状态
|
||||
@@ -298,40 +340,40 @@ export const useMessageSender = () => {
|
||||
*/
|
||||
const syncPrivateReadStatus = async (peerId: number) => {
|
||||
if (!peerId) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
// 私聊已读关闭:跳过对方已读位置同步,避免无谓接口调用
|
||||
if (!MESSAGE_PRIVATE_READ_ENABLED) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
const cachedMaxReadId = messageStore.getPrivateReadMaxId(peerId)
|
||||
const cachedMaxReadId = messageStore.getPrivateReadMaxId(peerId);
|
||||
if (cachedMaxReadId !== undefined) {
|
||||
if (cachedMaxReadId > 0) {
|
||||
messageStore.applyMessageReadReceipt({
|
||||
conversationType: ImConversationType.PRIVATE,
|
||||
targetId: peerId,
|
||||
privateReadMaxId: cachedMaxReadId
|
||||
})
|
||||
privateReadMaxId: cachedMaxReadId,
|
||||
});
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 拉取对方已读到的最大消息 id
|
||||
const maxReadId = await apiGetPrivateMaxReadMessageId(peerId)
|
||||
messageStore.updatePrivateReadMaxId(peerId, maxReadId)
|
||||
const maxReadId = await apiGetPrivateMaxReadMessageId(peerId);
|
||||
messageStore.updatePrivateReadMaxId(peerId, maxReadId);
|
||||
if (!maxReadId) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
// applyMessageReadReceipt 内部把 ≤ maxReadId 的本端消息回执更新为 DONE
|
||||
messageStore.applyMessageReadReceipt({
|
||||
conversationType: ImConversationType.PRIVATE,
|
||||
targetId: peerId,
|
||||
privateReadMaxId: maxReadId
|
||||
})
|
||||
privateReadMaxId: maxReadId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[IM] 拉取对方已读位置失败', { peerId }, error)
|
||||
console.warn('[IM] 拉取对方已读位置失败', { peerId }, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { send, sendRaw, recall, readActive, syncPrivateReadStatus }
|
||||
}
|
||||
return { send, sendRaw, recall, readActive, syncPrivateReadStatus };
|
||||
};
|
||||
|
||||
@@ -1,39 +1,41 @@
|
||||
import { computed, type ComputedRef, onScopeDispose, ref } from 'vue'
|
||||
import type { ComputedRef } from 'vue';
|
||||
|
||||
import { getCurrentUserId } from '#/views/im/utils/auth'
|
||||
import { computed, onScopeDispose, ref } from 'vue';
|
||||
|
||||
import { ImConversationType, ImGroupMemberRole } from '../../utils/constants'
|
||||
import { isGroupQuit } from '../../utils/user'
|
||||
import { useConversationStore } from '../store/conversationStore'
|
||||
import { useGroupStore } from '../store/groupStore'
|
||||
import { getCurrentUserId } from '#/views/im/utils/auth';
|
||||
|
||||
export type MuteOverlayInfo = { icon: string; text: string; }
|
||||
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
|
||||
const sharedNow = ref(Date.now());
|
||||
let sharedTickTimer: null | number = null;
|
||||
let subscriberCount = 0;
|
||||
|
||||
function subscribeNowTick(): void {
|
||||
subscriberCount++
|
||||
subscriberCount++;
|
||||
if (!sharedTickTimer) {
|
||||
sharedTickTimer = window.setInterval(() => {
|
||||
sharedNow.value = Date.now()
|
||||
}, 30_000)
|
||||
sharedNow.value = Date.now();
|
||||
}, 30_000);
|
||||
}
|
||||
}
|
||||
|
||||
function unsubscribeNowTick(): void {
|
||||
subscriberCount--
|
||||
subscriberCount--;
|
||||
if (subscriberCount <= 0) {
|
||||
subscriberCount = 0
|
||||
subscriberCount = 0;
|
||||
if (sharedTickTimer) {
|
||||
window.clearInterval(sharedTickTimer)
|
||||
sharedTickTimer = null
|
||||
window.clearInterval(sharedTickTimer);
|
||||
sharedTickTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,56 +49,65 @@ function unsubscribeNowTick(): void {
|
||||
* 避免「输入框拦了但重试绕过」「上传期间被禁言但 sendRaw 仍发出去」之类的不一致
|
||||
*/
|
||||
export function useMuteOverlay(): ComputedRef<MuteOverlayInfo | null> {
|
||||
const conversationStore = useConversationStore()
|
||||
const groupStore = useGroupStore()
|
||||
const conversationStore = useConversationStore();
|
||||
const groupStore = useGroupStore();
|
||||
|
||||
// 订阅模块级 tick;scope 销毁时反订阅,最后一个订阅者退场后 timer 也跟着清
|
||||
subscribeNowTick()
|
||||
onScopeDispose(unsubscribeNowTick)
|
||||
subscribeNowTick();
|
||||
onScopeDispose(unsubscribeNowTick);
|
||||
|
||||
return computed(() => {
|
||||
const conversation = conversationStore.activeConversation
|
||||
const conversation = conversationStore.activeConversation;
|
||||
if (!conversation || conversation.type !== ImConversationType.GROUP) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
const group = groupStore.getGroup(conversation.targetId)
|
||||
const group = groupStore.getGroup(conversation.targetId);
|
||||
if (!group) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
// 历史退群群:已退群只能查看历史,禁止发送(文本 / 图片 / 文件 / 语音 / 重试共用这一层拦截)
|
||||
if (isGroupQuit(group)) {
|
||||
return { text: '你已退出群聊,仅可查看历史消息', icon: 'ant-design:logout-outlined' }
|
||||
return {
|
||||
text: '你已退出群聊,仅可查看历史消息',
|
||||
icon: 'ant-design:logout-outlined',
|
||||
};
|
||||
}
|
||||
const myId = getCurrentUserId()
|
||||
const myId = getCurrentUserId();
|
||||
// 群封禁:管理后台操作,所有人不可发送
|
||||
if (group.banned) {
|
||||
return { text: '该群已被管理员封禁,无法发送消息', icon: 'ant-design:stop-outlined' }
|
||||
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)
|
||||
const myMember = group.members?.find((m) => m.userId === myId);
|
||||
if (myMember?.role !== ImGroupMemberRole.ADMIN) {
|
||||
return { text: '全群禁言中,暂时无法发送消息', icon: 'ant-design:audio-muted-outlined' }
|
||||
return {
|
||||
text: '全群禁言中,暂时无法发送消息',
|
||||
icon: 'ant-design:audio-muted-outlined',
|
||||
};
|
||||
}
|
||||
}
|
||||
// 成员禁言:muteEndTime 在未来才算;用响应式 sharedNow 比对,到期后下一个 tick 就让 overlay 消失
|
||||
const myMember = group.members?.find((m) => m.userId === myId)
|
||||
const myMember = group.members?.find((m) => m.userId === myId);
|
||||
if (myMember?.muteEndTime) {
|
||||
const endTime = new Date(myMember.muteEndTime)
|
||||
const endTime = new Date(myMember.muteEndTime);
|
||||
if (endTime.getTime() > sharedNow.value) {
|
||||
const pad = (n: number) => n.toString().padStart(2, '0')
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
const timeStr =
|
||||
`${pad(endTime.getMonth() + 1)}-${pad(endTime.getDate())} ` +
|
||||
`${pad(endTime.getHours())}:${pad(endTime.getMinutes())}`
|
||||
`${pad(endTime.getHours())}:${pad(endTime.getMinutes())}`;
|
||||
return {
|
||||
text: `您已被禁言,解除时间:${timeStr}`,
|
||||
icon: 'ant-design:audio-muted-outlined'
|
||||
}
|
||||
icon: 'ant-design:audio-muted-outlined',
|
||||
};
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { computed, type ComputedRef, type Ref } from 'vue'
|
||||
import type { ComputedRef, Ref } from 'vue';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
/**
|
||||
* 三态选择面板的「已选数 + 已选项列表」派生
|
||||
@@ -17,59 +19,59 @@ export function useSelectedItems<T>(
|
||||
lockedIds: () => readonly number[],
|
||||
disabledIds: () => readonly number[],
|
||||
hideIds: () => readonly number[],
|
||||
byId: ComputedRef<Map<number, T>> | Ref<Map<number, T>>
|
||||
byId: ComputedRef<Map<number, T>> | Ref<Map<number, T>>,
|
||||
): {
|
||||
selectedCount: ComputedRef<number>
|
||||
selectedItems: ComputedRef<T[]>
|
||||
selectedCount: ComputedRef<number>;
|
||||
selectedItems: ComputedRef<T[]>;
|
||||
} {
|
||||
const hideSet = computed(() => new Set(hideIds()))
|
||||
const disabledSet = computed(() => new Set(disabledIds()))
|
||||
const hideSet = computed(() => new Set(hideIds()));
|
||||
const disabledSet = computed(() => new Set(disabledIds()));
|
||||
|
||||
const selectedCount = computed(() => {
|
||||
const merged = new Set<number>()
|
||||
const merged = new Set<number>();
|
||||
for (const id of selectedIds()) {
|
||||
if (hideSet.value.has(id) || disabledSet.value.has(id)) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
merged.add(id)
|
||||
merged.add(id);
|
||||
}
|
||||
// locked 仅被 hide 过滤;契约里 locked 胜过 disabled,确保锁定项始终计入
|
||||
for (const id of lockedIds()) {
|
||||
if (hideSet.value.has(id)) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
merged.add(id)
|
||||
merged.add(id);
|
||||
}
|
||||
return merged.size
|
||||
})
|
||||
return merged.size;
|
||||
});
|
||||
|
||||
const selectedItems = computed(() => {
|
||||
const seen = new Set<number>()
|
||||
const result: T[] = []
|
||||
const seen = new Set<number>();
|
||||
const result: T[] = [];
|
||||
// locked 在前;仅被 hide 过滤
|
||||
for (const id of lockedIds()) {
|
||||
if (seen.has(id) || hideSet.value.has(id)) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
const item = byId.value.get(id)
|
||||
const item = byId.value.get(id);
|
||||
if (item) {
|
||||
seen.add(id)
|
||||
result.push(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
|
||||
continue;
|
||||
}
|
||||
const item = byId.value.get(id)
|
||||
const item = byId.value.get(id);
|
||||
if (item) {
|
||||
seen.add(id)
|
||||
result.push(item)
|
||||
seen.add(id);
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
return result;
|
||||
});
|
||||
|
||||
return { selectedCount, selectedItems }
|
||||
return { selectedCount, selectedItems };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref } from 'vue'
|
||||
import { ref } from 'vue';
|
||||
|
||||
/**
|
||||
* 语音播放全局互斥
|
||||
@@ -12,15 +12,15 @@ import { ref } from 'vue'
|
||||
* key 由 MessageBubble 在 setup 里 Symbol() 生成实例级唯一身份;不同来源(主面板 / 历史抽屉 /
|
||||
* 合并详情)的同一条语音 url 在不同气泡里也是不同 key,避免一处卸载误停另一处仍可见的播放。
|
||||
*/
|
||||
export type VoiceKey = symbol
|
||||
export type VoiceKey = symbol;
|
||||
|
||||
interface VoiceTask {
|
||||
key: VoiceKey
|
||||
url: string
|
||||
audio: HTMLAudioElement
|
||||
key: VoiceKey;
|
||||
url: string;
|
||||
audio: HTMLAudioElement;
|
||||
}
|
||||
|
||||
const currentTask = ref<null | VoiceTask>(null)
|
||||
const currentTask = ref<null | VoiceTask>(null);
|
||||
|
||||
/**
|
||||
* 显式停止
|
||||
@@ -29,19 +29,19 @@ const currentTask = ref<null | VoiceTask>(null)
|
||||
* - 传 key:仅当当前 task 是该 key 时才停(气泡卸载兜底用,避免误停别人)
|
||||
*/
|
||||
function stop(key?: VoiceKey) {
|
||||
const task = currentTask.value
|
||||
const task = currentTask.value;
|
||||
if (!task) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (key !== undefined && task.key !== key) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
task.audio.pause()
|
||||
task.audio.pause();
|
||||
// removeAttribute('src') + load() 是 W3C 推荐的释放姿势:不会触发空 src 加载导致的 error 事件,
|
||||
// 也能让浏览器立即释放底层 decoder buffer,比 audio.src = '' 更干净
|
||||
task.audio.removeAttribute('src')
|
||||
task.audio.load()
|
||||
currentTask.value = null
|
||||
task.audio.removeAttribute('src');
|
||||
task.audio.load();
|
||||
currentTask.value = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,31 +52,31 @@ function stop(key?: VoiceKey) {
|
||||
*/
|
||||
function play(key: VoiceKey, url: string) {
|
||||
if (!url) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (currentTask.value?.key === key) {
|
||||
stop(key)
|
||||
return
|
||||
stop(key);
|
||||
return;
|
||||
}
|
||||
stop()
|
||||
const audio = new Audio(url)
|
||||
const task: VoiceTask = { key, url, audio }
|
||||
stop();
|
||||
const audio = new Audio(url);
|
||||
const task: VoiceTask = { key, url, audio };
|
||||
/** 播放结束 / 异常清栈;只清当前任务,避免被后续新任务的回调误清 */
|
||||
const finalize = () => {
|
||||
if (currentTask.value === task) {
|
||||
currentTask.value = null
|
||||
currentTask.value = null;
|
||||
}
|
||||
}
|
||||
audio.addEventListener('ended', finalize, { once: true })
|
||||
audio.addEventListener('error', finalize, { once: true })
|
||||
currentTask.value = task
|
||||
audio.play().catch(finalize)
|
||||
};
|
||||
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 currentTask.value?.key === key;
|
||||
}
|
||||
return { isPlaying, play, stop }
|
||||
return { isPlaying, play, stop };
|
||||
}
|
||||
|
||||
@@ -255,6 +255,7 @@ watch(
|
||||
--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 的干净白差距明显。
|
||||
|
||||
@@ -1,30 +1,33 @@
|
||||
<script lang="ts" setup>
|
||||
import type { FriendLite } from '../../types'
|
||||
import type { FriendLite } from '../../types';
|
||||
|
||||
import { ref, toRef } from 'vue'
|
||||
import { ref, toRef } from 'vue';
|
||||
|
||||
import { IconifyIcon as Icon } from '@vben/icons'
|
||||
import { IconifyIcon as Icon } from '@vben/icons';
|
||||
|
||||
import { FriendItem } from '../../components/friend'
|
||||
import { useFriendBuckets } from '../../composables/useFriendBuckets'
|
||||
import { FriendItem } from '../../components/friend';
|
||||
import { useFriendBuckets } from '../../composables/useFriendBuckets';
|
||||
|
||||
defineOptions({ name: 'ImContactFriendList' })
|
||||
defineOptions({ name: 'ImContactFriendList' });
|
||||
|
||||
const props = defineProps<{
|
||||
activeId?: number
|
||||
friends: FriendLite[]
|
||||
keyword: string
|
||||
}>()
|
||||
activeId?: number;
|
||||
friends: FriendLite[];
|
||||
keyword: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
chat: [friend: FriendLite]
|
||||
delete: [friend: FriendLite]
|
||||
select: [friend: FriendLite]
|
||||
}>()
|
||||
chat: [friend: FriendLite];
|
||||
delete: [friend: FriendLite];
|
||||
select: [friend: FriendLite];
|
||||
}>();
|
||||
|
||||
const expanded = ref(true)
|
||||
const expanded = ref(true);
|
||||
|
||||
const { filtered, buckets } = useFriendBuckets(toRef(props, 'friends'), toRef(props, 'keyword'))
|
||||
const { filtered, buckets } = useFriendBuckets(
|
||||
toRef(props, 'friends'),
|
||||
toRef(props, 'keyword'),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -40,9 +43,14 @@ const { filtered, buckets } = useFriendBuckets(toRef(props, 'friends'), toRef(pr
|
||||
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" />
|
||||
<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>
|
||||
<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">
|
||||
|
||||
@@ -1,112 +1,113 @@
|
||||
<script lang="ts" setup>
|
||||
import type { FriendRequest, User } from '../../types'
|
||||
import type { FriendRequest, User } from '../../types';
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { prompt } from '@vben/common-ui'
|
||||
import { DICT_TYPE } from '@vben/constants'
|
||||
import { getDictLabel } from '@vben/hooks'
|
||||
import { prompt } from '@vben/common-ui';
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictLabel } from '@vben/hooks';
|
||||
|
||||
import { Button, Input, message } from 'ant-design-vue'
|
||||
import { Button, Input, message } from 'ant-design-vue';
|
||||
|
||||
import { getCurrentUserId } from '#/views/im/utils/auth'
|
||||
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'
|
||||
import { ImFriendRequestHandleResult } from '../../../utils/constants';
|
||||
import { UserAvatar } from '../../components/user';
|
||||
import { useFriendStore } from '../../store/friendStore';
|
||||
|
||||
defineOptions({ name: 'ImContactFriendRequestDetail' })
|
||||
defineOptions({ name: 'ImContactFriendRequestDetail' });
|
||||
|
||||
const props = defineProps<{
|
||||
request: FriendRequest
|
||||
}>()
|
||||
request: FriendRequest;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
chat: [peerUserId: number]
|
||||
}>()
|
||||
chat: [peerUserId: number];
|
||||
}>();
|
||||
|
||||
const friendStore = useFriendStore()
|
||||
const friendStore = useFriendStore();
|
||||
|
||||
/** 当前登录用户编号;用 computed 包一层,切账号后随 wsCache 重取,避免顶层求值在 keep-alive 实例里持有旧 id */
|
||||
const currentUserId = computed(() => getCurrentUserId())
|
||||
const currentUserId = computed(() => getCurrentUserId());
|
||||
|
||||
/** 是不是我发起的(fromUserId === currentUserId) */
|
||||
const iSentIt = computed(() => props.request.fromUserId === currentUserId.value)
|
||||
const iSentIt = computed(
|
||||
() => props.request.fromUserId === currentUserId.value,
|
||||
);
|
||||
|
||||
/** 是否「已拒绝」态:模板里多处用到,computed 一次省得到处写枚举比对 */
|
||||
const refused = computed(
|
||||
() => props.request.handleResult === ImFriendRequestHandleResult.REFUSED
|
||||
)
|
||||
() => props.request.handleResult === ImFriendRequestHandleResult.REFUSED,
|
||||
);
|
||||
|
||||
/** 是否「已通过」态:转走 UserInfo 好友详情入口 */
|
||||
const agreed = computed(
|
||||
() => props.request.handleResult === ImFriendRequestHandleResult.AGREED
|
||||
)
|
||||
() => props.request.handleResult === ImFriendRequestHandleResult.AGREED,
|
||||
);
|
||||
|
||||
/** 对端的用户编号 / 昵称 / 头像 */
|
||||
const peerUserId = computed(() =>
|
||||
iSentIt.value ? props.request.toUserId : props.request.fromUserId
|
||||
)
|
||||
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)
|
||||
)
|
||||
: props.request.fromNickname || String(props.request.fromUserId),
|
||||
);
|
||||
const peerAvatar = computed(() =>
|
||||
iSentIt.value ? props.request.toAvatar : props.request.fromAvatar
|
||||
)
|
||||
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
|
||||
}))
|
||||
avatar: peerAvatar.value,
|
||||
}));
|
||||
|
||||
// 各自的 loading 用于按钮 spinner 显示;processing 是跨按钮互斥锁,避免同意 / 拒绝并发提交同一申请
|
||||
const agreeing = ref(false)
|
||||
const refusing = ref(false)
|
||||
const processing = ref(false)
|
||||
const agreeing = ref(false);
|
||||
const refusing = ref(false);
|
||||
const processing = ref(false);
|
||||
|
||||
/** 同意申请:互斥锁 + 状态二次校验,避免并发 / 服务端已处理后再次提交 */
|
||||
async function handleAgree() {
|
||||
if (processing.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (props.request.handleResult !== ImFriendRequestHandleResult.UNHANDLED) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
processing.value = true
|
||||
agreeing.value = true
|
||||
processing.value = true;
|
||||
agreeing.value = true;
|
||||
try {
|
||||
await friendStore.agreeFriendRequest(props.request.id)
|
||||
message.success('已同意好友申请')
|
||||
await friendStore.agreeFriendRequest(props.request.id);
|
||||
message.success('已同意好友申请');
|
||||
} finally {
|
||||
agreeing.value = false
|
||||
processing.value = false
|
||||
agreeing.value = false;
|
||||
processing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 拒绝申请:弹 prompt 收集可选拒绝理由(点取消则中止),随后调 store 落库 + 提示 */
|
||||
async function handleRefuse() {
|
||||
if (processing.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (props.request.handleResult !== ImFriendRequestHandleResult.UNHANDLED) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
// 1. 弹 prompt 收集拒绝理由(最多 255 字);用户点「取消」会 reject,中止后续流程
|
||||
let handleContent: string | undefined
|
||||
let handleContent: string | undefined;
|
||||
try {
|
||||
const result = await prompt<string>({
|
||||
beforeClose(scope) {
|
||||
if (!scope.isConfirm) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
if ((scope.value || '').length > 255) {
|
||||
message.error('最多 255 个字符')
|
||||
return false
|
||||
message.error('最多 255 个字符');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
cancelText: '取消',
|
||||
@@ -115,33 +116,33 @@ async function handleRefuse() {
|
||||
allowClear: true,
|
||||
maxlength: 255,
|
||||
placeholder: '不填则不告知对方原因',
|
||||
rows: 3
|
||||
rows: 3,
|
||||
},
|
||||
content: '',
|
||||
defaultValue: '',
|
||||
confirmText: '拒绝',
|
||||
modelPropName: 'value',
|
||||
title: '拒绝好友申请'
|
||||
})
|
||||
handleContent = result || undefined
|
||||
title: '拒绝好友申请',
|
||||
});
|
||||
handleContent = result || undefined;
|
||||
} catch {
|
||||
return
|
||||
return;
|
||||
}
|
||||
// 2. prompt 期间状态可能被跨端改成 AGREED / REFUSED,再校验一次避免重复提交
|
||||
if (processing.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (props.request.handleResult !== ImFriendRequestHandleResult.UNHANDLED) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
processing.value = true
|
||||
refusing.value = true
|
||||
processing.value = true;
|
||||
refusing.value = true;
|
||||
try {
|
||||
await friendStore.refuseFriendRequest(props.request.id, handleContent)
|
||||
message.success('已拒绝好友申请')
|
||||
await friendStore.refuseFriendRequest(props.request.id, handleContent);
|
||||
message.success('已拒绝好友申请');
|
||||
} finally {
|
||||
refusing.value = false
|
||||
processing.value = false
|
||||
refusing.value = false;
|
||||
processing.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -210,15 +211,34 @@ async function handleRefuse() {
|
||||
<div class="w-full max-w-[420px] mt-8 flex justify-center">
|
||||
<!-- 我发起 + 等待中:禁用「等待对方验证」 -->
|
||||
<Button
|
||||
v-if="iSentIt && request.handleResult === ImFriendRequestHandleResult.UNHANDLED"
|
||||
v-if="
|
||||
iSentIt &&
|
||||
request.handleResult === ImFriendRequestHandleResult.UNHANDLED
|
||||
"
|
||||
disabled
|
||||
>
|
||||
等待对方验证
|
||||
</Button>
|
||||
<!-- 别人加我 + 等待中:同意 / 拒绝 -->
|
||||
<template v-if="!iSentIt && request.handleResult === ImFriendRequestHandleResult.UNHANDLED">
|
||||
<Button @click="handleRefuse" :loading="refusing" :disabled="processing">拒绝</Button>
|
||||
<Button type="primary" @click="handleAgree" :loading="agreeing" :disabled="processing">
|
||||
<template
|
||||
v-if="
|
||||
!iSentIt &&
|
||||
request.handleResult === ImFriendRequestHandleResult.UNHANDLED
|
||||
"
|
||||
>
|
||||
<Button
|
||||
@click="handleRefuse"
|
||||
:loading="refusing"
|
||||
:disabled="processing"
|
||||
>
|
||||
拒绝
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
@click="handleAgree"
|
||||
:loading="agreeing"
|
||||
:disabled="processing"
|
||||
>
|
||||
同意
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@@ -1,62 +1,62 @@
|
||||
<script lang="ts" setup>
|
||||
import type { FriendRequest } from '../../types'
|
||||
import type { FriendRequest } from '../../types';
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants'
|
||||
import { getDictLabel } from '@vben/hooks'
|
||||
import { IconifyIcon as Icon } from '@vben/icons'
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictLabel } from '@vben/hooks';
|
||||
import { IconifyIcon as Icon } from '@vben/icons';
|
||||
|
||||
import { Badge } from 'ant-design-vue'
|
||||
import { Badge } from 'ant-design-vue';
|
||||
|
||||
import { getCurrentUserId } from '#/views/im/utils/auth'
|
||||
import { getCurrentUserId } from '#/views/im/utils/auth';
|
||||
|
||||
import { UserAvatar } from '../../components/user'
|
||||
import { useFriendStore } from '../../store/friendStore'
|
||||
import { UserAvatar } from '../../components/user';
|
||||
import { useFriendStore } from '../../store/friendStore';
|
||||
|
||||
defineOptions({ name: 'ImContactFriendRequestList' })
|
||||
defineOptions({ name: 'ImContactFriendRequestList' });
|
||||
|
||||
const props = defineProps<{
|
||||
activeId?: number
|
||||
requests: FriendRequest[]
|
||||
}>()
|
||||
activeId?: number;
|
||||
requests: FriendRequest[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [request: FriendRequest]
|
||||
}>()
|
||||
select: [request: FriendRequest];
|
||||
}>();
|
||||
|
||||
const friendStore = useFriendStore()
|
||||
const expanded = ref(true)
|
||||
const friendStore = useFriendStore();
|
||||
const expanded = ref(true);
|
||||
/** 当前登录用户编号;用 computed 包一层,切账号后随 wsCache 重取,避免顶层求值在 keep-alive 实例里持有旧 id */
|
||||
const currentUserId = computed(() => getCurrentUserId())
|
||||
const currentUserId = computed(() => getCurrentUserId());
|
||||
|
||||
/** 列表项展示对端:fromUserId == 我 → 对端 = toUser;否则对端 = fromUser */
|
||||
function getPeer(request: FriendRequest) {
|
||||
const sentByMe = request.fromUserId === currentUserId.value
|
||||
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
|
||||
}
|
||||
avatar: sentByMe ? request.toAvatar : request.fromAvatar,
|
||||
};
|
||||
}
|
||||
|
||||
/** 列表项预先附 peer 字段,模板里直接 {{ peer.xxx }} 一次成型,省 3 次 helper 调用 */
|
||||
const enrichedRequests = computed(() =>
|
||||
props.requests.map((request) => ({ request, peer: getPeer(request) }))
|
||||
)
|
||||
props.requests.map((request) => ({ request, peer: getPeer(request) })),
|
||||
);
|
||||
|
||||
const loadingMore = ref(false) // 点击「加载更多」拉下一页;store 内部按 maxId 游标分页 + pending 去重
|
||||
const loadingMore = ref(false); // 点击「加载更多」拉下一页;store 内部按 maxId 游标分页 + pending 去重
|
||||
async function handleLoadMore() {
|
||||
if (loadingMore.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
loadingMore.value = true
|
||||
loadingMore.value = true;
|
||||
try {
|
||||
await friendStore.loadMoreFriendRequestList()
|
||||
await friendStore.loadMoreFriendRequestList();
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -74,7 +74,10 @@ async function handleLoadMore() {
|
||||
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" />
|
||||
<Icon
|
||||
:icon="expanded ? 'ep:caret-bottom' : 'ep:caret-right'"
|
||||
:size="14"
|
||||
/>
|
||||
<span class="flex-1">新的朋友</span>
|
||||
<!-- 红点:未处理且别人加我的(统一走 store getter,避免本地 computed 跟 store 双口径) -->
|
||||
<Badge
|
||||
@@ -83,7 +86,9 @@ async function handleLoadMore() {
|
||||
:max="99"
|
||||
class="mr-2"
|
||||
/>
|
||||
<span class="text-sm text-[var(--ant-color-text-secondary)]">{{ requests.length }}</span>
|
||||
<span class="text-sm text-[var(--ant-color-text-secondary)]">{{
|
||||
requests.length
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-show="expanded">
|
||||
<div
|
||||
@@ -91,7 +96,7 @@ async function handleLoadMore() {
|
||||
: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
|
||||
'bg-[var(--ant-color-fill)]': activeId === request.id,
|
||||
}"
|
||||
@click="emit('select', request)"
|
||||
>
|
||||
@@ -104,11 +109,20 @@ async function handleLoadMore() {
|
||||
/>
|
||||
<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)]">
|
||||
<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
|
||||
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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,44 +1,49 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Conversation } from '../../../../types'
|
||||
import type { Conversation } from '../../../../types';
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { confirm } from '@vben/common-ui'
|
||||
import { IconifyIcon as Icon } from '@vben/icons'
|
||||
import { confirm } from '@vben/common-ui';
|
||||
import { IconifyIcon as Icon } from '@vben/icons';
|
||||
|
||||
import { Tag } from 'ant-design-vue'
|
||||
import { Tag } from 'ant-design-vue';
|
||||
|
||||
import { buildRecallTip } from '#/views/im/utils/conversation'
|
||||
import { formatConversationTime } from '#/views/im/utils/time'
|
||||
import { getSenderDisplayName } from '#/views/im/utils/user'
|
||||
import { buildRecallTip } from '#/views/im/utils/conversation';
|
||||
import { formatConversationTime } from '#/views/im/utils/time';
|
||||
import { getSenderDisplayName } from '#/views/im/utils/user';
|
||||
|
||||
import { ImContentType, ImConversationType, isNormalMessage } from '../../../../../utils/constants'
|
||||
import { GroupAvatar } from '../../../../components/group'
|
||||
import { UserAvatar } from '../../../../components/user'
|
||||
import { useConversationStore } from '../../../../store/conversationStore'
|
||||
import { useFriendStore } from '../../../../store/friendStore'
|
||||
import { useGroupRequestStore } from '../../../../store/groupRequestStore'
|
||||
import { useGroupStore } from '../../../../store/groupStore'
|
||||
import { useImUiStore } from '../../../../store/uiStore'
|
||||
import {
|
||||
ImContentType,
|
||||
ImConversationType,
|
||||
isNormalMessage,
|
||||
} from '../../../../../utils/constants';
|
||||
import { GroupAvatar } from '../../../../components/group';
|
||||
import { UserAvatar } from '../../../../components/user';
|
||||
import { useConversationStore } from '../../../../store/conversationStore';
|
||||
import { useFriendStore } from '../../../../store/friendStore';
|
||||
import { useGroupRequestStore } from '../../../../store/groupRequestStore';
|
||||
import { useGroupStore } from '../../../../store/groupStore';
|
||||
import { useImUiStore } from '../../../../store/uiStore';
|
||||
|
||||
defineOptions({ name: 'ImConversationItem' })
|
||||
defineOptions({ name: 'ImConversationItem' });
|
||||
|
||||
// 周中文名(dayjs 的 day() 返回 0-6,0=周日);项目没全局装 dayjs/locale/zh-cn,本地映射避免引副作用
|
||||
const props = defineProps<{
|
||||
conversation: Conversation
|
||||
}>()
|
||||
conversation: Conversation;
|
||||
}>();
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
const friendStore = useFriendStore()
|
||||
const groupStore = useGroupStore()
|
||||
const groupRequestStore = useGroupRequestStore()
|
||||
const uiStore = useImUiStore()
|
||||
const conversationStore = useConversationStore();
|
||||
const friendStore = useFriendStore();
|
||||
const groupStore = useGroupStore();
|
||||
const groupRequestStore = useGroupRequestStore();
|
||||
const uiStore = useImUiStore();
|
||||
|
||||
const isActive = computed(
|
||||
() =>
|
||||
conversationStore.activeConversation?.targetId === props.conversation.targetId &&
|
||||
conversationStore.activeConversation?.type === props.conversation.type
|
||||
)
|
||||
conversationStore.activeConversation?.targetId ===
|
||||
props.conversation.targetId &&
|
||||
conversationStore.activeConversation?.type === props.conversation.type,
|
||||
);
|
||||
|
||||
/**
|
||||
* 当前会话的草稿快照:存在时列表显示 [草稿] 前缀 + 文本,盖掉 sender 前缀和 @我 红字
|
||||
@@ -46,97 +51,105 @@ const isActive = computed(
|
||||
*/
|
||||
const draft = computed(() => {
|
||||
if (isActive.value) {
|
||||
return undefined
|
||||
return undefined;
|
||||
}
|
||||
return conversationStore.getConversationDraft(props.conversation)
|
||||
})
|
||||
return conversationStore.getConversationDraft(props.conversation);
|
||||
});
|
||||
|
||||
const isGroup = computed(() => props.conversation.type === ImConversationType.GROUP)
|
||||
const isGroup = computed(
|
||||
() => props.conversation.type === ImConversationType.GROUP,
|
||||
);
|
||||
|
||||
/** 最后一条消息发送者的展示名:实时算 + 快照 fallback(getSenderDisplayName 算不出时兜底) */
|
||||
const lastSenderDisplayName = computed(() => {
|
||||
const senderId = props.conversation.lastSenderId
|
||||
const senderId = props.conversation.lastSenderId;
|
||||
if (!senderId) {
|
||||
return ''
|
||||
return '';
|
||||
}
|
||||
return getSenderDisplayName(
|
||||
senderId,
|
||||
props.conversation.type,
|
||||
props.conversation.targetId,
|
||||
props.conversation.lastSenderDisplayName
|
||||
)
|
||||
})
|
||||
props.conversation.lastSenderDisplayName,
|
||||
);
|
||||
});
|
||||
|
||||
/** 群聊 + 有最后发送者 + 最后一条是普通消息时,显示发送者前缀(FRIEND_* / GROUP_* / RECALL / 草稿态不带前缀) */
|
||||
const showSendName = computed(() => {
|
||||
if (draft.value) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
if (!isGroup.value) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
if (!props.conversation.lastSenderId) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
const lastType = props.conversation.lastMessageType
|
||||
return lastType != null && isNormalMessage(lastType)
|
||||
})
|
||||
const lastType = props.conversation.lastMessageType;
|
||||
return lastType !== null && isNormalMessage(lastType);
|
||||
});
|
||||
|
||||
/** 列表展示文案:草稿优先(对齐微信 PC:有草稿时盖掉最后一条预览)→ 撤回实时算 → lastContent 兜底 */
|
||||
const lastContentDisplay = computed(() => {
|
||||
if (draft.value) {
|
||||
return draft.value.plain
|
||||
return draft.value.plain;
|
||||
}
|
||||
if (
|
||||
props.conversation.lastMessageType === ImContentType.RECALL &&
|
||||
props.conversation.lastSenderId != null
|
||||
props.conversation.lastSenderId !== null
|
||||
) {
|
||||
return buildRecallTip(
|
||||
props.conversation.lastSenderId,
|
||||
!!props.conversation.lastSelfSend,
|
||||
props.conversation.type,
|
||||
props.conversation.targetId,
|
||||
props.conversation.lastSenderDisplayName
|
||||
)
|
||||
props.conversation.lastSenderDisplayName,
|
||||
);
|
||||
}
|
||||
return props.conversation.lastContent
|
||||
})
|
||||
return props.conversation.lastContent;
|
||||
});
|
||||
|
||||
/** 会话列表 "[草稿]" / "@ 我" / "@ 全体成员" 红字提示;草稿优先(对齐微信 PC) */
|
||||
const atText = computed(() => {
|
||||
if (draft.value) {
|
||||
return '[草稿]'
|
||||
return '[草稿]';
|
||||
}
|
||||
if (props.conversation.atMe) {
|
||||
return '[有人@我]'
|
||||
return '[有人@我]';
|
||||
}
|
||||
if (props.conversation.atAll) {
|
||||
return '[@全体成员]'
|
||||
return '[@全体成员]';
|
||||
}
|
||||
return ''
|
||||
})
|
||||
return '';
|
||||
});
|
||||
|
||||
/** 免打扰会话未读条数文案 */
|
||||
const mutedUnreadText = computed(() => {
|
||||
if (!props.conversation.silent || props.conversation.unreadCount <= 0) {
|
||||
return ''
|
||||
return '';
|
||||
}
|
||||
const count = props.conversation.unreadCount > 99 ? '99+' : props.conversation.unreadCount
|
||||
return `[${count}条]`
|
||||
})
|
||||
const count =
|
||||
props.conversation.unreadCount > 99
|
||||
? '99+'
|
||||
: props.conversation.unreadCount;
|
||||
return `[${count}条]`;
|
||||
});
|
||||
|
||||
/** 群聊未处理加群申请红字前缀;store 已经按「我管理的群」过滤过,count > 0 即可显示 */
|
||||
const requestText = computed(() => {
|
||||
if (!isGroup.value) {
|
||||
return ''
|
||||
return '';
|
||||
}
|
||||
const count = groupRequestStore.getUnhandledGroupRequestCountMap.get(props.conversation.targetId) ?? 0
|
||||
return count > 0 ? `[${count}条进群申请]` : ''
|
||||
})
|
||||
const count =
|
||||
groupRequestStore.getUnhandledGroupRequestCountMap.get(
|
||||
props.conversation.targetId,
|
||||
) ?? 0;
|
||||
return count > 0 ? `[${count}条进群申请]` : '';
|
||||
});
|
||||
|
||||
/** 点击切会话 */
|
||||
function handleClick() {
|
||||
conversationStore.setActiveConversation(props.conversation)
|
||||
conversationStore.setActiveConversation(props.conversation);
|
||||
}
|
||||
|
||||
/** 切换置顶 */
|
||||
@@ -144,30 +157,36 @@ function handleTop() {
|
||||
conversationStore.setConversationTop(
|
||||
props.conversation.type,
|
||||
props.conversation.targetId,
|
||||
!props.conversation.top
|
||||
)
|
||||
!props.conversation.top,
|
||||
);
|
||||
}
|
||||
|
||||
/** 切换免打扰:乐观 UI(先本地切换,菜单立即关;后端失败回滚 conversation 状态) */
|
||||
function handleMuted() {
|
||||
const next = !props.conversation.silent
|
||||
const { type, targetId } = props.conversation
|
||||
conversationStore.setConversationSilent(type, targetId, next)
|
||||
const next = !props.conversation.silent;
|
||||
const { type, targetId } = props.conversation;
|
||||
conversationStore.setConversationSilent(type, targetId, next);
|
||||
const sync =
|
||||
type === ImConversationType.PRIVATE
|
||||
? friendStore.setFriendSilent(targetId, next)
|
||||
: groupStore.setGroupSilent(targetId, next)
|
||||
: groupStore.setGroupSilent(targetId, next);
|
||||
sync.catch((error) => {
|
||||
console.error('[IM] 切换免打扰失败', error)
|
||||
conversationStore.setConversationSilent(type, targetId, !next)
|
||||
})
|
||||
console.error('[IM] 切换免打扰失败', error);
|
||||
conversationStore.setConversationSilent(type, targetId, !next);
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除会话:二次确认后软删 */
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await confirm(`确定删除与「${props.conversation.name}」的会话吗?`, '删除会话')
|
||||
conversationStore.removeConversation(props.conversation.type, props.conversation.targetId)
|
||||
await confirm(
|
||||
`确定删除与「${props.conversation.name}」的会话吗?`,
|
||||
'删除会话',
|
||||
);
|
||||
conversationStore.removeConversation(
|
||||
props.conversation.type,
|
||||
props.conversation.targetId,
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -177,38 +196,42 @@ function handleContextMenu(e: MouseEvent) {
|
||||
{ x: e.clientX, y: e.clientY },
|
||||
[
|
||||
{ key: 'TOP', name: props.conversation.top ? '取消置顶' : '置顶' },
|
||||
{ key: 'MUTED', name: props.conversation.silent ? '允许消息通知' : '消息免打扰' },
|
||||
{ key: 'DELETE', name: '删除', divided: true, danger: true }
|
||||
{
|
||||
key: 'MUTED',
|
||||
name: props.conversation.silent ? '允许消息通知' : '消息免打扰',
|
||||
},
|
||||
{ key: 'DELETE', name: '删除', divided: true, danger: true },
|
||||
],
|
||||
(item) => {
|
||||
switch (item.key) {
|
||||
case 'DELETE': {
|
||||
void handleDelete()
|
||||
case 'DELETE': {
|
||||
void handleDelete();
|
||||
|
||||
break;
|
||||
}
|
||||
case 'MUTED': {
|
||||
handleMuted()
|
||||
break;
|
||||
}
|
||||
case 'MUTED': {
|
||||
handleMuted();
|
||||
|
||||
break;
|
||||
}
|
||||
case 'TOP': {
|
||||
handleTop()
|
||||
break;
|
||||
}
|
||||
case 'TOP': {
|
||||
handleTop();
|
||||
|
||||
break;
|
||||
break;
|
||||
}
|
||||
// No default
|
||||
}
|
||||
// No default
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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)]': isActive }"
|
||||
:class="{
|
||||
'!bg-[#d9ecff] dark:!bg-[var(--ant-color-primary-bg-hover)]': isActive,
|
||||
}"
|
||||
:data-conversation-key="`${conversation.type}-${conversation.targetId}`"
|
||||
@click="handleClick"
|
||||
@contextmenu.prevent="handleContextMenu"
|
||||
@@ -246,7 +269,9 @@ function handleContextMenu(e: MouseEvent) {
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex flex-1 items-center gap-1 min-w-0">
|
||||
<span class="overflow-hidden text-sm truncate text-[var(--ant-color-text)]">
|
||||
<span
|
||||
class="overflow-hidden text-sm truncate text-[var(--ant-color-text)]"
|
||||
>
|
||||
{{ conversation.name }}
|
||||
</span>
|
||||
<Tag
|
||||
@@ -258,7 +283,9 @@ function handleContextMenu(e: MouseEvent) {
|
||||
群
|
||||
</Tag>
|
||||
</span>
|
||||
<span class="flex-shrink-0 ml-1 text-12px text-[var(--ant-color-text-secondary)]">
|
||||
<span
|
||||
class="flex-shrink-0 ml-1 text-12px text-[var(--ant-color-text-secondary)]"
|
||||
>
|
||||
{{ formatConversationTime(conversation.lastSendTime) }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -317,7 +344,7 @@ function handleContextMenu(e: MouseEvent) {
|
||||
|
||||
/* el-icon 的全局 color:var(--color) 在暗色模式下会渲染成白色,这里用 :deep + !important 锁定 */
|
||||
.conversation-item__silent :deep(svg) {
|
||||
fill: currentColor !important;
|
||||
fill: currentcolor !important;
|
||||
}
|
||||
|
||||
.conversation-item__prefix {
|
||||
|
||||
@@ -1,86 +1,102 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Conversation, Friend } from '../../../../types'
|
||||
import type { Conversation, Friend } from '../../../../types';
|
||||
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { IconifyIcon as Icon } from '@vben/icons'
|
||||
import { IconifyIcon as Icon } from '@vben/icons';
|
||||
|
||||
import { Button, Drawer, Input, message, Popover, Spin, Switch } from 'ant-design-vue'
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
Input,
|
||||
message,
|
||||
Popover,
|
||||
Spin,
|
||||
Switch,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { useConversationStore } from '#/views/im/home/store/conversationStore'
|
||||
import { useFriendStore } from '#/views/im/home/store/friendStore'
|
||||
import { useGroupStore } from '#/views/im/home/store/groupStore'
|
||||
import { ImConversationType } from '#/views/im/utils/constants'
|
||||
import { getFriendDisplayName, getGroupDisplayName } from '#/views/im/utils/user'
|
||||
import { useConversationStore } from '#/views/im/home/store/conversationStore';
|
||||
import { useFriendStore } from '#/views/im/home/store/friendStore';
|
||||
import { useGroupStore } from '#/views/im/home/store/groupStore';
|
||||
import { ImConversationType } from '#/views/im/utils/constants';
|
||||
import {
|
||||
getFriendDisplayName,
|
||||
getGroupDisplayName,
|
||||
} from '#/views/im/utils/user';
|
||||
|
||||
import { GroupCreateDialog } from '../../../../components/group'
|
||||
import { UserAvatar } from '../../../../components/user'
|
||||
import { GroupCreateDialog } from '../../../../components/group';
|
||||
import { UserAvatar } from '../../../../components/user';
|
||||
|
||||
defineOptions({ name: 'ImConversationPrivateSide' })
|
||||
defineOptions({ name: 'ImConversationPrivateSide' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
conversation?: Conversation | null // 当前会话(取置顶 / 免打扰态)
|
||||
friend?: Friend // 对方好友信息(取头像 / 昵称)
|
||||
modelValue?: boolean // 抽屉开关(v-model)
|
||||
conversation?: Conversation | null; // 当前会话(取置顶 / 免打扰态)
|
||||
friend?: Friend; // 对方好友信息(取头像 / 昵称)
|
||||
modelValue?: boolean; // 抽屉开关(v-model)
|
||||
}>(),
|
||||
{
|
||||
conversation: null,
|
||||
friend: undefined,
|
||||
modelValue: false
|
||||
}
|
||||
)
|
||||
modelValue: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
openHistory: [] // 点击 "查找聊天内容" 行 → 父组件打开 MessageHistory 弹窗
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
openHistory: []; // 点击 "查找聊天内容" 行 → 父组件打开 MessageHistory 弹窗
|
||||
'update:modelValue': [value: boolean];
|
||||
}>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
set: (value) => emit('update:modelValue', value),
|
||||
});
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
const friendStore = useFriendStore()
|
||||
const groupStore = useGroupStore()
|
||||
const conversationStore = useConversationStore();
|
||||
const friendStore = useFriendStore();
|
||||
const groupStore = useGroupStore();
|
||||
|
||||
/** tile 标签 / 后续聊天界面用的展示名:备注优先 */
|
||||
const displayName = computed(() => (props.friend ? getFriendDisplayName(props.friend) : ''))
|
||||
const displayName = computed(() =>
|
||||
props.friend ? getFriendDisplayName(props.friend) : '',
|
||||
);
|
||||
|
||||
const createGroupDialogRef = ref<InstanceType<typeof GroupCreateDialog>>() // 发起群聊弹窗 ref:handleOpenCreateGroup 调 open({ lockedIds }) 锁定对方
|
||||
const createGroupDialogRef = ref<InstanceType<typeof GroupCreateDialog>>(); // 发起群聊弹窗 ref:handleOpenCreateGroup 调 open({ lockedIds }) 锁定对方
|
||||
|
||||
/** 打开发起群聊弹窗:把对方默认勾上且不可取消,对应微信"基于私聊发起群聊" */
|
||||
function handleOpenCreateGroup() {
|
||||
const lockedIds = props.friend ? [props.friend.friendUserId] : []
|
||||
createGroupDialogRef.value?.open({ lockedIds })
|
||||
const lockedIds = props.friend ? [props.friend.friendUserId] : [];
|
||||
createGroupDialogRef.value?.open({ lockedIds });
|
||||
}
|
||||
|
||||
const displayNamePopoverVisible = ref(false)
|
||||
const editDisplayName = ref('')
|
||||
const displayNamePopoverVisible = ref(false);
|
||||
const editDisplayName = ref('');
|
||||
|
||||
// popover 弹出时把当前备注灌进编辑态,避免上次未保存的脏值
|
||||
watch(displayNamePopoverVisible, (open) => {
|
||||
if (open) {
|
||||
editDisplayName.value = props.friend?.displayName || ''
|
||||
editDisplayName.value = props.friend?.displayName || '';
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// 抽屉关闭时把还没收掉的 popover 一并清掉,避免下次打开闪一下
|
||||
watch(visible, (open) => {
|
||||
if (!open) {
|
||||
displayNamePopoverVisible.value = false
|
||||
displayNamePopoverVisible.value = false;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
/** 备注 popover 点击保存 */
|
||||
async function handleSaveDisplayName() {
|
||||
if (!props.friend) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
await friendStore.setFriendDisplayName(props.friend.friendUserId, editDisplayName.value)
|
||||
displayNamePopoverVisible.value = false
|
||||
message.success('保存成功')
|
||||
await friendStore.setFriendDisplayName(
|
||||
props.friend.friendUserId,
|
||||
editDisplayName.value,
|
||||
);
|
||||
displayNamePopoverVisible.value = false;
|
||||
message.success('保存成功');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,42 +104,50 @@ async function handleSaveDisplayName() {
|
||||
*/
|
||||
function handleMutedChange(value: boolean | number | string) {
|
||||
if (!props.conversation) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
const next = !!value
|
||||
const { type, targetId } = props.conversation
|
||||
conversationStore.setConversationSilent(type, targetId, next)
|
||||
const next = !!value;
|
||||
const { type, targetId } = props.conversation;
|
||||
conversationStore.setConversationSilent(type, targetId, next);
|
||||
if (type !== ImConversationType.PRIVATE) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
friendStore.setFriendSilent(targetId, next).catch((error) => {
|
||||
console.error('[IM ConversationPrivateSide] 切换免打扰失败', { targetId }, error)
|
||||
conversationStore.setConversationSilent(type, targetId, !next)
|
||||
})
|
||||
console.error(
|
||||
'[IM ConversationPrivateSide] 切换免打扰失败',
|
||||
{ targetId },
|
||||
error,
|
||||
);
|
||||
conversationStore.setConversationSilent(type, targetId, !next);
|
||||
});
|
||||
}
|
||||
|
||||
/** 切置顶:纯本地 conversationStore 排序态(无后端字段) */
|
||||
function handleTopChange(value: boolean | number | string) {
|
||||
if (!props.conversation) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
conversationStore.setConversationTop(props.conversation.type, props.conversation.targetId, !!value)
|
||||
conversationStore.setConversationTop(
|
||||
props.conversation.type,
|
||||
props.conversation.targetId,
|
||||
!!value,
|
||||
);
|
||||
}
|
||||
|
||||
/** 群创建成功:跳到新群会话 + 关掉本侧抽屉,让用户专注新群 */
|
||||
function handleGroupCreated(groupId: number) {
|
||||
const group = groupStore.getGroup(groupId)
|
||||
const group = groupStore.getGroup(groupId);
|
||||
if (!group) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
conversationStore.openConversation(
|
||||
groupId,
|
||||
ImConversationType.GROUP,
|
||||
getGroupDisplayName(group),
|
||||
group.avatar || '',
|
||||
{ silent: !!group.silent }
|
||||
)
|
||||
visible.value = false
|
||||
{ silent: !!group.silent },
|
||||
);
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -151,7 +175,9 @@ function handleGroupCreated(groupId: number) {
|
||||
<div v-else class="flex flex-col h-full bg-[var(--ant-color-bg-container)]">
|
||||
<div class="flex-1 overflow-y-auto bg-[var(--ant-color-fill-secondary)]">
|
||||
<!-- 好友宫格:原 tile + "+" tile,对齐 GroupSide 视觉,让两种抽屉看起来是一家的 -->
|
||||
<div class="flex flex-wrap gap-1 px-4 pt-4 pb-[14px] bg-[var(--ant-color-bg-container)]">
|
||||
<div
|
||||
class="flex flex-wrap gap-1 px-4 pt-4 pb-[14px] bg-[var(--ant-color-bg-container)]"
|
||||
>
|
||||
<div class="flex flex-col items-center w-[66px]">
|
||||
<UserAvatar
|
||||
:id="friend.friendUserId"
|
||||
@@ -159,7 +185,9 @@ function handleGroupCreated(groupId: number) {
|
||||
:name="friend.nickname"
|
||||
:size="50"
|
||||
/>
|
||||
<div class="w-full mt-1.5 overflow-hidden text-12px leading-[1.5] text-[var(--ant-color-text)] text-center truncate">
|
||||
<div
|
||||
class="w-full mt-1.5 overflow-hidden text-12px leading-[1.5] text-[var(--ant-color-text)] text-center truncate"
|
||||
>
|
||||
{{ displayName }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,10 +198,16 @@ function handleGroupCreated(groupId: number) {
|
||||
title="发起群聊"
|
||||
@click="handleOpenCreateGroup"
|
||||
>
|
||||
<div class="im-conversation-private-side__icon-tile flex items-center justify-center w-[50px] h-[50px] text-20px text-[var(--ant-color-text)] bg-[var(--ant-color-fill-tertiary)] border border-dashed border-[var(--ant-color-border)] rounded-md transition-colors duration-200">
|
||||
<div
|
||||
class="im-conversation-private-side__icon-tile flex items-center justify-center w-[50px] h-[50px] text-20px text-[var(--ant-color-text)] bg-[var(--ant-color-fill-tertiary)] border border-dashed border-[var(--ant-color-border)] rounded-md transition-colors duration-200"
|
||||
>
|
||||
<Icon icon="ant-design:plus-outlined" />
|
||||
</div>
|
||||
<div class="w-full mt-1.5 overflow-hidden text-12px leading-[1.5] text-[var(--ant-color-text)] text-center truncate">添加</div>
|
||||
<div
|
||||
class="w-full mt-1.5 overflow-hidden text-12px leading-[1.5] text-[var(--ant-color-text)] text-center truncate"
|
||||
>
|
||||
添加
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -190,14 +224,21 @@ function handleGroupCreated(groupId: number) {
|
||||
<div
|
||||
class="im-conversation-private-side__row flex flex-col items-stretch gap-1.5 px-4 py-[14px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--ant-color-fill-tertiary)]"
|
||||
>
|
||||
<span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">备注</span>
|
||||
<span
|
||||
class="flex-shrink-0 text-14px text-[var(--ant-color-text)]"
|
||||
>
|
||||
备注
|
||||
</span>
|
||||
<span
|
||||
v-if="friend.displayName"
|
||||
class="text-13px leading-[1.6] text-[var(--ant-color-text)] break-all line-clamp-2"
|
||||
>
|
||||
{{ friend.displayName }}
|
||||
</span>
|
||||
<span v-else class="text-13px leading-[1.6] text-[var(--ant-color-text-placeholder)]">
|
||||
<span
|
||||
v-else
|
||||
class="text-13px leading-[1.6] text-[var(--ant-color-text-placeholder)]"
|
||||
>
|
||||
好友备注仅自己可见
|
||||
</span>
|
||||
</div>
|
||||
@@ -210,8 +251,17 @@ function handleGroupCreated(groupId: number) {
|
||||
placeholder="请输入备注名"
|
||||
/>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button size="small" @click="displayNamePopoverVisible = false">取消</Button>
|
||||
<Button size="small" type="primary" @click="handleSaveDisplayName">
|
||||
<Button
|
||||
size="small"
|
||||
@click="displayNamePopoverVisible = false"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handleSaveDisplayName"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
@@ -228,7 +278,9 @@ function handleGroupCreated(groupId: number) {
|
||||
class="im-conversation-private-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--ant-color-fill-tertiary)]"
|
||||
@click="emit('openHistory')"
|
||||
>
|
||||
<span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">查找聊天内容</span>
|
||||
<span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">
|
||||
查找聊天内容
|
||||
</span>
|
||||
<Icon
|
||||
icon="ant-design:right-outlined"
|
||||
:size="11"
|
||||
@@ -241,12 +293,23 @@ function handleGroupCreated(groupId: number) {
|
||||
|
||||
<!-- 开关项 -->
|
||||
<div class="bg-[var(--ant-color-bg-container)]">
|
||||
<div class="im-conversation-private-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 transition-colors duration-150">
|
||||
<span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">消息免打扰</span>
|
||||
<Switch :checked="!!conversation?.silent" @change="handleMutedChange" />
|
||||
<div
|
||||
class="im-conversation-private-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 transition-colors duration-150"
|
||||
>
|
||||
<span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">
|
||||
消息免打扰
|
||||
</span>
|
||||
<Switch
|
||||
:checked="!!conversation?.silent"
|
||||
@change="handleMutedChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="im-conversation-private-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 transition-colors duration-150">
|
||||
<span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">置顶聊天</span>
|
||||
<div
|
||||
class="im-conversation-private-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 transition-colors duration-150"
|
||||
>
|
||||
<span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">
|
||||
置顶聊天
|
||||
</span>
|
||||
<Switch :checked="!!conversation?.top" @change="handleTopChange" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -254,21 +317,25 @@ function handleGroupCreated(groupId: number) {
|
||||
</div>
|
||||
|
||||
<!-- 子对话框:发起群聊(锁定对方为已选) -->
|
||||
<GroupCreateDialog ref="createGroupDialogRef" @created="handleGroupCreated" />
|
||||
<GroupCreateDialog
|
||||
ref="createGroupDialogRef"
|
||||
@created="handleGroupCreated"
|
||||
/>
|
||||
</Drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 「+」 tile: hover 时联动内部 icon-tile 走主色; 跨子元素的 hover 联动无法用单元素工具类表达 */
|
||||
.im-conversation-private-side__tile-wrap-clickable:hover .im-conversation-private-side__icon-tile {
|
||||
.im-conversation-private-side__tile-wrap-clickable:hover
|
||||
.im-conversation-private-side__icon-tile {
|
||||
color: var(--ant-color-primary);
|
||||
border-color: var(--ant-color-primary);
|
||||
background-color: var(--ant-color-primary-bg);
|
||||
border-color: var(--ant-color-primary);
|
||||
}
|
||||
|
||||
/* :deep 穿透 Icon 内部 svg; el-icon 全局 color 在暗色模式下被主题盖过,锁 fill 到当前色 */
|
||||
.im-conversation-private-side__icon-tile :deep(svg) {
|
||||
fill: currentColor !important;
|
||||
fill: currentcolor !important;
|
||||
}
|
||||
|
||||
/* 相邻信息行加分隔线; 相邻兄弟选择器无法用工具类表达 */
|
||||
|
||||
@@ -340,14 +340,14 @@ onUnmounted(() => {
|
||||
<style scoped>
|
||||
/* 底部小三角:指向触发图标,仿微信 PC 气泡指针;left 偏移对应表情按钮(工具栏 1st icon) */
|
||||
.im-popover-arrow::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: calc(100% - 1px);
|
||||
left: 10px;
|
||||
border-style: solid;
|
||||
border-width: 6px 6px 0 6px;
|
||||
content: '';
|
||||
border-color: var(--ant-color-bg-container) transparent transparent transparent;
|
||||
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.08));
|
||||
border-style: solid;
|
||||
border-width: 6px 6px 0;
|
||||
filter: drop-shadow(0 2px 2px rgb(0 0 0 / 8%));
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1167,8 +1167,9 @@ async function onVideoPicked(e: Event) {
|
||||
.message-input__tool:deep(svg) {
|
||||
font-size: 18px !important;
|
||||
color: var(--ant-color-text) !important;
|
||||
fill: currentColor !important;
|
||||
fill: currentcolor !important;
|
||||
}
|
||||
|
||||
.message-input__tool:hover,
|
||||
.message-input__tool:hover:deep(svg) {
|
||||
color: var(--ant-color-primary) !important;
|
||||
@@ -1203,10 +1204,10 @@ async function onVideoPicked(e: Event) {
|
||||
|
||||
/* 用 data-empty 而非 :empty:浏览器在删空后会留下 <br>,:empty 不命中;data-empty 由 syncEditorState 维护 */
|
||||
.message-input__editor[data-empty]::before {
|
||||
content: attr(data-placeholder);
|
||||
position: absolute;
|
||||
color: var(--ant-color-text-placeholder);
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
content: attr(data-placeholder);
|
||||
}
|
||||
|
||||
/* @ token 走主色高亮;contenteditable=false 让 backspace 整段删而不是逐字符 */
|
||||
|
||||
@@ -311,14 +311,14 @@ onUnmounted(() => {
|
||||
<style scoped>
|
||||
/* 底部小三角 */
|
||||
.im-popover-arrow::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: calc(100% - 1px);
|
||||
left: 110px;
|
||||
border-style: solid;
|
||||
border-width: 6px 6px 0 6px;
|
||||
content: '';
|
||||
border-color: var(--ant-color-bg-container) transparent transparent transparent;
|
||||
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.08));
|
||||
border-style: solid;
|
||||
border-width: 6px 6px 0;
|
||||
filter: drop-shadow(0 2px 2px rgb(0 0 0 / 8%));
|
||||
}
|
||||
|
||||
/* 录音中的脉冲动画 */
|
||||
@@ -328,13 +328,15 @@ onUnmounted(() => {
|
||||
|
||||
@keyframes im-voice-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(245, 108, 108, 0.6);
|
||||
box-shadow: 0 0 0 0 rgb(245 108 108 / 60%);
|
||||
}
|
||||
|
||||
70% {
|
||||
box-shadow: 0 0 0 20px rgba(245, 108, 108, 0);
|
||||
box-shadow: 0 0 0 20px rgb(245 108 108 / 0%);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(245, 108, 108, 0);
|
||||
box-shadow: 0 0 0 0 rgb(245 108 108 / 0%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -193,15 +193,15 @@ async function handleRemove(pinnedMessage: Message) {
|
||||
<style scoped>
|
||||
/* 弹出层朝上的三角箭头;走 ::before + 4 边 border 配色画,颜色跟弹出层 background 一致 */
|
||||
.im-group-pinned-message__list::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 184px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 8px solid transparent;
|
||||
content: '';
|
||||
border-right: 8px solid transparent;
|
||||
border-bottom: 8px solid var(--ant-color-bg-container);
|
||||
filter: drop-shadow(0 -2px 1px rgba(0, 0, 0, 0.04));
|
||||
border-left: 8px solid transparent;
|
||||
filter: drop-shadow(0 -2px 1px rgb(0 0 0 / 4%));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -83,6 +83,6 @@ const pendingCount = computed(() => groupRequestStore.getUnhandledGroupRequestCo
|
||||
<style scoped>
|
||||
/* :deep 穿透 Icon 子组件 DOM;强制 svg 走 currentColor 应对暗色模式 el-icon 全局色覆盖 */
|
||||
.im-group-request-pending__icon :deep(svg) {
|
||||
fill: currentColor !important;
|
||||
fill: currentcolor !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -145,7 +145,7 @@ const onClick = async () => {
|
||||
transition: box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 2px 12px rgb(0 0 0 / 8%);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -359,29 +359,33 @@ onBeforeUnmount(() => {
|
||||
颜色与气泡背景对应,留 1px 视觉吃进去,省一张图片 */
|
||||
.message-bubble--other::before,
|
||||
.message-bubble--self::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
content: '';
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.message-bubble--other {
|
||||
--im-message-bubble-other-bg: #f5f5f5;
|
||||
}
|
||||
|
||||
.message-bubble--other.message-bubble--text,
|
||||
.message-bubble--other.message-bubble--voice {
|
||||
background-color: var(--im-message-bubble-other-bg);
|
||||
}
|
||||
|
||||
.message-bubble--other::before {
|
||||
left: -5px;
|
||||
border-width: 5px 6px 5px 0;
|
||||
border-color: transparent var(--im-message-bubble-other-bg) transparent transparent;
|
||||
border-width: 5px 6px 5px 0;
|
||||
}
|
||||
|
||||
.message-bubble--self::before {
|
||||
right: -5px;
|
||||
border-width: 5px 0 5px 6px;
|
||||
border-color: transparent transparent transparent #95ec69;
|
||||
border-width: 5px 0 5px 6px;
|
||||
}
|
||||
|
||||
/* 整体放进 :global(),避免 Vue scoped 把 `:global(.dark) .xxx` 塌缩成裸 `.dark` 而把变量刷到 <html> */
|
||||
@@ -393,6 +397,7 @@ onBeforeUnmount(() => {
|
||||
.message-bubble__voice-icon :deep(svg) {
|
||||
fill: #606266 !important;
|
||||
}
|
||||
|
||||
.message-bubble__voice-icon.im-voice-playing :deep(svg) {
|
||||
fill: #409eff !important;
|
||||
}
|
||||
@@ -407,6 +412,7 @@ onBeforeUnmount(() => {
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
@@ -754,16 +754,18 @@ function locateMessage(messageId: number) {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
display: none;
|
||||
white-space: nowrap;
|
||||
margin-top: 4px;
|
||||
color: #1989fa;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.im-message-history__row:hover .im-message-history__locate {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.im-message-history__locate:hover {
|
||||
color: #146fc7;
|
||||
}
|
||||
@@ -775,16 +777,18 @@ function locateMessage(messageId: number) {
|
||||
color: #1989fa;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.im-message-history__tab:hover {
|
||||
color: #2f81d4;
|
||||
}
|
||||
|
||||
.im-message-history__tab--active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
content: '';
|
||||
background: #1989fa;
|
||||
border-radius: 1px;
|
||||
}
|
||||
@@ -793,15 +797,18 @@ function locateMessage(messageId: number) {
|
||||
.im-message-history__calendar :deep(.ant-picker-calendar-header) {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.im-message-history__calendar :deep(.ant-picker-content) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.im-message-history__calendar :deep(.ant-picker-cell) {
|
||||
padding: 1px 0;
|
||||
}
|
||||
|
||||
.im-message-history__calendar :deep(.ant-picker-cell .ant-picker-calendar-date) {
|
||||
height: 28px;
|
||||
margin: 0 2px;
|
||||
padding: 2px 4px 0;
|
||||
margin: 0 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1047,6 +1047,7 @@ function handleDelete() {
|
||||
.im-loading-spin {
|
||||
animation: im-loading-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes im-loading-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
|
||||
@@ -752,7 +752,7 @@ watch(
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--ant-color-text) !important;
|
||||
fill: currentColor !important;
|
||||
fill: currentcolor !important;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
@@ -760,6 +760,7 @@ watch(
|
||||
box-sizing: content-box;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.message-panel__header-icon:hover,
|
||||
.message-panel__header-icon:hover :deep(svg) {
|
||||
color: var(--ant-color-primary) !important;
|
||||
@@ -777,6 +778,7 @@ watch(
|
||||
.message-panel__message-anchor {
|
||||
transition: background-color 0.6s ease;
|
||||
}
|
||||
|
||||
.message-panel__message-anchor--highlight {
|
||||
background-color: var(--ant-color-warning-bg);
|
||||
}
|
||||
|
||||
@@ -334,17 +334,18 @@ function updateLocalResult(id: number, handleResult: number) {
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
transition:
|
||||
background-color 0.15s,
|
||||
border-color 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.im-group-request-list__btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.im-group-request-list__btn--primary {
|
||||
@@ -352,6 +353,7 @@ function updateLocalResult(id: number, handleResult: number) {
|
||||
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);
|
||||
@@ -362,6 +364,7 @@ function updateLocalResult(id: number, handleResult: number) {
|
||||
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);
|
||||
|
||||
@@ -363,6 +363,7 @@ function handleToggle(conversation: Conversation) {
|
||||
.im-conversation-picker__recent::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.im-conversation-picker__recent::-webkit-scrollbar-thumb {
|
||||
background-color: var(--ant-color-border);
|
||||
border-radius: 2px;
|
||||
|
||||
@@ -97,12 +97,14 @@ const setAudioRef = useMediaStreamElement<HTMLAudioElement>(() => props.particip
|
||||
.tile-dot {
|
||||
animation: tile-dot 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes tile-dot {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
40% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -279,11 +279,13 @@ const formattedDuration = computed(() =>
|
||||
.reconnect-dot {
|
||||
animation: reconnect-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes reconnect-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -255,6 +255,7 @@ watch(
|
||||
--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 的干净白差距明显。
|
||||
|
||||
@@ -808,13 +808,13 @@ function handleOpenTransferOwner() {
|
||||
/* 「添加 / 移出」瓦片:hover 时联动内部 icon-tile 走主色,跨子元素的 hover 联动无法用单元素工具类表达 */
|
||||
.im-conversation-group-side__tile-wrap:hover .im-conversation-group-side__icon-tile {
|
||||
color: var(--ant-color-primary);
|
||||
border-color: var(--ant-color-primary);
|
||||
background-color: var(--ant-color-primary-bg);
|
||||
border-color: var(--ant-color-primary);
|
||||
}
|
||||
|
||||
/* :deep 穿透 Icon 内部 svg; el-icon 全局 color 在暗色模式下被主题盖过,锁 fill 到当前色 */
|
||||
.im-conversation-group-side__icon-tile :deep(svg) {
|
||||
fill: currentColor !important;
|
||||
fill: currentcolor !important;
|
||||
}
|
||||
|
||||
/* 相邻信息行加分隔线; 相邻兄弟选择器无法用工具类表达 */
|
||||
|
||||
@@ -261,13 +261,13 @@ function handleGroupCreated(groupId: number) {
|
||||
/* 「+」 tile: hover 时联动内部 icon-tile 走主色; 跨子元素的 hover 联动无法用单元素工具类表达 */
|
||||
.im-conversation-private-side__tile-wrap-clickable:hover .im-conversation-private-side__icon-tile {
|
||||
color: var(--ant-color-primary);
|
||||
border-color: var(--ant-color-primary);
|
||||
background-color: var(--ant-color-primary-bg);
|
||||
border-color: var(--ant-color-primary);
|
||||
}
|
||||
|
||||
/* :deep 穿透 Icon 内部 svg; el-icon 全局 color 在暗色模式下被主题盖过,锁 fill 到当前色 */
|
||||
.im-conversation-private-side__icon-tile :deep(svg) {
|
||||
fill: currentColor !important;
|
||||
fill: currentcolor !important;
|
||||
}
|
||||
|
||||
/* 相邻信息行加分隔线; 相邻兄弟选择器无法用工具类表达 */
|
||||
|
||||
@@ -340,14 +340,14 @@ onUnmounted(() => {
|
||||
<style scoped>
|
||||
/* 底部小三角:指向触发图标,仿微信 PC 气泡指针;left 偏移对应表情按钮(工具栏 1st icon) */
|
||||
.im-popover-arrow::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: calc(100% - 1px);
|
||||
left: 10px;
|
||||
border-style: solid;
|
||||
border-width: 6px 6px 0 6px;
|
||||
content: '';
|
||||
border-color: var(--ant-color-bg-container) transparent transparent transparent;
|
||||
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.08));
|
||||
border-style: solid;
|
||||
border-width: 6px 6px 0;
|
||||
filter: drop-shadow(0 2px 2px rgb(0 0 0 / 8%));
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1168,8 +1168,9 @@ async function onVideoPicked(e: Event) {
|
||||
.message-input__tool:deep(svg) {
|
||||
font-size: 18px !important;
|
||||
color: var(--ant-color-text) !important;
|
||||
fill: currentColor !important;
|
||||
fill: currentcolor !important;
|
||||
}
|
||||
|
||||
.message-input__tool:hover,
|
||||
.message-input__tool:hover:deep(svg) {
|
||||
color: var(--ant-color-primary) !important;
|
||||
@@ -1204,10 +1205,10 @@ async function onVideoPicked(e: Event) {
|
||||
|
||||
/* 用 data-empty 而非 :empty:浏览器在删空后会留下 <br>,:empty 不命中;data-empty 由 syncEditorState 维护 */
|
||||
.message-input__editor[data-empty]::before {
|
||||
content: attr(data-placeholder);
|
||||
position: absolute;
|
||||
color: var(--ant-color-text-placeholder);
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
content: attr(data-placeholder);
|
||||
}
|
||||
|
||||
/* @ token 走主色高亮;contenteditable=false 让 backspace 整段删而不是逐字符 */
|
||||
|
||||
@@ -311,14 +311,14 @@ onUnmounted(() => {
|
||||
<style scoped>
|
||||
/* 底部小三角 */
|
||||
.im-popover-arrow::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: calc(100% - 1px);
|
||||
left: 110px;
|
||||
border-style: solid;
|
||||
border-width: 6px 6px 0 6px;
|
||||
content: '';
|
||||
border-color: var(--ant-color-bg-container) transparent transparent transparent;
|
||||
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.08));
|
||||
border-style: solid;
|
||||
border-width: 6px 6px 0;
|
||||
filter: drop-shadow(0 2px 2px rgb(0 0 0 / 8%));
|
||||
}
|
||||
|
||||
/* 录音中的脉冲动画 */
|
||||
@@ -328,13 +328,15 @@ onUnmounted(() => {
|
||||
|
||||
@keyframes im-voice-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(245, 108, 108, 0.6);
|
||||
box-shadow: 0 0 0 0 rgb(245 108 108 / 60%);
|
||||
}
|
||||
|
||||
70% {
|
||||
box-shadow: 0 0 0 20px rgba(245, 108, 108, 0);
|
||||
box-shadow: 0 0 0 20px rgb(245 108 108 / 0%);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(245, 108, 108, 0);
|
||||
box-shadow: 0 0 0 0 rgb(245 108 108 / 0%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -193,15 +193,15 @@ async function handleRemove(pinnedMessage: Message) {
|
||||
<style scoped>
|
||||
/* 弹出层朝上的三角箭头;走 ::before + 4 边 border 配色画,颜色跟弹出层 background 一致 */
|
||||
.im-group-pinned-message__list::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 184px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 8px solid transparent;
|
||||
content: '';
|
||||
border-right: 8px solid transparent;
|
||||
border-bottom: 8px solid var(--ant-color-bg-container);
|
||||
filter: drop-shadow(0 -2px 1px rgba(0, 0, 0, 0.04));
|
||||
border-left: 8px solid transparent;
|
||||
filter: drop-shadow(0 -2px 1px rgb(0 0 0 / 4%));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -83,6 +83,6 @@ const pendingCount = computed(() => groupRequestStore.getUnhandledGroupRequestCo
|
||||
<style scoped>
|
||||
/* :deep 穿透 Icon 子组件 DOM;强制 svg 走 currentColor 应对暗色模式 el-icon 全局色覆盖 */
|
||||
.im-group-request-pending__icon :deep(svg) {
|
||||
fill: currentColor !important;
|
||||
fill: currentcolor !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -145,7 +145,7 @@ const onClick = async () => {
|
||||
transition: box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 2px 12px rgb(0 0 0 / 8%);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -359,29 +359,33 @@ onBeforeUnmount(() => {
|
||||
颜色与气泡背景对应,留 1px 视觉吃进去,省一张图片 */
|
||||
.message-bubble--other::before,
|
||||
.message-bubble--self::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
content: '';
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.message-bubble--other {
|
||||
--im-message-bubble-other-bg: #f5f5f5;
|
||||
}
|
||||
|
||||
.message-bubble--other.message-bubble--text,
|
||||
.message-bubble--other.message-bubble--voice {
|
||||
background-color: var(--im-message-bubble-other-bg);
|
||||
}
|
||||
|
||||
.message-bubble--other::before {
|
||||
left: -5px;
|
||||
border-width: 5px 6px 5px 0;
|
||||
border-color: transparent var(--im-message-bubble-other-bg) transparent transparent;
|
||||
border-width: 5px 6px 5px 0;
|
||||
}
|
||||
|
||||
.message-bubble--self::before {
|
||||
right: -5px;
|
||||
border-width: 5px 0 5px 6px;
|
||||
border-color: transparent transparent transparent #95ec69;
|
||||
border-width: 5px 0 5px 6px;
|
||||
}
|
||||
|
||||
/* 整体放进 :global(),避免 Vue scoped 把 `:global(.dark) .xxx` 塌缩成裸 `.dark` 而把变量刷到 <html> */
|
||||
@@ -393,6 +397,7 @@ onBeforeUnmount(() => {
|
||||
.message-bubble__voice-icon :deep(svg) {
|
||||
fill: #606266 !important;
|
||||
}
|
||||
|
||||
.message-bubble__voice-icon.im-voice-playing :deep(svg) {
|
||||
fill: #409eff !important;
|
||||
}
|
||||
@@ -407,6 +412,7 @@ onBeforeUnmount(() => {
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
@@ -754,16 +754,18 @@ function locateMessage(messageId: number) {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
display: none;
|
||||
white-space: nowrap;
|
||||
margin-top: 4px;
|
||||
color: #1989fa;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.im-message-history__row:hover .im-message-history__locate {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.im-message-history__locate:hover {
|
||||
color: #146fc7;
|
||||
}
|
||||
@@ -775,16 +777,18 @@ function locateMessage(messageId: number) {
|
||||
color: #1989fa;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.im-message-history__tab:hover {
|
||||
color: #2f81d4;
|
||||
}
|
||||
|
||||
.im-message-history__tab--active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
content: '';
|
||||
background: #1989fa;
|
||||
border-radius: 1px;
|
||||
}
|
||||
@@ -793,15 +797,18 @@ function locateMessage(messageId: number) {
|
||||
.im-message-history__calendar :deep(.ant-picker-calendar-header) {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.im-message-history__calendar :deep(.ant-picker-content) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.im-message-history__calendar :deep(.ant-picker-cell) {
|
||||
padding: 1px 0;
|
||||
}
|
||||
|
||||
.im-message-history__calendar :deep(.ant-picker-cell .ant-picker-calendar-date) {
|
||||
height: 28px;
|
||||
margin: 0 2px;
|
||||
padding: 2px 4px 0;
|
||||
margin: 0 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1047,6 +1047,7 @@ function handleDelete() {
|
||||
.im-loading-spin {
|
||||
animation: im-loading-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes im-loading-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
|
||||
@@ -363,6 +363,7 @@ function handleToggle(conversation: Conversation) {
|
||||
.im-conversation-picker__recent::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.im-conversation-picker__recent::-webkit-scrollbar-thumb {
|
||||
background-color: var(--ant-color-border);
|
||||
border-radius: 2px;
|
||||
|
||||
@@ -97,12 +97,14 @@ const setAudioRef = useMediaStreamElement<HTMLAudioElement>(() => props.particip
|
||||
.tile-dot {
|
||||
animation: tile-dot 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes tile-dot {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
40% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -279,11 +279,13 @@ const formattedDuration = computed(() =>
|
||||
.reconnect-dot {
|
||||
animation: reconnect-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes reconnect-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -254,6 +254,7 @@ watch(
|
||||
--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 的干净白差距明显。
|
||||
|
||||
@@ -812,13 +812,13 @@ function handleOpenTransferOwner() {
|
||||
/* 「添加 / 移出」瓦片:hover 时联动内部 icon-tile 走主色,跨子元素的 hover 联动无法用单元素工具类表达 */
|
||||
.im-conversation-group-side__tile-wrap:hover .im-conversation-group-side__icon-tile {
|
||||
color: var(--ant-color-primary);
|
||||
border-color: var(--ant-color-primary);
|
||||
background-color: var(--ant-color-primary-bg);
|
||||
border-color: var(--ant-color-primary);
|
||||
}
|
||||
|
||||
/* :deep 穿透 Icon 内部 svg; el-icon 全局 color 在暗色模式下被主题盖过,锁 fill 到当前色 */
|
||||
.im-conversation-group-side__icon-tile :deep(svg) {
|
||||
fill: currentColor !important;
|
||||
fill: currentcolor !important;
|
||||
}
|
||||
|
||||
/* 相邻信息行加分隔线; 相邻兄弟选择器无法用工具类表达 */
|
||||
|
||||
@@ -340,14 +340,14 @@ onUnmounted(() => {
|
||||
<style scoped>
|
||||
/* 底部小三角:指向触发图标,仿微信 PC 气泡指针;left 偏移对应表情按钮(工具栏 1st icon) */
|
||||
.im-popover-arrow::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: calc(100% - 1px);
|
||||
left: 10px;
|
||||
border-style: solid;
|
||||
border-width: 6px 6px 0 6px;
|
||||
content: '';
|
||||
border-color: var(--ant-color-bg-container) transparent transparent transparent;
|
||||
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.08));
|
||||
border-style: solid;
|
||||
border-width: 6px 6px 0;
|
||||
filter: drop-shadow(0 2px 2px rgb(0 0 0 / 8%));
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1168,8 +1168,9 @@ async function onVideoPicked(e: Event) {
|
||||
.message-input__tool:deep(svg) {
|
||||
font-size: 18px !important;
|
||||
color: var(--ant-color-text) !important;
|
||||
fill: currentColor !important;
|
||||
fill: currentcolor !important;
|
||||
}
|
||||
|
||||
.message-input__tool:hover,
|
||||
.message-input__tool:hover:deep(svg) {
|
||||
color: var(--ant-color-primary) !important;
|
||||
@@ -1204,10 +1205,10 @@ async function onVideoPicked(e: Event) {
|
||||
|
||||
/* 用 data-empty 而非 :empty:浏览器在删空后会留下 <br>,:empty 不命中;data-empty 由 syncEditorState 维护 */
|
||||
.message-input__editor[data-empty]::before {
|
||||
content: attr(data-placeholder);
|
||||
position: absolute;
|
||||
color: var(--ant-color-text-placeholder);
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
content: attr(data-placeholder);
|
||||
}
|
||||
|
||||
/* @ token 走主色高亮;contenteditable=false 让 backspace 整段删而不是逐字符 */
|
||||
|
||||
@@ -6,257 +6,276 @@ import {
|
||||
onUnmounted,
|
||||
ref,
|
||||
useTemplateRef,
|
||||
watch
|
||||
} from 'vue'
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import { ElButton, ElMessage } from 'element-plus'
|
||||
import { ElButton, ElMessage } from 'element-plus';
|
||||
|
||||
import { formatSeconds } from '#/views/im/utils/time'
|
||||
import { formatSeconds } from '#/views/im/utils/time';
|
||||
|
||||
defineOptions({ name: 'ImVoiceRecorder' })
|
||||
defineOptions({ name: 'ImVoiceRecorder' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
maxDuration?: number // 最长录制秒数
|
||||
modelValue: boolean // 是否显示
|
||||
maxDuration?: number; // 最长录制秒数
|
||||
modelValue: boolean; // 是否显示
|
||||
}>(),
|
||||
{
|
||||
maxDuration: 60
|
||||
}
|
||||
)
|
||||
maxDuration: 60,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
send: [payload: { blob: Blob; duration: number; extension: string; mimeType: string }] // 录制完成数据
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
send: [
|
||||
payload: {
|
||||
blob: Blob;
|
||||
duration: number;
|
||||
extension: string;
|
||||
mimeType: string;
|
||||
},
|
||||
]; // 录制完成数据
|
||||
'update:modelValue': [value: boolean];
|
||||
}>();
|
||||
|
||||
const VOICE_MIME_TYPE_OPTIONS = [
|
||||
{ extension: 'webm', mimeType: 'audio/webm;codecs=opus' },
|
||||
{ extension: 'webm', mimeType: 'audio/webm' },
|
||||
{ extension: 'm4a', mimeType: 'audio/mp4' },
|
||||
{ extension: 'ogg', mimeType: 'audio/ogg;codecs=opus' },
|
||||
{ extension: 'ogg', mimeType: 'audio/ogg' }
|
||||
]
|
||||
{ extension: 'ogg', mimeType: 'audio/ogg' },
|
||||
];
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
})
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
});
|
||||
|
||||
const rootRef = useTemplateRef<HTMLDivElement>('rootRef')
|
||||
const rootRef = useTemplateRef<HTMLDivElement>('rootRef');
|
||||
|
||||
/** 录制状态 */
|
||||
type Status = 'idle' | 'preview' | 'recording'
|
||||
const status = ref<Status>('idle')
|
||||
const duration = ref(0)
|
||||
const previewUrl = ref('')
|
||||
type Status = 'idle' | 'preview' | 'recording';
|
||||
const status = ref<Status>('idle');
|
||||
const duration = ref(0);
|
||||
const previewUrl = ref('');
|
||||
|
||||
let mediaRecorder: MediaRecorder | null = null
|
||||
let audioChunks: Blob[] = []
|
||||
let mediaStream: MediaStream | null = null
|
||||
let timer: null | ReturnType<typeof setInterval> = null
|
||||
let recordedBlob: Blob | null = null
|
||||
let discarding = false
|
||||
let recordingMimeType = ''
|
||||
let recordingExtension = ''
|
||||
let recordedMimeType = ''
|
||||
let recordedExtension = ''
|
||||
let mediaRecorder: MediaRecorder | null = null;
|
||||
let audioChunks: Blob[] = [];
|
||||
let mediaStream: MediaStream | null = null;
|
||||
let timer: null | ReturnType<typeof setInterval> = null;
|
||||
let recordedBlob: Blob | null = null;
|
||||
let discarding = false;
|
||||
let recordingMimeType = '';
|
||||
let recordingExtension = '';
|
||||
let recordedMimeType = '';
|
||||
let recordedExtension = '';
|
||||
|
||||
/** 计时器文案 */
|
||||
const timerText = computed(() => formatSeconds(duration.value))
|
||||
const timerText = computed(() => formatSeconds(duration.value));
|
||||
|
||||
/** 监听面板显示状态 */
|
||||
watch(visible, (v) => {
|
||||
if (v) {
|
||||
document.addEventListener('click', handleDocumentClick)
|
||||
document.addEventListener('click', handleDocumentClick);
|
||||
} else {
|
||||
document.removeEventListener('click', handleDocumentClick)
|
||||
resetAll()
|
||||
document.removeEventListener('click', handleDocumentClick);
|
||||
resetAll();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
/** 处理外部点击 */
|
||||
function handleDocumentClick(e: MouseEvent) {
|
||||
if (!props.modelValue || !rootRef.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (rootRef.value.contains(e.target as Node)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (status.value !== 'idle') {
|
||||
return
|
||||
return;
|
||||
}
|
||||
visible.value = false
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
/** 获取支持的录音格式 */
|
||||
function getSupportedVoiceMimeType() {
|
||||
if (typeof MediaRecorder === 'undefined' || !MediaRecorder.isTypeSupported) {
|
||||
return undefined
|
||||
return undefined;
|
||||
}
|
||||
return VOICE_MIME_TYPE_OPTIONS.find((item) => MediaRecorder.isTypeSupported(item.mimeType))
|
||||
return VOICE_MIME_TYPE_OPTIONS.find((item) =>
|
||||
MediaRecorder.isTypeSupported(item.mimeType),
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取录音文件后缀 */
|
||||
function getVoiceExtension(mimeType: string) {
|
||||
const normalizedMimeType = mimeType.split(';')[0]
|
||||
const normalizedMimeType = mimeType.split(';')[0];
|
||||
if (normalizedMimeType === 'audio/mp4') {
|
||||
return 'm4a'
|
||||
return 'm4a';
|
||||
}
|
||||
if (normalizedMimeType === 'audio/ogg') {
|
||||
return 'ogg'
|
||||
return 'ogg';
|
||||
}
|
||||
return 'webm'
|
||||
return 'webm';
|
||||
}
|
||||
|
||||
/** 创建录音器 */
|
||||
function createVoiceRecorder(stream: MediaStream) {
|
||||
const supportedMimeType = getSupportedVoiceMimeType()
|
||||
const supportedMimeType = getSupportedVoiceMimeType();
|
||||
const recorder = supportedMimeType
|
||||
? new MediaRecorder(stream, { mimeType: supportedMimeType.mimeType })
|
||||
: new MediaRecorder(stream)
|
||||
recordingMimeType = recorder.mimeType || supportedMimeType?.mimeType || ''
|
||||
recordingExtension = supportedMimeType?.extension || (recordingMimeType ? getVoiceExtension(recordingMimeType) : '')
|
||||
return recorder
|
||||
: new MediaRecorder(stream);
|
||||
recordingMimeType = recorder.mimeType || supportedMimeType?.mimeType || '';
|
||||
recordingExtension =
|
||||
supportedMimeType?.extension ||
|
||||
(recordingMimeType ? getVoiceExtension(recordingMimeType) : '');
|
||||
return recorder;
|
||||
}
|
||||
|
||||
/** 开始录制 */
|
||||
async function startRecord() {
|
||||
if (typeof MediaRecorder === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
|
||||
ElMessage.error('当前浏览器不支持录音(需要 HTTPS 或 localhost)')
|
||||
return
|
||||
if (
|
||||
typeof MediaRecorder === 'undefined' ||
|
||||
!navigator.mediaDevices?.getUserMedia
|
||||
) {
|
||||
ElMessage.error('当前浏览器不支持录音(需要 HTTPS 或 localhost)');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
} catch {
|
||||
ElMessage.error('无法获取麦克风权限')
|
||||
return
|
||||
ElMessage.error('无法获取麦克风权限');
|
||||
return;
|
||||
}
|
||||
audioChunks = []
|
||||
discarding = false
|
||||
audioChunks = [];
|
||||
discarding = false;
|
||||
try {
|
||||
mediaRecorder = createVoiceRecorder(mediaStream)
|
||||
mediaRecorder = createVoiceRecorder(mediaStream);
|
||||
} catch {
|
||||
cleanupStream()
|
||||
ElMessage.error('当前浏览器不支持录音格式')
|
||||
return
|
||||
cleanupStream();
|
||||
ElMessage.error('当前浏览器不支持录音格式');
|
||||
return;
|
||||
}
|
||||
mediaRecorder.addEventListener('dataavailable', (event: BlobEvent) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunks.push(event.data)
|
||||
audioChunks.push(event.data);
|
||||
}
|
||||
})
|
||||
});
|
||||
mediaRecorder.addEventListener('stop', () => {
|
||||
if (discarding) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
recordedMimeType = recordingMimeType || mediaRecorder?.mimeType || audioChunks[0]?.type || 'audio/webm'
|
||||
recordedExtension = recordingExtension || getVoiceExtension(recordedMimeType)
|
||||
recordedBlob = new Blob(audioChunks, { type: recordedMimeType })
|
||||
previewUrl.value = URL.createObjectURL(recordedBlob)
|
||||
status.value = 'preview'
|
||||
})
|
||||
mediaRecorder.start()
|
||||
status.value = 'recording'
|
||||
duration.value = 0
|
||||
recordedMimeType =
|
||||
recordingMimeType ||
|
||||
mediaRecorder?.mimeType ||
|
||||
audioChunks[0]?.type ||
|
||||
'audio/webm';
|
||||
recordedExtension =
|
||||
recordingExtension || getVoiceExtension(recordedMimeType);
|
||||
recordedBlob = new Blob(audioChunks, { type: recordedMimeType });
|
||||
previewUrl.value = URL.createObjectURL(recordedBlob);
|
||||
status.value = 'preview';
|
||||
});
|
||||
mediaRecorder.start();
|
||||
status.value = 'recording';
|
||||
duration.value = 0;
|
||||
timer = setInterval(() => {
|
||||
duration.value++
|
||||
duration.value++;
|
||||
if (duration.value >= props.maxDuration) {
|
||||
stopRecord()
|
||||
stopRecord();
|
||||
}
|
||||
}, 1000)
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/** 停止录制 */
|
||||
function stopRecord() {
|
||||
if (duration.value < 1) {
|
||||
ElMessage.warning('录音时间太短')
|
||||
resetAll()
|
||||
return
|
||||
ElMessage.warning('录音时间太短');
|
||||
resetAll();
|
||||
return;
|
||||
}
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
mediaRecorder.stop()
|
||||
mediaRecorder.stop();
|
||||
}
|
||||
cleanupStream()
|
||||
cleanupStream();
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 重新录制 */
|
||||
function restart() {
|
||||
clearPreview()
|
||||
duration.value = 0
|
||||
status.value = 'idle'
|
||||
clearPreview();
|
||||
duration.value = 0;
|
||||
status.value = 'idle';
|
||||
}
|
||||
|
||||
/** 发送录音 */
|
||||
function handleSend() {
|
||||
if (!recordedBlob) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
emit('send', {
|
||||
blob: recordedBlob,
|
||||
duration: duration.value,
|
||||
extension: recordedExtension,
|
||||
mimeType: recordedMimeType || recordedBlob.type
|
||||
})
|
||||
visible.value = false
|
||||
mimeType: recordedMimeType || recordedBlob.type,
|
||||
});
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
/** 取消录制 */
|
||||
function handleCancel() {
|
||||
visible.value = false
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
/** 重置录制资源 */
|
||||
function resetAll() {
|
||||
discarding = true
|
||||
discarding = true;
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
mediaRecorder.stop()
|
||||
mediaRecorder.stop();
|
||||
}
|
||||
cleanupStream()
|
||||
cleanupStream();
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
audioChunks = []
|
||||
duration.value = 0
|
||||
status.value = 'idle'
|
||||
recordingMimeType = ''
|
||||
recordingExtension = ''
|
||||
recordedMimeType = ''
|
||||
recordedExtension = ''
|
||||
clearPreview()
|
||||
audioChunks = [];
|
||||
duration.value = 0;
|
||||
status.value = 'idle';
|
||||
recordingMimeType = '';
|
||||
recordingExtension = '';
|
||||
recordedMimeType = '';
|
||||
recordedExtension = '';
|
||||
clearPreview();
|
||||
}
|
||||
|
||||
/** 释放预览音频 */
|
||||
function clearPreview() {
|
||||
recordedBlob = null
|
||||
recordedBlob = null;
|
||||
if (previewUrl.value) {
|
||||
URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = ''
|
||||
URL.revokeObjectURL(previewUrl.value);
|
||||
previewUrl.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
/** 关闭麦克风采集 */
|
||||
function cleanupStream() {
|
||||
mediaStream?.getTracks().forEach((t) => t.stop())
|
||||
mediaStream = null
|
||||
mediaStream?.getTracks().forEach((t) => t.stop());
|
||||
mediaStream = null;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.modelValue) {
|
||||
document.addEventListener('click', handleDocumentClick)
|
||||
document.addEventListener('click', handleDocumentClick);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
onBeforeUnmount(resetAll)
|
||||
onBeforeUnmount(resetAll);
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleDocumentClick)
|
||||
})
|
||||
document.removeEventListener('click', handleDocumentClick);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -269,14 +288,18 @@ onUnmounted(() => {
|
||||
>
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<!-- 录制时长 -->
|
||||
<div class="text-[28px] font-medium tabular-nums text-[var(--ant-color-text)]">
|
||||
<div
|
||||
class="text-[28px] font-medium tabular-nums text-[var(--ant-color-text)]"
|
||||
>
|
||||
{{ timerText }}
|
||||
</div>
|
||||
|
||||
<!-- 状态文案 -->
|
||||
<div class="text-13px text-[var(--ant-color-text-secondary)]">
|
||||
<span v-if="status === 'idle'">点击下方按钮开始录制</span>
|
||||
<span v-else-if="status === 'recording'">录制中,最长 {{ maxDuration }} 秒</span>
|
||||
<span v-else-if="status === 'recording'"
|
||||
>录制中,最长 {{ maxDuration }} 秒</span
|
||||
>
|
||||
<span v-else>录制完成,可试听后发送</span>
|
||||
</div>
|
||||
|
||||
@@ -284,7 +307,9 @@ onUnmounted(() => {
|
||||
<div
|
||||
v-if="status !== 'preview'"
|
||||
class="w-12 h-12 rounded-full bg-[var(--ant-color-border)]"
|
||||
:class="{ 'im-voice-recorder__pulse bg-[#f56c6c]': status === 'recording' }"
|
||||
:class="{
|
||||
'im-voice-recorder__pulse bg-[#f56c6c]': status === 'recording',
|
||||
}"
|
||||
></div>
|
||||
<audio v-else :src="previewUrl" controls class="w-full"></audio>
|
||||
</div>
|
||||
@@ -293,16 +318,22 @@ onUnmounted(() => {
|
||||
<div class="flex justify-end gap-2 mt-3">
|
||||
<template v-if="status === 'idle'">
|
||||
<ElButton size="small" @click="handleCancel">取消</ElButton>
|
||||
<ElButton size="small" type="primary" @click="startRecord">开始录制</ElButton>
|
||||
<ElButton size="small" type="primary" @click="startRecord">
|
||||
开始录制
|
||||
</ElButton>
|
||||
</template>
|
||||
<template v-else-if="status === 'recording'">
|
||||
<ElButton size="small" @click="handleCancel">取消</ElButton>
|
||||
<ElButton size="small" type="primary" @click="stopRecord">停止录制</ElButton>
|
||||
<ElButton size="small" type="primary" @click="stopRecord">
|
||||
停止录制
|
||||
</ElButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ElButton size="small" @click="handleCancel">取消</ElButton>
|
||||
<ElButton size="small" @click="restart">重新录制</ElButton>
|
||||
<ElButton size="small" type="primary" @click="handleSend">发送</ElButton>
|
||||
<ElButton size="small" type="primary" @click="handleSend">
|
||||
发送
|
||||
</ElButton>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -311,14 +342,15 @@ onUnmounted(() => {
|
||||
<style scoped>
|
||||
/* 底部小三角 */
|
||||
.im-popover-arrow::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: calc(100% - 1px);
|
||||
left: 110px;
|
||||
content: '';
|
||||
border-color: var(--ant-color-bg-container) transparent transparent
|
||||
transparent;
|
||||
border-style: solid;
|
||||
border-width: 6px 6px 0 6px;
|
||||
border-color: var(--ant-color-bg-container) transparent transparent transparent;
|
||||
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.08));
|
||||
border-width: 6px 6px 0;
|
||||
filter: drop-shadow(0 2px 2px rgb(0 0 0 / 8%));
|
||||
}
|
||||
|
||||
/* 录音中的脉冲动画 */
|
||||
@@ -328,13 +360,15 @@ onUnmounted(() => {
|
||||
|
||||
@keyframes im-voice-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(245, 108, 108, 0.6);
|
||||
box-shadow: 0 0 0 0 rgb(245 108 108 / 60%);
|
||||
}
|
||||
|
||||
70% {
|
||||
box-shadow: 0 0 0 20px rgba(245, 108, 108, 0);
|
||||
box-shadow: 0 0 0 20px rgb(245 108 108 / 0%);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(245, 108, 108, 0);
|
||||
box-shadow: 0 0 0 0 rgb(245 108 108 / 0%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -193,15 +193,15 @@ async function handleRemove(pinnedMessage: Message) {
|
||||
<style scoped>
|
||||
/* 弹出层朝上的三角箭头;走 ::before + 4 边 border 配色画,颜色跟弹出层 background 一致 */
|
||||
.im-group-pinned-message__list::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 184px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 8px solid transparent;
|
||||
content: '';
|
||||
border-right: 8px solid transparent;
|
||||
border-bottom: 8px solid var(--ant-color-bg-container);
|
||||
filter: drop-shadow(0 -2px 1px rgba(0, 0, 0, 0.04));
|
||||
border-left: 8px solid transparent;
|
||||
filter: drop-shadow(0 -2px 1px rgb(0 0 0 / 4%));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -83,6 +83,6 @@ const pendingCount = computed(() => groupRequestStore.getUnhandledGroupRequestCo
|
||||
<style scoped>
|
||||
/* :deep 穿透 Icon 子组件 DOM;强制 svg 走 currentColor 应对暗色模式 el-icon 全局色覆盖 */
|
||||
.im-group-request-pending__icon :deep(svg) {
|
||||
fill: currentColor !important;
|
||||
fill: currentcolor !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -145,7 +145,7 @@ const onClick = async () => {
|
||||
transition: box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 2px 12px rgb(0 0 0 / 8%);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -360,29 +360,33 @@ onBeforeUnmount(() => {
|
||||
颜色与气泡背景对应,留 1px 视觉吃进去,省一张图片 */
|
||||
.message-bubble--other::before,
|
||||
.message-bubble--self::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
content: '';
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.message-bubble--other {
|
||||
--im-message-bubble-other-bg: #f5f5f5;
|
||||
}
|
||||
|
||||
.message-bubble--other.message-bubble--text,
|
||||
.message-bubble--other.message-bubble--voice {
|
||||
background-color: var(--im-message-bubble-other-bg);
|
||||
}
|
||||
|
||||
.message-bubble--other::before {
|
||||
left: -5px;
|
||||
border-width: 5px 6px 5px 0;
|
||||
border-color: transparent var(--im-message-bubble-other-bg) transparent transparent;
|
||||
border-width: 5px 6px 5px 0;
|
||||
}
|
||||
|
||||
.message-bubble--self::before {
|
||||
right: -5px;
|
||||
border-width: 5px 0 5px 6px;
|
||||
border-color: transparent transparent transparent #95ec69;
|
||||
border-width: 5px 0 5px 6px;
|
||||
}
|
||||
|
||||
/* 整体放进 :global(),避免 Vue scoped 把 `:global(.dark) .xxx` 塌缩成裸 `.dark` 而把变量刷到 <html> */
|
||||
@@ -394,6 +398,7 @@ onBeforeUnmount(() => {
|
||||
.message-bubble__voice-icon :deep(svg) {
|
||||
fill: #606266 !important;
|
||||
}
|
||||
|
||||
.message-bubble__voice-icon.im-voice-playing :deep(svg) {
|
||||
fill: #409eff !important;
|
||||
}
|
||||
@@ -408,6 +413,7 @@ onBeforeUnmount(() => {
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
@@ -753,16 +753,18 @@ function locateMessage(messageId: number) {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
display: none;
|
||||
white-space: nowrap;
|
||||
margin-top: 4px;
|
||||
color: #1989fa;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.im-message-history__row:hover .im-message-history__locate {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.im-message-history__locate:hover {
|
||||
color: #146fc7;
|
||||
}
|
||||
@@ -774,16 +776,18 @@ function locateMessage(messageId: number) {
|
||||
color: #1989fa;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.im-message-history__tab:hover {
|
||||
color: #2f81d4;
|
||||
}
|
||||
|
||||
.im-message-history__tab--active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
content: '';
|
||||
background: #1989fa;
|
||||
border-radius: 1px;
|
||||
}
|
||||
@@ -792,15 +796,18 @@ function locateMessage(messageId: number) {
|
||||
.im-message-history__calendar :deep(.el-calendar__header) {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.im-message-history__calendar :deep(.el-calendar-table) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.im-message-history__calendar :deep(.el-calendar-table td) {
|
||||
padding: 1px 0;
|
||||
}
|
||||
|
||||
.im-message-history__calendar :deep(.el-calendar-day) {
|
||||
height: 28px;
|
||||
margin: 0 2px;
|
||||
padding: 2px 4px 0;
|
||||
margin: 0 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1047,6 +1047,7 @@ function handleDelete() {
|
||||
.im-loading-spin {
|
||||
animation: im-loading-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes im-loading-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
|
||||
@@ -757,17 +757,18 @@ watch(
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--ant-color-text) !important;
|
||||
fill: currentColor !important;
|
||||
fill: currentcolor !important;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.message-panel__header-icon {
|
||||
box-sizing: content-box;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.message-panel__header-icon:hover,
|
||||
.message-panel__header-icon:hover :deep(svg) {
|
||||
color: var(--ant-color-primary) !important;
|
||||
@@ -785,6 +786,7 @@ watch(
|
||||
.message-panel__message-anchor {
|
||||
transition: background-color 0.6s ease;
|
||||
}
|
||||
|
||||
.message-panel__message-anchor--highlight {
|
||||
background-color: var(--ant-color-warning-bg);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user