feat(im): 优化 icon 的导入

im
YunaiV 2026-04-28 08:15:10 +08:00
parent 9fc10b304c
commit 6ead932813
4 changed files with 121 additions and 91 deletions

View File

@ -27,7 +27,7 @@
class="mb-2.5" class="mb-2.5"
> >
<template #prefix> <template #prefix>
<el-icon><Search /></el-icon> <Icon icon="ant-design:search-outlined" />
</template> </template>
</el-input> </el-input>
@ -41,7 +41,7 @@
<div <div
class="im-chat-group-side__tool-btn flex items-center justify-center w-[38px] h-[38px] text-[18px] cursor-pointer border border-dashed border-[var(--el-border-color)] rounded text-[var(--el-text-color-regular)] hover:text-[#409eff] hover:border-[#409eff]" class="im-chat-group-side__tool-btn flex items-center justify-center w-[38px] h-[38px] text-[18px] cursor-pointer border border-dashed border-[var(--el-border-color)] rounded text-[var(--el-text-color-regular)] hover:text-[#409eff] hover:border-[#409eff]"
> >
<el-icon><Plus /></el-icon> <Icon icon="ant-design:plus-outlined" />
</div> </div>
<div class="mt-1 text-12px text-[var(--el-text-color-regular)]">邀请</div> <div class="mt-1 text-12px text-[var(--el-text-color-regular)]">邀请</div>
</div> </div>
@ -56,7 +56,7 @@
<div <div
class="im-chat-group-side__tool-btn flex items-center justify-center w-[38px] h-[38px] text-[18px] cursor-pointer border border-dashed border-[var(--el-border-color)] rounded text-[var(--el-text-color-regular)] hover:text-[#409eff] hover:border-[#409eff]" class="im-chat-group-side__tool-btn flex items-center justify-center w-[38px] h-[38px] text-[18px] cursor-pointer border border-dashed border-[var(--el-border-color)] rounded text-[var(--el-text-color-regular)] hover:text-[#409eff] hover:border-[#409eff]"
> >
<el-icon><Minus /></el-icon> <Icon icon="ant-design:minus-outlined" />
</div> </div>
<div class="mt-1 text-12px text-[var(--el-text-color-regular)]">移除</div> <div class="mt-1 text-12px text-[var(--el-text-color-regular)]">移除</div>
</div> </div>
@ -121,7 +121,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus, Minus } from '@element-plus/icons-vue' import Icon from '@/components/Icon/src/Icon.vue'
import { useUserStore } from '@/store/modules/user' import { useUserStore } from '@/store/modules/user'
import { CommonStatusEnum } from '@/utils/constants' import { CommonStatusEnum } from '@/utils/constants'

View File

@ -11,28 +11,22 @@
<div class="flex gap-3 items-center"> <div class="flex gap-3 items-center">
<!-- 聊天历史从输入区底部工具栏挪到顶部右上角对齐微信 PC点击弹窗承接历史消息 --> <!-- 聊天历史从输入区底部工具栏挪到顶部右上角对齐微信 PC点击弹窗承接历史消息 -->
<el-tooltip content="聊天历史" placement="bottom"> <el-tooltip content="聊天历史" placement="bottom">
<el-icon <Icon
class="chat-panel__header-icon text-[20px] cursor-pointer" icon="ant-design:profile-outlined"
:size="20"
class="chat-panel__header-icon cursor-pointer"
@click="historyVisible = true" @click="historyVisible = true"
> />
<Tickets />
</el-icon>
</el-tooltip> </el-tooltip>
<!-- TODO @AI无论是群聊还是单聊都是 *** 三个点 --> <!-- 信息抽屉入口群聊 / 私聊统一用 3 点图标对齐微信 PC
<!-- 私聊聊天信息抽屉免打扰 / 置顶 --> sideVisible 在群 / 私聊两个抽屉之间共用一个 ref v-if-else 决定挂哪个 -->
<el-tooltip v-if="!isGroup" content="聊天信息" placement="bottom"> <el-tooltip :content="isGroup ? '群聊信息' : '聊天信息'" placement="bottom">
<el-icon <Icon
class="chat-panel__header-icon text-[20px] cursor-pointer" icon="ant-design:more-outlined"
@click="togglePrivateSide" :size="20"
> class="chat-panel__header-icon cursor-pointer"
<MoreFilled /> @click="toggleSide"
</el-icon> />
</el-tooltip>
<!-- 群聊群聊信息抽屉 -->
<el-tooltip v-if="isGroup" content="群聊信息" placement="bottom">
<el-icon class="chat-panel__header-icon text-[20px] cursor-pointer" @click="toggleSide">
<InfoFilled />
</el-icon>
</el-tooltip> </el-tooltip>
</div> </div>
</div> </div>
@ -67,7 +61,7 @@
class="chat-panel__jump-bottom sticky bottom-3 left-1/2 inline-flex gap-1.5 items-center w-fit mx-auto px-3.5 py-1.5 text-xs text-[#409eff] bg-[var(--el-bg-color-overlay)] rounded-2xl shadow-[0_2px_8px_rgba(0,0,0,0.12)] cursor-pointer hover:text-white hover:bg-[#409eff]" class="chat-panel__jump-bottom sticky bottom-3 left-1/2 inline-flex gap-1.5 items-center w-fit mx-auto px-3.5 py-1.5 text-xs text-[#409eff] bg-[var(--el-bg-color-overlay)] rounded-2xl shadow-[0_2px_8px_rgba(0,0,0,0.12)] cursor-pointer hover:text-white hover:bg-[#409eff]"
@click="scrollToBottom(true)" @click="scrollToBottom(true)"
> >
<el-icon class="text-sm"><ArrowDown /></el-icon> <Icon icon="ant-design:down-outlined" :size="14" />
<span v-if="newMessageCount > 0" class="font-medium"> <span v-if="newMessageCount > 0" class="font-medium">
{{ newMessageCount > 99 ? '99+' : newMessageCount }} 条新消息 {{ newMessageCount > 99 ? '99+' : newMessageCount }} 条新消息
</span> </span>
@ -94,7 +88,7 @@
/> />
<ChatPrivateSide <ChatPrivateSide
v-else v-else
v-model="privateSideVisible" v-model="sideVisible"
:conversation="conversationStore.activeConversation" :conversation="conversationStore.activeConversation"
:friend="privateFriend" :friend="privateFriend"
/> />
@ -113,7 +107,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref, watch, nextTick, computed } from 'vue' import { ref, watch, nextTick, computed } from 'vue'
import { InfoFilled, MoreFilled, ArrowDown, Tickets } from '@element-plus/icons-vue' import Icon from '@/components/Icon/src/Icon.vue'
import { useConversationStore } from '../../../store/conversationStore' import { useConversationStore } from '../../../store/conversationStore'
import { useFriendStore } from '../../../store/friendStore' import { useFriendStore } from '../../../store/friendStore'
@ -158,7 +152,13 @@ const showJumpToBottom = ref(false)
/** 不在底部期间累计的新消息数 */ /** 不在底部期间累计的新消息数 */
const newMessageCount = ref(0) const newMessageCount = ref(0)
// groupStore activeConversation /**
* 当前激活的群详情优先 groupStore带详细字段未加载完时用 activeConversation 兜底
*
* groupStore 是按需懒加载的初次进群时 ensureGroupData 触发后才会有完整数据
* 兜底字段name / avatar保证 header 不会"闪空"notice / ownerId / memberCount
* 必须等 store 就位才有值这些字段在 conversation 里没有
*/
const groupInfo = computed< const groupInfo = computed<
(GroupLite & { notice?: string; remarkNickName?: string; ownerId?: number }) | undefined (GroupLite & { notice?: string; remarkNickName?: string; ownerId?: number }) | undefined
>(() => { >(() => {
@ -166,16 +166,15 @@ const groupInfo = computed<
if (!conversation || conversation.type !== ImConversationType.GROUP) { if (!conversation || conversation.type !== ImConversationType.GROUP) {
return undefined return undefined
} }
// TODO @AIgroup const group = groupStore.getGroup(conversation.targetId)
const g = groupStore.getGroup(conversation.targetId)
return { return {
id: conversation.targetId, id: conversation.targetId,
name: g?.name || conversation.name, name: group?.name || conversation.name,
showGroupName: g?.name || conversation.name, showGroupName: group?.name || conversation.name,
showImage: g?.avatar || conversation.avatar, showImage: group?.avatar || conversation.avatar,
notice: g?.notice, notice: group?.notice,
ownerId: g?.ownerUserId, ownerId: group?.ownerUserId,
memberCount: g?.memberCount memberCount: group?.memberCount
} }
}) })
@ -194,36 +193,40 @@ const groupMembers = computed<GroupMemberLite[]>(() => {
})) }))
}) })
/** 好友列表(用于邀请对话框) */ /** 好友列表(用于"邀请入群"对话框):把 friendStore 的全量好友 map 成 FriendLite 窄接口 */
const groupFriends = computed<FriendLite[]>(() => const groupFriends = computed<FriendLite[]>(() =>
// TODO @AIfriend friendStore.getActiveFriends.map((friend) => ({
friendStore.getActiveFriends.map((f) => ({ id: friend.friendUserId,
id: f.friendUserId, nickname: friend.nickname,
nickname: f.nickname, avatar: friend.avatar,
avatar: f.avatar, deleted: friend.status === CommonStatusEnum.DISABLE
deleted: f.status === CommonStatusEnum.DISABLE
})) }))
) )
// TODO @AI /**
// group / members / * 切换到群会话时自动从后端拉取 group / members / 好友store 内自带缓存
// *
// fire-and-forget + catch Promise.all * 三件事各自 fire-and-forget + 各自 catch之前用 Promise.all 时任意一项失败会让其它
// * 已成功的结果只记一条笼统日志丢掉具体出错点这里拆开谁挂谁单独记不互相牵连
* 错误日志把 groupId 一起带上多群环境下排查问题能直接定位
*/
function ensureGroupData(groupId: number) { function ensureGroupData(groupId: number) {
// TODO @AI groupid groupStore.loadGroupInfo(groupId).catch((error) => {
groupStore.loadGroupInfo(groupId).catch((e) => { console.warn('[IM ChatPanel] loadGroupInfo 失败', { groupId }, error)
console.warn('[IM ChatPanel] loadGroupInfo 失败', e)
}) })
groupStore.loadGroupMembers(groupId).catch((e) => { groupStore.loadGroupMembers(groupId).catch((error) => {
console.warn('[IM ChatPanel] loadGroupMembers 失败', e) console.warn('[IM ChatPanel] loadGroupMembers 失败', { groupId }, error)
}) })
friendStore.loadFriends().catch((e) => { friendStore.loadFriends().catch((error) => {
console.warn('[IM ChatPanel] loadFriends 失败', e) console.warn('[IM ChatPanel] loadFriends 失败', { groupId }, error)
}) })
} }
// TODO @AI /**
* 群信息抽屉里点"刷新"等触发强拉一次最新群元数据 + 群成员force=true 跳过缓存
*
* 仅在当前会话仍是同一个群时执行避免 await 期间用户已经切走把别的群数据也跟着重拉
*/
function reloadGroupData() { function reloadGroupData() {
const conversation = conversationStore.activeConversation const conversation = conversationStore.activeConversation
if (!conversation || conversation.type !== ImConversationType.GROUP) { if (!conversation || conversation.type !== ImConversationType.GROUP) {
@ -234,22 +237,19 @@ function reloadGroupData() {
} }
const historyVisible = ref(false) const historyVisible = ref(false)
// TODO @AIsideVisible sideVisible + privateSideVisible /**
/** 群聊抽屉的开关:纯 ChatPanel 本地 UI 状态 */ * 信息抽屉的开关 ChatPanel 本地 UI 状态
const sideVisible = ref(false) // ChatPanel UI *
/** 私聊抽屉的开关:纯 ChatPanel 本地 UI 状态 */ * 群聊 / 私聊共用一个 ref模板里 v-if-else 决定挂哪个抽屉同一时刻只有一个组件
const privateSideVisible = ref(false) * DOM 所以一个布尔够用早先拆成 sideVisible + privateSideVisible 是冗余
*/
const sideVisible = ref(false)
// TODO @AI /** 信息抽屉的 toggle跟 header 上 3 点图标按钮共用 */
function toggleSide() { function toggleSide() {
sideVisible.value = !sideVisible.value sideVisible.value = !sideVisible.value
} }
// TODO @AI
function togglePrivateSide() {
privateSideVisible.value = !privateSideVisible.value
}
/** 当前私聊对应的好友(抽屉头部展示用) */ /** 当前私聊对应的好友(抽屉头部展示用) */
const privateFriend = computed(() => { const privateFriend = computed(() => {
const conversation = conversationStore.activeConversation const conversation = conversationStore.activeConversation
@ -268,7 +268,11 @@ function distanceFromBottom(): number {
return el.scrollHeight - el.scrollTop - el.clientHeight return el.scrollHeight - el.scrollTop - el.clientHeight
} }
// TODO @AI /**
* 消息列表滚动事件刷新"是否在底部"状态
* - 在底部隐藏"回到底部"浮窗 + 清掉"未读新消息"计数
* - 不在底部显示"回到底部"浮窗新消息会累计到 newMessageCount
*/
function handleScroll() { function handleScroll() {
const dist = distanceFromBottom() const dist = distanceFromBottom()
const atBottom = dist <= BOTTOM_THRESHOLD const atBottom = dist <= BOTTOM_THRESHOLD
@ -278,7 +282,13 @@ function handleScroll() {
} }
} }
// TODO @AI /**
* 滚到底部切会话 / 收到新消息且当前在底部/ 用户主动点"回到底部" 都走这里
*
* nextTick 是为了等 v-for 把新消息真正渲染进 DOM 后再算 scrollHeight
* 否则可能滚到的还是旧高度差最后一条的位置smooth=true 走平滑动画
* 适合用户主动点击初始 / 自动滚动用 auto避免用户感知到动画拖拽
*/
function scrollToBottom(smooth = false) { function scrollToBottom(smooth = false) {
nextTick(() => { nextTick(() => {
if (!listRef.value) { if (!listRef.value) {
@ -321,18 +331,20 @@ async function handleLocate(messageId: number) {
} }
/** /**
* 消息变化时 * 消息数量变化时的滚动跟进策略
* - 如果当前在底部自动跟进滚动 * - 当前在底部 自动滚到新底部用户在"实时跟读"体验上跟微信一致
* - 否则累计 newMessageCount * - 不在底部 累计 newMessageCount sticky 浮窗显示"X 条新消息"让用户主动点
*/ */
watch( watch(
() => messages.value.length, () => messages.value.length,
(newLen, oldLen) => { (newLen, oldLen) => {
// delta > 0 / length
const delta = (newLen || 0) - (oldLen || 0) const delta = (newLen || 0) - (oldLen || 0)
if (delta <= 0) { if (delta <= 0) {
return return
} }
// TODO @AI // BOTTOM_THRESHOLD80px""
// auto-scroll
const dist = distanceFromBottom() const dist = distanceFromBottom()
if (dist <= BOTTOM_THRESHOLD) { if (dist <= BOTTOM_THRESHOLD) {
scrollToBottom() scrollToBottom()
@ -343,14 +355,19 @@ watch(
} }
) )
// /**
* 切换会话清空"在不在底部"相关状态强制滚到底部群会话预拉资料
*
* immediate:true 让首次进入页面就能正确初始化无需等到第一次切换
*/
watch( watch(
() => conversationStore.activeConversation?.targetId, () => conversationStore.activeConversation?.targetId,
(targetId) => { (targetId) => {
// TODO @AI // " + "
newMessageCount.value = 0 newMessageCount.value = 0
showJumpToBottom.value = false showJumpToBottom.value = false
scrollToBottom() scrollToBottom()
// / / friendStore globally pull
if (targetId && conversationStore.activeConversation?.type === ImConversationType.GROUP) { if (targetId && conversationStore.activeConversation?.type === ImConversationType.GROUP) {
ensureGroupData(targetId) ensureGroupData(targetId)
} }

View File

@ -29,7 +29,11 @@
> >
{{ filterChipLabel }} {{ filterChipLabel }}
</el-tag> </el-tag>
<el-icon v-else class="text-[var(--el-text-color-secondary)]"><Search /></el-icon> <Icon
v-else
icon="ant-design:search-outlined"
class="text-[var(--el-text-color-secondary)]"
/>
<input <input
v-model="keyword" v-model="keyword"
type="text" type="text"
@ -109,7 +113,7 @@
<div> <div>
<el-input v-model="memberSearchKeyword" placeholder="搜索群成员" size="small"> <el-input v-model="memberSearchKeyword" placeholder="搜索群成员" size="small">
<template #prefix> <template #prefix>
<el-icon><Search /></el-icon> <Icon icon="ant-design:search-outlined" />
</template> </template>
</el-input> </el-input>
<div class="max-h-[360px] overflow-y-auto mt-2"> <div class="max-h-[360px] overflow-y-auto mt-2">
@ -293,7 +297,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import dayjs from 'dayjs' import dayjs from 'dayjs'

View File

@ -120,12 +120,12 @@
]" ]"
@click="handleVoiceClick" @click="handleVoiceClick"
> >
<el-icon <Icon
class="message-bubble__voice-icon !text-[18px]" icon="ant-design:audio-outlined"
:size="18"
class="message-bubble__voice-icon"
:class="{ 'im-voice-playing': voicePlaying }" :class="{ 'im-voice-playing': voicePlaying }"
> />
<Microphone />
</el-icon>
<span class="text-13px text-[var(--el-text-color-primary)]"> <span class="text-13px text-[var(--el-text-color-primary)]">
{{ formatSeconds(voicePayload.duration) }} {{ formatSeconds(voicePayload.duration) }}
</span> </span>
@ -158,18 +158,19 @@
<!-- 状态区自己消息展示发送状态 + 已读/群回执对方消息 + @自己时展示 @徽标 --> <!-- 状态区自己消息展示发送状态 + 已读/群回执对方消息 + @自己时展示 @徽标 -->
<div class="flex gap-1.5 items-center text-base"> <div class="flex gap-1.5 items-center text-base">
<template v-if="message.selfSend"> <template v-if="message.selfSend">
<el-icon v-if="message.status === ImMessageStatus.SENDING" class="is-loading"> <Icon
<Loading /> v-if="message.status === ImMessageStatus.SENDING"
</el-icon> icon="ant-design:loading-outlined"
<el-icon class="im-loading-spin"
/>
<Icon
v-else-if="message.status === ImMessageStatus.FAILED" v-else-if="message.status === ImMessageStatus.FAILED"
icon="ant-design:warning-filled"
color="#f56c6c" color="#f56c6c"
class="cursor-pointer" class="cursor-pointer"
title="发送失败,点击重试" title="发送失败,点击重试"
@click="handleResend" @click="handleResend"
> />
<WarningFilled />
</el-icon>
<!-- 已读态私聊 --> <!-- 已读态私聊 -->
<span <span
v-else-if="privateReadLabel" v-else-if="privateReadLabel"
@ -210,7 +211,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onBeforeUnmount, ref } from 'vue' import { computed, onBeforeUnmount, ref } from 'vue'
import { Loading, WarningFilled, Microphone } from '@element-plus/icons-vue'
import { ElMessageBox } from 'element-plus' import { ElMessageBox } from 'element-plus'
import Icon from '@/components/Icon/src/Icon.vue' import Icon from '@/components/Icon/src/Icon.vue'
@ -622,4 +622,14 @@ function handleDelete() {
transform: scale(1.15); transform: scale(1.15);
} }
} }
/* SENDING 状态的转圈动画el-icon 自带 .is-loading 旋转,迁到 Iconify 后丢了,自己补一份 */
.im-loading-spin {
animation: im-loading-spin 1s linear infinite;
}
@keyframes im-loading-spin {
to {
transform: rotate(360deg);
}
}
</style> </style>