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