admin-vben/apps/web-antd/src/views/im/home/components/user/recommend-card-dialog.vue

323 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<script lang="ts" setup>
import type { Conversation, FriendLite } from '../../types'
import { computed, ref } from 'vue'
import { IconifyIcon as Icon } from '@vben/icons'
import { Button, Input, message, Modal } from 'ant-design-vue'
import { createGroup } from '#/api/im/group'
import { CardBubble } from '#/views/im/home/components/card'
import { ImContentType, ImConversationType, isGroupConversation } from '../../../utils/constants'
import { getConversationKey } from '../../../utils/conversation'
import { buildDefaultGroupName } from '../../../utils/group'
import { type CardTarget, serializeMessage } from '../../../utils/message'
import { isGroupQuit } from '../../../utils/user'
import { useMessageSender } from '../../composables/useMessageSender'
import { FacePicker } from '../../pages/conversation/components/input'
import { useConversationStore } from '../../store/conversationStore'
import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore'
import { ConversationPickerPanel } from '../picker'
import { FriendPickerPanel } from '../picker'
defineOptions({ name: 'ImRecommendCardDialog' })
const conversationStore = useConversationStore()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const { sendRaw, send } = useMessageSender()
const visible = ref(false)
const target = ref<CardTarget | null>(null)
const view = ref<'contact' | 'conversation'>('conversation') // 当前视图:默认会话选择,「创建聊天」入口切到好友选择
const selectedKeys = ref<string[]>([])
const selectedFriendIds = ref<number[]>([])
const leaveMessage = ref('')
const sending = ref(false)
const emojiVisible = ref(false) // 表情面板显隐:右侧 smile icon 切换
defineExpose({
/** 打开推荐弹窗reset → 灌参 → visible=true */
open(opts: { target: CardTarget }) {
target.value = opts.target
view.value = 'conversation'
selectedKeys.value = []
selectedFriendIds.value = []
leaveMessage.value = ''
emojiVisible.value = false
sending.value = false
visible.value = true
}
})
/** 弹窗标题:会话视图按 target 类型分文案;好友视图固定为「选择好友」 */
const headerTitle = computed(() => {
if (view.value === 'contact') {
return '选择好友'
}
return isGroupConversation(target.value?.targetType) ? '把这个群推荐给朋友' : '把他推荐给朋友'
})
/** 候选会话:从 store 拿排序后的列表hide 由 Panel 接 hideKeys 过滤);历史退群群不可被推荐选中(选了后端也会拒) */
const candidateConversations = computed<Conversation[]>(() =>
conversationStore.getSortedConversationList.filter(
(conversation) =>
!(
conversation.type === ImConversationType.GROUP &&
isGroupQuit(groupStore.getGroup(conversation.targetId))
)
)
)
/** 隐藏 key不能把名片推回名片本身的会话用户名片避免自推、群名片避免推回该群 */
const hideKeys = computed<string[]>(() => {
const t = target.value
if (!t) {
return []
}
return [getConversationKey({ type: t.targetType, targetId: t.targetId })]
})
/** 好友视图候选列表:直接复用 friendStore Lite 视图 */
const friends = computed<FriendLite[]>(() => friendStore.getActiveFriendLiteList)
/** 把选中的 emoji 拼到留言末尾FacePicker 自身负责关闭面板 */
function handleEmojiSelect(emoji: string) {
leaveMessage.value = `${leaveMessage.value}${emoji}`
}
/** 切到好友视图:清掉之前在会话视图输入的留言,避免在不可见输入框里把留言静默发到新群 */
function handleSwitchToContact() {
view.value = 'contact'
leaveMessage.value = ''
emojiVisible.value = false
}
/**
* 确认发送(会话视图):每个选中会话先发 CARDCARD 成功后才发留言(保证「先看到名片」的顺序意图)
*
* 文案聚合全部成功「已转发」、全部失败「转发失败A、B」、部分失败「已转发但 X、Y 失败」;
* 失败的消息以 FAILED 状态留在对应会话气泡里,可右键重试
*/
async function handleSend() {
const card = target.value
if (!card?.targetId || selectedKeys.value.length === 0) {
return
}
const byKey = new Map(candidateConversations.value.map((c) => [getConversationKey(c), c]))
const targets = selectedKeys.value
.map((key) => byKey.get(key))
.filter((c): c is Conversation => c != null)
if (targets.length === 0) {
return
}
const cardContent = serializeMessage({ ...card })
const leaveText = leaveMessage.value.trim()
sending.value = true
try {
const tasks = targets.map(async (conversation) => {
const cardOk = await sendRaw(ImContentType.CARD, cardContent, { conversation })
if (!cardOk) {
return { conversation, ok: false }
}
const ok = leaveText ? await send(leaveText, { conversation }) : true
return { conversation, ok }
})
const results = await Promise.all(tasks)
const failedNames = results.filter((r) => !r.ok).map((r) => r.conversation.name || '未命名会话')
// 把命中的目标推到最近转发列表(部分失败也推:用户的"意图"已表达)
conversationStore.pushRecentForwardConversationKeyList(targets.map((c) => getConversationKey(c)))
if (failedNames.length === 0) {
message.success('已转发')
} else if (failedNames.length === targets.length) {
message.error(`转发失败:${failedNames.join('、')}`)
} else {
message.warning(`已转发,但 ${failedNames.join('、')} 失败`)
}
visible.value = false
} finally {
sending.value = false
}
}
/**
* 好友视图发送:先建群(同时邀请所选好友)→ 给新群发名片 → 发留言 → 关弹窗
*
* 跟会话视图的差别:先要 createGroup 拿到 groupId之后构造一个 GROUP 类型的 conversation 对象给 sendRaw 用
* sendRaw 内部会自动 insertMessage 把新群登记进 store最近转发列表也能正常推
*/
async function handleCreateGroupAndSend() {
const card = target.value
if (!card?.targetId || selectedFriendIds.value.length === 0) {
return
}
const byId = new Map(friends.value.map((f) => [f.id, f]))
const members = selectedFriendIds.value
.map((id) => byId.get(id))
.filter((f): f is FriendLite => f != null)
if (members.length === 0) {
return
}
sending.value = true
try {
const memberUserIds = members.map((m) => m.id)
const name = buildDefaultGroupName(members)
const group = await createGroup({ name, memberUserIds, joinApproval: false })
if (!group?.id) {
throw new Error('创建群失败:未返回群编号')
}
// upsert 进 groupStore省一次 fetchGroupList
groupStore.upsertGroup({
id: group.id,
name: group.name,
avatar: group.avatar,
notice: group.notice,
ownerUserId: group.ownerUserId
})
// 给新群构造一个临时 conversation 对象给 sendRaw 用sendRaw 内部会自动 insertMessage 登记
const newConversation: Conversation = {
type: ImConversationType.GROUP,
targetId: group.id,
name: group.name || name,
avatar: group.avatar || '',
unreadCount: 0,
lastContent: '',
lastSendTime: 0
}
const cardOk = await sendRaw(ImContentType.CARD, serializeMessage({ ...card }), {
conversation: newConversation
})
if (!cardOk) {
message.warning('群已创建,但名片发送失败,请稍后在群里重试')
visible.value = false
return
}
const leaveText = leaveMessage.value.trim()
if (leaveText) {
await send(leaveText, { conversation: newConversation })
}
conversationStore.pushRecentForwardConversationKeyList([getConversationKey(newConversation)])
message.success('已创建群聊并发送')
visible.value = false
} finally {
sending.value = false
}
}
</script>
<template>
<!--
把名片推荐给朋友用户 / 群通用
- dialog 壳由本组件持有选择 UI 委托 ConversationPickerPanel / FriendPickerPanel
- view='conversation'选已有会话发名片默认视图
- view='contact'创建聊天入口进入选好友建群再发名片业务壳层切视图两个 Panel 互不知道对方
- 1 发送多个走分别发送(n)文案与微信一致
- 失败的消息以 FAILED 状态留在对应会话气泡里供右键重试
- 对外接口ref + open({ target })不再走 v-model
-->
<Modal
v-model:open="visible"
width="720px"
:mask-closable="false"
:footer="view === 'conversation' ? null : undefined"
class="im-picker-dialog im-recommend-dialog"
>
<template #header>
<div class="flex gap-2 items-center">
<Icon
v-if="view === 'contact'"
icon="ant-design:arrow-left-outlined"
:size="16"
class="cursor-pointer text-[var(--ant-color-text-secondary)] transition-colors duration-150 hover:text-[var(--ant-color-primary)]"
@click="view = 'conversation'"
/>
<span class="text-base text-[var(--ant-color-text)]">
{{ headerTitle }}
</span>
</div>
</template>
<div class="h-[480px]">
<!-- 会话视图:选已有会话发送 -->
<ConversationPickerPanel
v-if="view === 'conversation'"
v-model:selected-keys="selectedKeys"
:conversations="candidateConversations"
:recent-forward-conversation-keys="conversationStore.recentForwardConversationKeys"
:hide-keys="hideKeys"
:show-create-chat="true"
@create-chat="handleSwitchToContact"
@remove-recent="conversationStore.removeRecentForwardConversationKey"
>
<template #footer>
<div class="flex flex-col gap-3 px-4 py-3">
<!-- 名片预览卡 -->
<CardBubble v-if="target" :card="target" />
<!-- 留言(单行):右侧表情按钮触发 FacePicker选中 emoji 拼到末尾 -->
<div class="relative">
<Input v-model:value="leaveMessage" :maxlength="100" placeholder="给朋友留言">
<template #suffix>
<Icon
icon="ant-design:smile-outlined"
:size="18"
class="cursor-pointer text-[var(--ant-color-text-secondary)] hover:text-[var(--ant-color-primary)]"
@click.stop="emojiVisible = !emojiVisible"
/>
</template>
</Input>
<FacePicker
v-model:visible="emojiVisible"
mode="emoji"
class="bottom-full right-0 mb-2"
@select-emoji="handleEmojiSelect"
/>
</div>
<!-- 操作按钮:选 0/1 显示「发送」、多个显示「分别发送(n)」 -->
<div class="flex gap-2 justify-end">
<Button @click="visible = false">取消</Button>
<Button
type="primary"
:loading="sending"
:disabled="selectedKeys.length === 0"
@click="handleSend"
>
{{ selectedKeys.length > 1 ? `分别发送(${selectedKeys.length}` : '发送' }}
</Button>
</div>
</div>
</template>
</ConversationPickerPanel>
<!-- 好友视图:选好友建群后发送 -->
<FriendPickerPanel v-else v-model:selected-ids="selectedFriendIds" :friends="friends" />
</div>
<!-- 好友视图的 dialog footer建群并发送 -->
<template v-if="view === 'contact'" #footer>
<Button @click="visible = false">取消</Button>
<Button
type="primary"
:loading="sending"
:disabled="selectedFriendIds.length === 0"
@click="handleCreateGroupAndSend"
>
创建群聊并发送
</Button>
</template>
</Modal>
</template>
<style scoped lang="scss">
@use '../picker/picker-dialog' as picker;
/* :deep 穿 el-dialog picker mixin */
.im-picker-dialog {
@include picker.styles;
}
</style>