feat(im): 优化名片消息类型 v0.2:优化转发弹窗的界面样式

im
YunaiV 2026-05-06 08:33:03 +08:00
parent c15d75ba91
commit 957a63f8f4
2 changed files with 221 additions and 251 deletions

View File

@ -1,74 +1,72 @@
<template> <template>
<!-- <!--
把他推荐给朋友个人名片转发弹窗 把他推荐给朋友个人名片转发弹窗对齐微信 PC 双栏布局
- 顶部展示被推荐用户名片预览 - 左栏搜索 + 最近聊天列表圆形单/多选指示
- 中间列出最近会话私聊 + 群聊支持多选过滤掉推荐给本人的私聊会话 - 右栏已选预览每行可移除+ 名片预览卡 + 留言 + 取消/发送
- 底部可选给朋友留言确定后逐个会话发送 CARD 消息有留言再补一条 TEXT - 选中时按 1 个走发送多个走分别发送(n)文案与微信一致
TODO @AI/Users/yunai/Downloads/iShot_2026-05-05_23.56.31.png 更完整的参考微信界面 - 失败的消息以 FAILED 状态留在对应会话气泡里供右键重试
TODO @AI/Users/yunai/Downloads/iShot_2026-05-05_23.57.51.png
TODO @AI输入可以选择表情
--> -->
<el-dialog v-model="visible" title="把他推荐给朋友" width="520px" :close-on-click-modal="false"> <el-dialog
<div class="flex flex-col gap-3"> v-model="visible"
<!-- 名片预览 FriendAddDialog 的用户卡保持一致的浅色背景 --> title="把他推荐给朋友"
<div class="flex gap-3 items-center px-3 py-2.5 rounded-md bg-[var(--el-fill-color-light)]"> width="720px"
<UserAvatar :close-on-click-modal="false"
:id="user?.id" class="im-recommend-dialog"
:url="user?.avatar" @open="resetForm"
:name="user?.nickname" >
:size="40" <div class="flex h-[480px]">
:clickable="false" <!-- ============ 左栏搜索 + 会话列表 ============ -->
/> <div
<div class="flex-1 min-w-0"> class="flex flex-col w-[280px] border-r border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-light)]"
<div class="text-sm font-medium truncate text-[var(--el-text-color-primary)]"> >
{{ user?.nickname }} <!-- 搜索框 -->
</div> <div class="px-3 py-3 flex-shrink-0">
<div class="mt-0.5 text-12px text-[var(--el-text-color-secondary)]">个人名片</div> <el-input v-model="keyword" placeholder="搜索" clearable size="small">
<template #prefix>
<Icon icon="ant-design:search-outlined" />
</template>
</el-input>
</div> </div>
</div>
<!-- 搜索 --> <div class="px-3 pb-1.5 text-13px text-[var(--el-text-color-secondary)] flex-shrink-0">
<el-input v-model="keyword" placeholder="搜索聊天" clearable> 最近聊天
<template #prefix> </div>
<Icon icon="ant-design:search-outlined" />
</template>
</el-input>
<!-- 最近聊天列表 --> <!-- 会话列表 -->
<div class="flex flex-col gap-1.5"> <el-scrollbar class="flex-1">
<div class="text-13px text-[var(--el-text-color-secondary)]">最近聊天</div>
<el-scrollbar class="h-[260px] rounded border border-[var(--el-border-color-lighter)]">
<div <div
v-for="conversation in shownConversations" v-for="conversation in shownConversations"
:key="getConversationKey(conversation)" :key="getConversationKey(conversation)"
class="flex gap-2.5 items-center px-3 py-2 cursor-pointer hover:bg-[var(--el-fill-color-lighter)]" class="flex gap-2.5 items-center px-3 py-1.5 cursor-pointer hover:bg-[var(--el-fill-color)]"
@click="handleToggle(conversation)" @click="handleToggle(conversation)"
> >
<el-checkbox <!-- 圆形单选指示选中绿底白对勾未选浅灰圈 div 实现避开 el-checkbox 方框观感 -->
:model-value="isSelected(conversation)" <span
@click.stop class="flex flex-shrink-0 items-center justify-center w-5 h-5 rounded-full transition-colors"
@change="handleToggle(conversation)" :class="
/> isSelected(conversation)
? 'bg-[#07c160]'
: 'border border-[var(--el-border-color)] bg-[var(--el-bg-color)]'
"
>
<Icon
v-if="isSelected(conversation)"
icon="ant-design:check-outlined"
:size="12"
color="#fff"
/>
</span>
<UserAvatar <UserAvatar
:url="conversation.avatar" :url="conversation.avatar"
:name="conversation.name" :name="conversation.name"
:size="32" :size="32"
:clickable="false" :clickable="false"
/> />
<div class="flex flex-1 gap-1 items-center min-w-0"> <span
<span class="overflow-hidden text-sm truncate text-[var(--el-text-color-primary)]"> class="flex-1 min-w-0 overflow-hidden text-sm truncate text-[var(--el-text-color-primary)]"
{{ conversation.name }} >
</span> {{ conversation.name }}
<el-tag </span>
v-if="conversation.type === ImConversationType.GROUP"
size="small"
type="primary"
effect="plain"
class="flex-shrink-0"
>
</el-tag>
</div>
</div> </div>
<div <div
v-if="shownConversations.length === 0" v-if="shownConversations.length === 0"
@ -79,50 +77,110 @@
</el-scrollbar> </el-scrollbar>
</div> </div>
<!-- 留言可不填 --> <!-- ============ 右栏已选 + 名片卡 + 留言 + 按钮 ============ -->
<el-input <div class="flex flex-col flex-1 min-w-0">
v-model="leaveMessage" <!-- 标题单选发送给/ 多选分别发送给与底部按钮文案保持一致 -->
type="textarea" <div
:rows="2" class="px-4 py-3 text-13px text-[var(--el-text-color-secondary)] flex-shrink-0 border-b border-[var(--el-border-color-lighter)]"
:maxlength="100" >
show-word-limit {{ sendTitle }}
placeholder="给朋友留言(可不填)" </div>
/>
</div>
<template #footer> <!-- 已选预览每行 头像 + 名字 + × 移除 -->
<el-button @click="visible = false">取消</el-button> <el-scrollbar class="flex-1">
<el-button <div
type="primary" v-for="conversation in selectedConversations"
:loading="sending" :key="getConversationKey(conversation)"
:disabled="selectedKeys.size === 0" class="flex gap-2.5 items-center px-4 py-2"
@click="handleSend" >
> <UserAvatar
发送{{ selectedKeys.size > 0 ? `${selectedKeys.size}` : '' }} :url="conversation.avatar"
</el-button> :name="conversation.name"
</template> :size="28"
:clickable="false"
/>
<span
class="flex-1 min-w-0 overflow-hidden text-13px truncate text-[var(--el-text-color-primary)]"
>
{{ conversation.name }}
</span>
<Icon
icon="ant-design:close-outlined"
:size="14"
class="im-recommend__remove flex-shrink-0 cursor-pointer"
@click="handleToggle(conversation)"
/>
</div>
<div
v-if="selectedConversations.length === 0"
class="py-10 text-13px text-center text-[var(--el-text-color-disabled)]"
>
从左侧选择联系人或群聊
</div>
</el-scrollbar>
<!-- 名片预览卡 + 留言 + 按钮 -->
<div
class="flex flex-col gap-3 px-4 py-3 flex-shrink-0 border-t border-[var(--el-border-color-lighter)]"
>
<!-- 名片预览和聊天里的名片气泡同源浅卡片 + 个人名片分隔条 -->
<div
class="flex flex-col w-full rounded-md overflow-hidden bg-[var(--el-bg-color)] border border-[var(--el-border-color-lighter)]"
>
<div class="flex gap-2.5 items-center px-3 py-2.5">
<UserAvatar
:id="user?.id"
:url="user?.avatar"
:name="user?.nickname"
:size="36"
:clickable="false"
/>
<div
class="flex-1 min-w-0 text-sm font-medium truncate text-[var(--el-text-color-primary)]"
>
{{ user?.nickname }}
</div>
</div>
<div
class="px-3 py-1 text-12px border-t text-[var(--el-text-color-placeholder)] border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-lighter)]"
>
个人名片
</div>
</div>
<!-- 留言单行 -->
<el-input v-model="leaveMessage" :maxlength="100" placeholder="给朋友留言" />
<!-- 操作按钮 0/1 显示发送多个显示分别发送(n) -->
<div class="flex gap-2 justify-end">
<el-button @click="visible = false">取消</el-button>
<el-button
type="primary"
:loading="sending"
:disabled="selectedKeys.length === 0"
@click="handleSend"
>
{{ selectedKeys.length > 1 ? `分别发送(${selectedKeys.length}` : '发送' }}
</el-button>
</div>
</div>
</div>
</div>
</el-dialog> </el-dialog>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref, watch } from 'vue' import { computed, ref } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue' import Icon from '@/components/Icon/src/Icon.vue'
import { useMessage } from '@/hooks/web/useMessage' import { useMessage } from '@/hooks/web/useMessage'
import { useUserStore } from '@/store/modules/user'
import UserAvatar from './UserAvatar.vue' import UserAvatar from './UserAvatar.vue'
import { useConversationStore } from '../../store/conversationStore' import { useConversationStore } from '../../store/conversationStore'
import { ImConversationType, ImMessageType, ImMessageStatus } from '../../../utils/constants' import { useMessageSender } from '../../composables/useMessageSender'
import { ImConversationType, ImMessageType } from '../../../utils/constants'
import { getConversationKey } from '../../../utils/conversation' import { getConversationKey } from '../../../utils/conversation'
import { import { serializeMessage, type CardMessage } from '../../../utils/message'
generateClientMessageId, import type { Conversation, User } from '../../types'
serializeMessage,
type CardMessage,
type TextMessage
} from '../../../utils/message'
import { sendPrivateMessage as apiSendPrivateMessage } from '@/api/im/message/private'
import { sendGroupMessage as apiSendGroupMessage } from '@/api/im/message/group'
import type { Conversation, Message, User } from '../../types'
defineOptions({ name: 'ImRecommendCardDialog' }) defineOptions({ name: 'ImRecommendCardDialog' })
@ -137,8 +195,8 @@ const emit = defineEmits<{
}>() }>()
const message = useMessage() const message = useMessage()
const userStore = useUserStore()
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
const { sendRaw, send } = useMessageSender()
/** 弹窗显隐:把父侧 v-model 转双向计算 */ /** 弹窗显隐:把父侧 v-model 转双向计算 */
const visible = computed({ const visible = computed({
@ -149,34 +207,30 @@ const visible = computed({
const keyword = ref('') const keyword = ref('')
const leaveMessage = ref('') const leaveMessage = ref('')
const sending = ref(false) const sending = ref(false)
/** 已勾选的会话 key 集合(使用 type:targetId 组合主键),勾选数据不直接绑 conversation 对象避免 store 引用副作用 */ /** 已勾选的会话 key 列表type:targetId 组合主键selectedSet 派生用于 row 快查 */
const selectedKeys = ref<Set<string>>(new Set()) const selectedKeys = ref<string[]>([])
/** 已选 key 集合handlerToggle 写数组row isSelected 走 set 快查避免 O(N) 扫描 */
const selectedSet = computed(() => new Set(selectedKeys.value))
/** 每次重新打开都把选中态 / 搜索 / 留言清空,避免上次脏数据泄漏到下次 */ /** 右栏标题:选中多个时改「分别发送给」与底部按钮文案保持一致 */
watch(visible, (open) => { const sendTitle = computed(() => (selectedKeys.value.length > 1 ? '分别发送给' : '发送给'))
if (open) {
keyword.value = ''
leaveMessage.value = ''
selectedKeys.value = new Set()
}
})
/** 候选会话:排除已删除 / 自己发给自己;私聊「推荐给本人」过滤掉避免无意义自推 */ /** 弹窗打开时复位el-dialog @open 比 watch 更直观 */
const candidateConversations = computed(() => { function resetForm() {
keyword.value = ''
leaveMessage.value = ''
selectedKeys.value = []
}
/** 候选会话:私聊「推荐给本人」过滤掉避免无意义自推 */
const candidateConversations = computed<Conversation[]>(() => {
const recommendId = props.user?.id const recommendId = props.user?.id
return conversationStore.getSortedConversations.filter((conversation) => { return conversationStore.getSortedConversations.filter(
if ( (c) => !(recommendId && c.type === ImConversationType.PRIVATE && c.targetId === recommendId)
recommendId && )
conversation.type === ImConversationType.PRIVATE &&
conversation.targetId === recommendId
) {
return false
}
return true
})
}) })
/** 按搜索关键字过滤展示列表(仅按 name 模糊匹配;与左侧主搜索一致 */ /** 按搜索关键字过滤展示列表(仅按 name 模糊匹配) */
const shownConversations = computed(() => { const shownConversations = computed(() => {
const keywordLower = keyword.value.trim().toLowerCase() const keywordLower = keyword.value.trim().toLowerCase()
if (!keywordLower) { if (!keywordLower) {
@ -187,154 +241,81 @@ const shownConversations = computed(() => {
) )
}) })
/** 是否选中:按 conversation key 查 set */ /** 已选会话:右栏预览渲染用,按 selectedKeys 顺序展示 */
const selectedConversations = computed<Conversation[]>(() => {
const keys = selectedSet.value
return candidateConversations.value.filter((c) => keys.has(getConversationKey(c)))
})
/** 是否已选中:圆形指示 + 右栏预览过滤都走它 */
function isSelected(conversation: Conversation): boolean { function isSelected(conversation: Conversation): boolean {
return selectedKeys.value.has(getConversationKey(conversation)) return selectedSet.value.has(getConversationKey(conversation))
} }
/** 切换选中态set 引用替换触发响应式 */ /** 切换选中态:左栏 row 点击 / 右栏 × 移除都走这里 */
function handleToggle(conversation: Conversation) { function handleToggle(conversation: Conversation) {
const key = getConversationKey(conversation) const key = getConversationKey(conversation)
const next = new Set(selectedKeys.value) const index = selectedKeys.value.indexOf(key)
if (next.has(key)) { if (index >= 0) {
next.delete(key) selectedKeys.value.splice(index, 1)
} else { } else {
next.add(key) selectedKeys.value.push(key)
} }
selectedKeys.value = next
} }
/** 构造名片消息 contentJSON 字符串) */ /** 构造名片消息 contentJSON 字符串)user 由调用方 narrow 后显式传入避免 non-null 断言 */
function buildCardContent(): string { function buildCardContent(user: User): string {
const payload: CardMessage = { const payload: CardMessage = {
userId: props.user!.id!, userId: user.id!,
nickname: props.user!.nickname || '', nickname: user.nickname || '',
avatar: props.user!.avatar avatar: user.avatar
} }
return serializeMessage(payload) return serializeMessage(payload)
} }
/** 构造留言文本消息 content空文本返回 null */ /** 确认发送:每个选中会话先发 CARD 再发 TEXT 留言;失败的消息会以 FAILED 状态留在对应会话气泡里供右键重试 */
function buildLeaveTextContent(): string | null {
const text = leaveMessage.value.trim()
if (!text) {
return null
}
return serializeMessage<TextMessage>({ content: text })
}
/** 构造本地乐观消息id=0 表示尚未拿到服务端 id */
function buildLocalMessage(opts: {
clientMessageId: string
content: string
type: number
targetId: number
}): Message {
return {
id: 0,
clientMessageId: opts.clientMessageId,
type: opts.type,
content: opts.content,
status: ImMessageStatus.SENDING,
sendTime: Date.now(),
senderId: Number(userStore.getUser?.id) || 0,
targetId: opts.targetId,
selfSend: true
}
}
/** 发送一条消息到指定会话:插占位 → 调 API → ack失败更新为 FAILED */
async function sendOneToConversation(conversation: Conversation, type: number, content: string) {
const clientMessageId = generateClientMessageId()
const localMessage = buildLocalMessage({
clientMessageId,
content,
type,
targetId: conversation.targetId
})
conversationStore.insertMessage(
{
type: conversation.type,
targetId: conversation.targetId,
name: conversation.name || String(conversation.targetId),
avatar: conversation.avatar || ''
},
localMessage
)
try {
if (conversation.type === ImConversationType.PRIVATE) {
const data = await apiSendPrivateMessage({
clientMessageId,
receiverId: conversation.targetId,
type,
content
})
conversationStore.ackMessage(conversation.type, conversation.targetId, clientMessageId, {
id: data.id,
sendTime: new Date(data.sendTime).getTime(),
status: data.status,
content: data.content
})
} else {
const data = await apiSendGroupMessage({
clientMessageId,
groupId: conversation.targetId,
type,
content
})
conversationStore.ackMessage(conversation.type, conversation.targetId, clientMessageId, {
id: data.id,
sendTime: new Date(data.sendTime).getTime(),
status: data.status,
receiptStatus: data.receiptStatus,
readCount: data.readCount,
content: data.content
})
}
} catch (e) {
console.error('[IM] 名片消息发送失败', { conversation, type }, e)
conversationStore.ackMessage(conversation.type, conversation.targetId, clientMessageId, {
status: ImMessageStatus.FAILED
})
throw e
}
}
/** 确认发送:循环每个选中会话发名片,有留言再补一条文本;并发发完统一提示 */
async function handleSend() { async function handleSend() {
if (!props.user?.id) { const user = props.user
if (!user?.id || selectedKeys.value.length === 0) {
return return
} }
if (selectedKeys.value.size === 0) { const targets = selectedConversations.value
return const cardContent = buildCardContent(user)
} const leaveText = leaveMessage.value.trim()
const targets = candidateConversations.value.filter((c) =>
selectedKeys.value.has(getConversationKey(c))
)
const cardContent = buildCardContent()
const textContent = buildLeaveTextContent()
sending.value = true sending.value = true
try { try {
// allSettled
const tasks = targets.map(async (target) => { const tasks = targets.map(async (target) => {
await sendOneToConversation(target, ImMessageType.CARD, cardContent) await sendRaw(ImMessageType.CARD, cardContent, { conversation: target })
if (textContent) { if (leaveText) {
await sendOneToConversation(target, ImMessageType.TEXT, textContent) await send(leaveText, { conversation: target })
} }
}) })
// TODO @AI await Promise.all(tasks)
const results = await Promise.allSettled(tasks) message.success('已转发')
const failed = results.filter((result) => result.status === 'rejected').length
if (failed === 0) {
message.success(targets.length > 1 ? `已分别发送给 ${targets.length} 位联系人` : '发送成功')
} else if (failed < targets.length) {
message.warning(`成功 ${targets.length - failed} 个,失败 ${failed}`)
} else {
message.error('发送失败')
}
visible.value = false visible.value = false
} finally { } finally {
sending.value = false sending.value = false
} }
} }
</script> </script>
<style scoped>
/* 双栏布局要顶到 dialog 边缘:把 el-dialog body 默认 padding / header 下边距清零,两栏 border 自然分隔 */
.im-recommend-dialog :deep(.el-dialog__body) {
padding: 0;
border-top: 1px solid var(--el-border-color-lighter);
}
.im-recommend-dialog :deep(.el-dialog__header) {
margin-right: 0;
padding-bottom: 16px;
}
/* 已选行 × 移除常驻显示hover 转危险色 */
.im-recommend__remove {
color: var(--el-text-color-placeholder);
transition: color 0.15s;
}
.im-recommend__remove:hover {
color: var(--el-color-danger);
}
</style>

View File

@ -171,19 +171,11 @@
> >
[视频消息] [视频消息]
</div> </div>
<!-- 名片消息头像 + 昵称 + 个人名片标签点击气泡弹被推荐用户的名片浮层 --> <!-- 名片消息头像 + 昵称 + 个人名片标签点击气泡弹被推荐用户的名片浮层
<!-- TODO @AI卡片样式/Users/yunai/Downloads/iShot_2026-05-06_00.00.04.png 参照微信观感自己 / 对方都是浅灰白卡片不染绿头像圆角块底部分隔条灰字个人名片 -->
TODO @AImessagepreview 是不是也要加下管理后台的manager
-->
<div <div
v-else-if="isCard && cardPayload" v-else-if="isCard && cardPayload"
class="flex flex-col min-w-[220px] max-w-[260px] rounded cursor-pointer overflow-hidden" class="flex flex-col w-[240px] rounded-md overflow-hidden cursor-pointer bg-[var(--el-bg-color)] border border-[var(--el-border-color-lighter)]"
:class="[
message.selfSend ? 'message-bubble--self' : 'message-bubble--other',
message.selfSend
? 'bg-[#95ec69]'
: 'bg-[var(--el-bg-color)] border border-[var(--el-border-color-light)]'
]"
@click="handleCardClick" @click="handleCardClick"
> >
<div class="flex gap-2.5 items-center px-3 py-2.5"> <div class="flex gap-2.5 items-center px-3 py-2.5">
@ -194,15 +186,12 @@
:size="40" :size="40"
:clickable="false" :clickable="false"
/> />
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0 text-sm font-medium truncate text-[var(--el-text-color-primary)]">
<div class="text-sm font-medium truncate text-[var(--el-text-color-primary)]"> {{ cardPayload.nickname }}
{{ cardPayload.nickname }}
</div>
</div> </div>
</div> </div>
<div <div
class="px-3 py-1 text-12px border-t text-[var(--el-text-color-secondary)] border-[var(--el-border-color-lighter)]" class="px-3 py-1 text-12px border-t text-[var(--el-text-color-placeholder)] border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-lighter)]"
:class="message.selfSend ? 'bg-[#86d65f]' : 'bg-[var(--el-fill-color-lighter)]'"
> >
个人名片 个人名片
</div> </div>