✨ feat(im): 优化名片消息类型 v0.2:优化转发弹窗的界面样式
parent
c15d75ba91
commit
957a63f8f4
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 构造名片消息 content(JSON 字符串) */
|
/** 构造名片消息 content(JSON 字符串);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>
|
||||||
|
|
|
||||||
|
|
@ -171,19 +171,11 @@
|
||||||
>
|
>
|
||||||
[视频消息]
|
[视频消息]
|
||||||
</div>
|
</div>
|
||||||
<!-- 名片消息:头像 + 昵称 + 「个人名片」标签;点击气泡弹被推荐用户的名片浮层 -->
|
<!-- 名片消息:头像 + 昵称 + 「个人名片」标签;点击气泡弹被推荐用户的名片浮层
|
||||||
<!-- TODO @AI:卡片样式,/Users/yunai/Downloads/iShot_2026-05-06_00.00.04.png;
|
参照微信观感:自己 / 对方都是浅灰白卡片不染绿,头像圆角块,底部分隔条灰字「个人名片」 -->
|
||||||
TODO @AI:messagepreview 是不是也要加下?管理后台的;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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue