fix: lint

This commit is contained in:
xingyu4j
2026-06-26 16:02:46 +08:00
parent a66887a3fb
commit 1b5a42f7f4
91 changed files with 3777 additions and 2902 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)"
>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 拼图并写回 mergedUrlmergeToken 校验避免老 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);

View File

@@ -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 的 :keycaller 传 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>

View File

@@ -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[];
/** 已选会话 keyv-modelkey 由 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;

View File

@@ -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;
/** 已选好友 idv-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));
/** 候选好友:剔除 hideIdshide 优先级最高) */
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>

View File

@@ -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';
/** 已选 userIdv-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"

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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 拿 tokenrtcStore 按 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

View File

@@ -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>

View File

@@ -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 };
}

View File

@@ -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,
};
}

View File

@@ -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 Serveraudio / 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 握手并行,省 100300ms 串行延迟;
// 拿到的 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;
}
/** 断开当前 RoomclearHandlers 为 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>;

View File

@@ -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;
};
}

View File

@@ -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 URLcommit 时是真实 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 走 uploadAndSendMediavideo 走低层 helper 自行组装) */
export interface UploadAndSendMediaOptions {
file: File
file: File;
/** 对齐 ImContentTypemediaTypeHandlers 必须有对应项 */
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();
/**
* 立即写入媒体占位消息(低层 helperimage/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 复用 existingClientMessageIdstore 内部 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,
};
};

View File

@@ -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 };
}

View File

@@ -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);
/** 服务端私聊消息 -> 本地 MessagetargetId 是会话主键(对端 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, // 详情页拉富文本用
};
};
/** 频道:会话归属到 channelIdname / 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原消息走批量 insertRECALL 信号走批量 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),
});
}
}
// 入库 + 推进 messageMaxIdnextMinId 为空(本批无有效 id时不推进游标与旧逻辑一致
await messageStore.applyPulledMessageList(pulledMessages, conversationType, nextMinId)
}
})
}
await messageStore.applyPulledMessageList(
pulledMessages,
conversationType,
nextMinId,
);
},
});
};
/** 同一时刻只允许一次 pullindex.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 仍属于当前 sessionepoch 未漂 + 用户未切;任何动新 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 };
};

View File

@@ -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 };
};

View File

@@ -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();
// 订阅模块级 tickscope 销毁时反订阅,最后一个订阅者退场后 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;
});
}

View File

@@ -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 };
}

View File

@@ -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 };
}

View File

@@ -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 的干净白差距明显。

View File

@@ -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">

View File

@@ -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>

View File

@@ -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

View File

@@ -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-60=周日);项目没全局装 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,
);
/** 最后一条消息发送者的展示名:实时算 + 快照 fallbackgetSenderDisplayName 算不出时兜底) */
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 {

View File

@@ -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>>() // 发起群聊弹窗 refhandleOpenCreateGroup 调 open({ lockedIds }) 锁定对方
const createGroupDialogRef = ref<InstanceType<typeof GroupCreateDialog>>(); // 发起群聊弹窗 refhandleOpenCreateGroup 调 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;
}
/* 相邻信息行加分隔线; 相邻兄弟选择器无法用工具类表达 */

View File

@@ -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>

View File

@@ -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 整段删而不是逐字符 */

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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%);
}
}

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -1047,6 +1047,7 @@ function handleDelete() {
.im-loading-spin {
animation: im-loading-spin 1s linear infinite;
}
@keyframes im-loading-spin {
to {
transform: rotate(360deg);

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 的干净白差距明显。

View File

@@ -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;
}
/* 相邻信息行加分隔线; 相邻兄弟选择器无法用工具类表达 */

View File

@@ -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;
}
/* 相邻信息行加分隔线; 相邻兄弟选择器无法用工具类表达 */

View File

@@ -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>

View File

@@ -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 整段删而不是逐字符 */

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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%);
}
}

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -1047,6 +1047,7 @@ function handleDelete() {
.im-loading-spin {
animation: im-loading-spin 1s linear infinite;
}
@keyframes im-loading-spin {
to {
transform: rotate(360deg);

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 的干净白差距明显。

View File

@@ -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;
}
/* 相邻信息行加分隔线; 相邻兄弟选择器无法用工具类表达 */

View File

@@ -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>

View File

@@ -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 整段删而不是逐字符 */

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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%);
}
}

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -1047,6 +1047,7 @@ function handleDelete() {
.im-loading-spin {
animation: im-loading-spin 1s linear infinite;
}
@keyframes im-loading-spin {
to {
transform: rotate(360deg);

View File

@@ -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);
}