✨ feat(im): 优化名片消息类型 v0.3:增加表情选择
parent
b17f7a57e5
commit
59aab8ecdc
|
|
@ -148,8 +148,25 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 留言(单行) -->
|
||||
<el-input v-model="leaveMessage" :maxlength="100" placeholder="给朋友留言" />
|
||||
<!-- 留言(单行):右侧表情按钮触发 EmojiPicker,所选 emoji 直接拼接到输入末尾 -->
|
||||
<div class="relative">
|
||||
<el-input v-model="leaveMessage" :maxlength="100" placeholder="给朋友留言">
|
||||
<template #suffix>
|
||||
<Icon
|
||||
icon="ant-design:smile-outlined"
|
||||
:size="18"
|
||||
class="cursor-pointer text-[var(--el-text-color-secondary)] hover:text-[var(--el-color-primary)]"
|
||||
@click.stop="emojiVisible = !emojiVisible"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
<!-- bottom-full 让 picker 下沿贴 input 顶部,向上弹出;right-0 对齐 input 右侧表情按钮 -->
|
||||
<EmojiPicker
|
||||
v-model:visible="emojiVisible"
|
||||
class="bottom-full right-0 mb-2"
|
||||
@select="handleEmojiSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮:选 0/1 显示「发送」、多个显示「分别发送(n)」 -->
|
||||
<div class="flex gap-2 justify-end">
|
||||
|
|
@ -175,10 +192,11 @@ import Icon from '@/components/Icon/src/Icon.vue'
|
|||
import { useMessage } from '@/hooks/web/useMessage'
|
||||
|
||||
import UserAvatar from './UserAvatar.vue'
|
||||
import EmojiPicker from '../../pages/conversation/components/input/EmojiPicker.vue'
|
||||
import { useConversationStore } from '../../store/conversationStore'
|
||||
import { useMessageSender } from '../../composables/useMessageSender'
|
||||
import { ImConversationType, ImMessageType } from '../../../utils/constants'
|
||||
import { getConversationKey } from '../../../utils/conversation'
|
||||
import { filterConversationsByKeyword, getConversationKey } from '../../../utils/conversation'
|
||||
import { serializeMessage, type CardMessage } from '../../../utils/message'
|
||||
import type { Conversation, User } from '../../types'
|
||||
|
||||
|
|
@ -207,6 +225,8 @@ const visible = computed({
|
|||
const keyword = ref('')
|
||||
const leaveMessage = ref('')
|
||||
const sending = ref(false)
|
||||
/** 表情面板显隐:右侧 smile icon 切换 */
|
||||
const emojiVisible = ref(false)
|
||||
/** 已勾选的会话 key 列表(type:targetId 组合主键);selectedSet 派生用于 row 快查 */
|
||||
const selectedKeys = ref<string[]>([])
|
||||
/** 已选 key 集合:handlerToggle 写数组,row isSelected 走 set 快查避免 O(N) 扫描 */
|
||||
|
|
@ -220,6 +240,12 @@ function resetForm() {
|
|||
keyword.value = ''
|
||||
leaveMessage.value = ''
|
||||
selectedKeys.value = []
|
||||
emojiVisible.value = false
|
||||
}
|
||||
|
||||
/** 选中 emoji:直接拼到留言末尾;EmojiPicker 自身 emit('update:visible', false) 关闭面板 */
|
||||
function handleEmojiSelect(emoji: string) {
|
||||
leaveMessage.value = `${leaveMessage.value}${emoji}`
|
||||
}
|
||||
|
||||
/** 候选会话:私聊「推荐给本人」过滤掉避免无意义自推 */
|
||||
|
|
@ -231,15 +257,9 @@ const candidateConversations = computed<Conversation[]>(() => {
|
|||
})
|
||||
|
||||
/** 按搜索关键字过滤展示列表(仅按 name 模糊匹配) */
|
||||
const shownConversations = computed(() => {
|
||||
const keywordLower = keyword.value.trim().toLowerCase()
|
||||
if (!keywordLower) {
|
||||
return candidateConversations.value
|
||||
}
|
||||
return candidateConversations.value.filter((c) =>
|
||||
(c.name || '').toLowerCase().includes(keywordLower)
|
||||
)
|
||||
})
|
||||
const shownConversations = computed(() =>
|
||||
filterConversationsByKeyword(candidateConversations.value, keyword.value)
|
||||
)
|
||||
|
||||
/** 已选会话:右栏预览渲染用,按 selectedKeys 顺序展示 */
|
||||
const selectedConversations = computed<Conversation[]>(() => {
|
||||
|
|
|
|||
|
|
@ -130,12 +130,7 @@ function handleClick(e: MouseEvent) {
|
|||
}
|
||||
// 情况一:有预传 user 信息:就直接用,省一次接口
|
||||
if (props.user) {
|
||||
uiStore.openUserInfoCard(
|
||||
props.user,
|
||||
{ x: e.clientX + 20, y: e.clientY },
|
||||
props.addSource,
|
||||
props.addSourceExtra
|
||||
)
|
||||
uiStore.openUserInfoCardAtEvent(props.user, e, props.addSource, props.addSourceExtra)
|
||||
return
|
||||
}
|
||||
// 情况二:无预传 user 信息:打开名片,传最小必要信息(id + 昵称 + 头像),位置在鼠标右侧
|
||||
|
|
@ -143,13 +138,13 @@ function handleClick(e: MouseEvent) {
|
|||
if (!numId || numId <= 0) {
|
||||
return
|
||||
}
|
||||
uiStore.openUserInfoCard(
|
||||
uiStore.openUserInfoCardAtEvent(
|
||||
{
|
||||
id: numId,
|
||||
nickname: props.name,
|
||||
avatar: props.url
|
||||
},
|
||||
{ x: e.clientX + 20, y: e.clientY },
|
||||
e,
|
||||
props.addSource,
|
||||
props.addSourceExtra
|
||||
)
|
||||
|
|
|
|||
|
|
@ -60,12 +60,7 @@
|
|||
<div
|
||||
v-if="isText"
|
||||
class="relative px-3.5 py-2.5 text-sm leading-normal break-words whitespace-pre-wrap rounded-lg"
|
||||
:class="[
|
||||
message.selfSend ? 'message-bubble--self' : 'message-bubble--other',
|
||||
message.selfSend
|
||||
? 'text-black bg-[#95ec69]'
|
||||
: 'text-[var(--el-text-color-primary)] bg-[var(--el-fill-color-light)]'
|
||||
]"
|
||||
:class="bubbleClass('text')"
|
||||
>
|
||||
{{ textContent }}
|
||||
</div>
|
||||
|
|
@ -89,13 +84,7 @@
|
|||
<div
|
||||
v-else-if="isFile && filePayload"
|
||||
class="relative flex gap-3 items-center min-w-[260px] max-w-[340px] px-3.5 py-3 border rounded transition-colors"
|
||||
:class="[
|
||||
message.selfSend ? 'message-bubble--self' : 'message-bubble--other',
|
||||
message.selfSend
|
||||
? 'bg-[#95ec69] border-[var(--el-border-color-lighter)]'
|
||||
: 'bg-[var(--el-bg-color)] border-[var(--el-border-color-light)] hover:border-[#409eff]',
|
||||
isUploading ? 'cursor-default' : 'cursor-pointer'
|
||||
]"
|
||||
:class="[bubbleClass('file'), isUploading ? 'cursor-default' : 'cursor-pointer']"
|
||||
@click="handleFileClick"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
|
|
@ -204,9 +193,10 @@
|
|||
<!-- 状态区:自己消息展示发送状态 + 已读/群回执;对方消息 + @自己时展示 @徽标 -->
|
||||
<div class="flex gap-1.5 items-center text-base">
|
||||
<template v-if="message.selfSend">
|
||||
<!-- 媒体消息 SENDING 时气泡自身已显示进度遮罩/进度条,外层 loading 多余;其它消息(含语音)保留 loading 表达 -->
|
||||
<!-- SENDING 显示外层 loading;图片/视频/文件气泡自身有进度反馈则抑制;
|
||||
语音气泡只有麦克风 + 时长,无内嵌进度条,必须保留外层 loading 让用户感知正在发送 -->
|
||||
<Icon
|
||||
v-if="message.status === ImMessageStatus.SENDING && !isUploading"
|
||||
v-if="message.status === ImMessageStatus.SENDING && (!isUploading || isVoice)"
|
||||
icon="ant-design:loading-outlined"
|
||||
class="im-loading-spin"
|
||||
/>
|
||||
|
|
@ -374,6 +364,34 @@ const isVoice = computed(() => props.message.type === ImMessageType.VOICE)
|
|||
const isVideo = computed(() => props.message.type === ImMessageType.VIDEO)
|
||||
const isCard = computed(() => props.message.type === ImMessageType.CARD)
|
||||
|
||||
// ==================== 气泡配色 helper ====================
|
||||
// self / other 两侧 ::before 三角靠 message-bubble--self/other 类承载;本 helper 把 4 处 selfSend ternary
|
||||
// 集中到一处,新增 variant 直接在 switch 加 case;class 字面量保留以便 UnoCSS 静态扫描
|
||||
|
||||
/** 文本 / 文件 / 语音气泡的整体 class(含 selfSend 配色 + ::before 三角的 side class) */
|
||||
function bubbleClass(variant: 'text' | 'file' | 'voice'): string[] {
|
||||
const isSelf = props.message.selfSend
|
||||
const side = isSelf ? 'message-bubble--self' : 'message-bubble--other'
|
||||
switch (variant) {
|
||||
case 'text':
|
||||
return [
|
||||
side,
|
||||
isSelf
|
||||
? 'text-black bg-[#95ec69]'
|
||||
: 'text-[var(--el-text-color-primary)] bg-[var(--el-fill-color-light)]'
|
||||
]
|
||||
case 'file':
|
||||
return [
|
||||
side,
|
||||
isSelf
|
||||
? 'bg-[#95ec69] border-[var(--el-border-color-lighter)]'
|
||||
: 'bg-[var(--el-bg-color)] border-[var(--el-border-color-light)] hover:border-[#409eff]'
|
||||
]
|
||||
case 'voice':
|
||||
return [side, isSelf ? 'bg-[#95ec69]' : 'bg-[var(--el-fill-color-light)]']
|
||||
}
|
||||
}
|
||||
|
||||
// TODO @AI:抽到 message.ts 里?作为一个工具方法?
|
||||
/**
|
||||
* 时间分隔线文案:
|
||||
|
|
@ -468,13 +486,13 @@ function handleCardClick(e: MouseEvent) {
|
|||
if (!card?.userId) {
|
||||
return
|
||||
}
|
||||
uiStore.openUserInfoCard(
|
||||
uiStore.openUserInfoCardAtEvent(
|
||||
{
|
||||
id: card.userId,
|
||||
nickname: card.nickname,
|
||||
avatar: card.avatar
|
||||
},
|
||||
{ x: e.clientX + 20, y: e.clientY },
|
||||
e,
|
||||
ImFriendAddSource.CARD
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ import { useFriendStore } from '../../store/friendStore'
|
|||
import { useGroupStore } from '../../store/groupStore'
|
||||
import { StorageKeys } from '../../../utils/storage'
|
||||
import { ImConversationType } from '../../../utils/constants'
|
||||
import { getConversationKey } from '../../../utils/conversation'
|
||||
import { filterConversationsByKeyword, getConversationKey } from '../../../utils/conversation'
|
||||
import { CommonStatusEnum } from '@/utils/constants'
|
||||
import type { Conversation, Friend, FriendLite } from '../../types'
|
||||
import ResizableAside from '../../components/ResizableAside.vue'
|
||||
|
|
@ -129,15 +129,9 @@ const createGroupVisible = ref(false)
|
|||
const sortedConversations = computed(() => conversationStore.getSortedConversations)
|
||||
|
||||
/** 顶部搜索框过滤会话:只按 name 模糊匹配,避免命中 lastContent 等次要字段干扰 */
|
||||
const filteredConversations = computed(() => {
|
||||
const keywordLower = keyword.value.trim().toLowerCase()
|
||||
if (!keywordLower) {
|
||||
return sortedConversations.value
|
||||
}
|
||||
return sortedConversations.value.filter((c) =>
|
||||
(c.name || '').toLowerCase().includes(keywordLower)
|
||||
)
|
||||
})
|
||||
const filteredConversations = computed(() =>
|
||||
filterConversationsByKeyword(sortedConversations.value, keyword.value)
|
||||
)
|
||||
|
||||
// ==================== 置顶相关 ====================
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,16 @@ export const useImUiStore = defineStore('imUiStore', () => {
|
|||
userInfoCard.show = true
|
||||
}
|
||||
|
||||
/** 鼠标点击位置 + 20px 横向偏移打开名片:避免名片直接覆盖触发元素,对齐头像 / 名片消息等点击交互的统一观感 */
|
||||
function openUserInfoCardAtEvent(
|
||||
user: User,
|
||||
e: MouseEvent,
|
||||
addSource: number = ImFriendAddSource.SEARCH,
|
||||
addSourceExtra: string = ''
|
||||
) {
|
||||
openUserInfoCard(user, { x: e.clientX + 20, y: e.clientY }, addSource, addSourceExtra)
|
||||
}
|
||||
|
||||
/** 关闭用户名片 */
|
||||
function closeUserInfoCard() {
|
||||
userInfoCard.show = false
|
||||
|
|
@ -86,6 +96,7 @@ export const useImUiStore = defineStore('imUiStore', () => {
|
|||
return {
|
||||
userInfoCard,
|
||||
openUserInfoCard,
|
||||
openUserInfoCardAtEvent,
|
||||
closeUserInfoCard,
|
||||
|
||||
contextMenu,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,18 @@ export function getConversationKey(conversation: { type: number; targetId: numbe
|
|||
return `${conversation.type}-${conversation.targetId}`
|
||||
}
|
||||
|
||||
/** 按昵称模糊过滤会话列表:空 keyword 原样返回,命中走 toLowerCase 不区分大小写 */
|
||||
export function filterConversationsByKeyword<T extends { name?: string }>(
|
||||
list: T[],
|
||||
keyword: string
|
||||
): T[] {
|
||||
const trimmed = keyword.trim().toLowerCase()
|
||||
if (!trimmed) {
|
||||
return list
|
||||
}
|
||||
return list.filter((c) => (c.name || '').toLowerCase().includes(trimmed))
|
||||
}
|
||||
|
||||
/** 撤回提示文案:自己撤回固定文案,对方撤回带 sender 名(实时算 + fallbackName 兜底) */
|
||||
export function buildRecallTip(
|
||||
senderId: number,
|
||||
|
|
|
|||
Loading…
Reference in New Issue