| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { Conversation, FriendLite, Message } from '#/views/im/home/types' |
| | | |
| | | import { computed, reactive, ref } from 'vue' |
| | | |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | |
| | | import { Button, Input, message, Modal } from 'ant-design-vue' |
| | | |
| | | import { createGroup } from '#/api/im/group' |
| | | import { ConversationPickerPanel } from '#/views/im/home/components/picker' |
| | | import { FriendPickerPanel } from '#/views/im/home/components/picker' |
| | | import { useMessageMultiSelect } from '#/views/im/home/composables/useMessageMultiSelect' |
| | | import { useMessageSender } from '#/views/im/home/composables/useMessageSender' |
| | | import { useConversationStore } from '#/views/im/home/store/conversationStore' |
| | | import { useFriendStore } from '#/views/im/home/store/friendStore' |
| | | import { useGroupStore } from '#/views/im/home/store/groupStore' |
| | | import { MESSAGE_MERGE_PREVIEW_LINES } from '#/views/im/utils/config' |
| | | import { |
| | | ImContentType, |
| | | ImConversationType, |
| | | ImForwardMode, |
| | | type ImForwardModeValue |
| | | } from '#/views/im/utils/constants' |
| | | import { getConversationKey, summarizeMessageContent } from '#/views/im/utils/conversation' |
| | | import { buildDefaultGroupName } from '#/views/im/utils/group' |
| | | import { |
| | | buildMergeMessagePayload, |
| | | removeQuotePayload, |
| | | serializeMessage |
| | | } from '#/views/im/utils/message' |
| | | import { getGroupDisplayName, isGroupQuit } from '#/views/im/utils/user' |
| | | |
| | | import { FacePicker } from '../../input' |
| | | |
| | | defineOptions({ name: 'ImMessageForwardDialog' }) |
| | | |
| | | const conversationStore = useConversationStore() |
| | | const friendStore = useFriendStore() |
| | | const groupStore = useGroupStore() |
| | | const { sendRaw, send } = useMessageSender() |
| | | const multiSelect = useMessageMultiSelect() |
| | | |
| | | const state = reactive({ |
| | | mode: ImForwardMode.SINGLE as ImForwardModeValue, |
| | | messages: [] as Message[], |
| | | sourceConversation: null as Conversation | null |
| | | }) |
| | | const visible = ref(false) |
| | | 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) // emoji picker æ¾éï¼å³ä¾§ç¬è¸æé®åæ¢ |
| | | |
| | | defineExpose({ |
| | | /** æå¼è½¬åå¼¹çªï¼reset â çå â visible=true */ |
| | | open(opts: { messages: Message[]; mode: ImForwardModeValue; sourceConversation: Conversation }) { |
| | | state.mode = opts.mode |
| | | state.messages = opts.messages |
| | | state.sourceConversation = opts.sourceConversation |
| | | view.value = 'conversation' |
| | | selectedKeys.value = [] |
| | | selectedFriendIds.value = [] |
| | | leaveMessage.value = '' |
| | | emojiVisible.value = false |
| | | sending.value = false |
| | | visible.value = true |
| | | } |
| | | }) |
| | | |
| | | /** å¼¹çªæ é¢ï¼ä¼è¯è§å¾æ mode åºåãéæ¡ / å并转åãï¼å¥½åè§å¾åºå®ä¸ºã鿩好åã */ |
| | | const headerTitle = computed(() => { |
| | | if (view.value === 'contact') { |
| | | return '鿩好å' |
| | | } |
| | | return state.mode === ImForwardMode.MERGE ? 'å并转å' : 'éæ¡è½¬å' |
| | | }) |
| | | |
| | | /** 确认æé®ææ¡ï¼åéãåéããå¤éãåå«åé(n)ã */ |
| | | const confirmButtonText = computed(() => |
| | | selectedKeys.value.length > 1 ? `åå«åéï¼${selectedKeys.value.length}ï¼` : 'åé' |
| | | ) |
| | | |
| | | /** åéä¼è¯ï¼ä» store æ¿æåºåçå表ï¼è½¬åååä¼è¯ä¹å
许ï¼ä¸å¾®ä¿¡ä¸è´ï¼ï¼å
¬ä¼å· / é¢éååæ¶æ¯ä¸æ¥å转åï¼ä»åééåé¤ */ |
| | | const candidateConversations = computed<Conversation[]>(() => |
| | | conversationStore.getSortedConversationList.filter( |
| | | (conversation) => |
| | | conversation.type !== ImConversationType.CHANNEL && |
| | | // åå²é群群ä¸å¯è¢«è½¬åéä¸ï¼éäºå端ä¹ä¼æï¼ |
| | | !( |
| | | conversation.type === ImConversationType.GROUP && |
| | | isGroupQuit(groupStore.getGroup(conversation.targetId)) |
| | | ) |
| | | ) |
| | | ) |
| | | |
| | | /** 好åè§å¾åéå表ï¼ç´æ¥å¤ç¨ friendStore Lite è§å¾ */ |
| | | const friends = computed<FriendLite[]>(() => friendStore.getActiveFriendLiteList) |
| | | |
| | | /** åå°å¥½åè§å¾ï¼æ¸
æä¹åå¨ä¼è¯è§å¾è¾å
¥ççè¨ï¼é¿å
å¨ä¸å¯è§è¾å
¥æ¡éæçè¨éé»åå°æ°ç¾¤ */ |
| | | function handleSwitchToContact() { |
| | | view.value = 'contact' |
| | | leaveMessage.value = '' |
| | | emojiVisible.value = false |
| | | } |
| | | |
| | | /** éä¸ emojiï¼æ¼å°çè¨æ«å°¾ï¼FacePicker èªèº«è´è´£å
³é颿¿ */ |
| | | function handleEmojiSelect(emoji: string) { |
| | | leaveMessage.value = `${leaveMessage.value}${emoji}` |
| | | } |
| | | |
| | | /** åå¹¶ payload + åºåå contentï¼merge 模å¼ä¸ä¸æ¬¡æé ï¼é¢è§ / åéå
±ç¨ */ |
| | | const mergeBundle = computed(() => { |
| | | if ( |
| | | state.mode !== ImForwardMode.MERGE || |
| | | !state.sourceConversation || |
| | | state.messages.length === 0 |
| | | ) { |
| | | return null |
| | | } |
| | | const payload = buildMergeMessagePayload(state.messages, state.sourceConversation) |
| | | return { payload, content: serializeMessage(payload) } |
| | | }) |
| | | |
| | | /** å并模å¼é¢è§ï¼ä» messages å N æ¡æ´¾çã{æµç§°}ï¼{æè¦}ã */ |
| | | const mergePreview = computed(() => { |
| | | const payload = mergeBundle.value?.payload |
| | | if (!payload) { |
| | | return null |
| | | } |
| | | const lines = payload.messages |
| | | .slice(0, MESSAGE_MERGE_PREVIEW_LINES) |
| | | .map((item) => `${item.senderNickname}ï¼${summarizeMessageContent(item)}`) |
| | | return { title: payload.title, lines } |
| | | }) |
| | | |
| | | /** éæ¡æ¨¡å¼é¢è§ï¼åå N æ¡æè¦ */ |
| | | const singlePreviewLines = computed(() => |
| | | state.messages.slice(0, MESSAGE_MERGE_PREVIEW_LINES).map((m) => summarizeMessageContent(m)) |
| | | ) |
| | | |
| | | /** å¾
åéçéæ¡æ¶æ¯ï¼å¥ç¦» quote 䏿¬¡ï¼åéå¤ç®æ æ¶å¤ç¨ */ |
| | | const cleanedSinglePayloads = computed(() => |
| | | state.messages.map((m) => ({ type: m.type, content: removeQuotePayload(m.content) })) |
| | | ) |
| | | |
| | | /** |
| | | * ç»åä¸ªç®æ åéè½¬åæ¶æ¯ï¼merge 䏿¬¡ sendRawãsingle ææ¶é´åºéæ¡ sendRaw |
| | | * |
| | | * ä»»ä¸åæ¶æ¯å¤±è´¥å³åæ¢åç»ï¼è¿å false 让ä¸å±è·³è¿çè¨ |
| | | */ |
| | | async function forwardToTarget(target: Conversation): Promise<boolean> { |
| | | if (state.mode === ImForwardMode.MERGE) { |
| | | const content = mergeBundle.value?.content |
| | | if (!content) { |
| | | return false |
| | | } |
| | | return sendRaw(ImContentType.MERGE, content, { conversation: target }) |
| | | } |
| | | for (const payload of cleanedSinglePayloads.value) { |
| | | const ok = await sendRaw(payload.type, payload.content, { conversation: target }) |
| | | if (!ok) { |
| | | return false |
| | | } |
| | | } |
| | | return true |
| | | } |
| | | |
| | | /** |
| | | * 确认åéï¼åæ¡æ¨¡å¼éæ¡å¾ªç¯ï¼å并模å¼å䏿¡ MergeMessage |
| | | * |
| | | * ææ¡èåï¼å
¨é¨æåã已转åããå
¨é¨å¤±è´¥ã转å失败ï¼AãBããé¨å失败ã已转åï¼ä½ XãY 失败ãï¼ |
| | | * çè¨åªå¨è¯¥ç®æ çè½¬åæ¶æ¯å
¨é¨æååæè¿½å ï¼é¿å
éåº |
| | | */ |
| | | async function handleSend() { |
| | | if (selectedKeys.value.length === 0) { |
| | | return |
| | | } |
| | | if (state.messages.length === 0) { |
| | | message.warning('没æå¯è½¬åçæ¶æ¯') |
| | | return |
| | | } |
| | | // 忥已é conversation å¯¹è±¡ï¼æ selectedKeys æ°ç»é¡ºåºï¼å³ç¹å»é¡ºåºï¼ |
| | | const candidates = candidateConversations.value |
| | | const byKey = new Map(candidates.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 leaveText = leaveMessage.value.trim() |
| | | sending.value = true |
| | | try { |
| | | const tasks = targets.map(async (target) => { |
| | | const forwardOk = await forwardToTarget(target) |
| | | if (!forwardOk) { |
| | | return { target, ok: false } |
| | | } |
| | | const ok = leaveText ? await send(leaveText, { conversation: target }) : true |
| | | return { target, ok } |
| | | }) |
| | | const results = await Promise.all(tasks) |
| | | const failedNames = results.filter((r) => !r.ok).map((r) => r.target.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('ã')} 失败`) |
| | | } |
| | | if (multiSelect.state.active) { |
| | | multiSelect.exit() |
| | | } |
| | | visible.value = false |
| | | } finally { |
| | | sending.value = false |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 好åè§å¾åéï¼å
建群ï¼åæ¶é请æé好åï¼â ç»æ°ç¾¤å¤ç¨ forwardToTarget 转å â åçè¨ â å
³å¼¹çª |
| | | * |
| | | * è·ä¼è¯è§å¾çå·®å«ï¼å
è¦ createGroup æ¿å° groupIdï¼ä¹åæé GROUP ä¸´æ¶ conversation åç»å·²æç forwardToTarget |
| | | * ï¼sendRaw å
é¨ä¼èªå¨ insertMessage ææ°ç¾¤ç»è®°è¿ storeï¼æè¿è½¬åå表ä¹è½æ£å¸¸æ¨ï¼ |
| | | */ |
| | | async function handleCreateGroupAndSend() { |
| | | if (selectedFriendIds.value.length === 0) { |
| | | return |
| | | } |
| | | if (state.messages.length === 0) { |
| | | message.warning('没æå¯è½¬åçæ¶æ¯') |
| | | 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 å¯¹è±¡ç» forwardToTarget ç¨ï¼sendRaw å
é¨ä¼èªå¨ insertMessage ç»è®° |
| | | const newConversation: Conversation = { |
| | | type: ImConversationType.GROUP, |
| | | targetId: group.id, |
| | | name: getGroupDisplayName(group) || name, |
| | | avatar: group.avatar || '', |
| | | unreadCount: 0, |
| | | lastContent: '', |
| | | lastSendTime: 0 |
| | | } |
| | | const forwardOk = await forwardToTarget(newConversation) |
| | | if (forwardOk) { |
| | | const leaveText = leaveMessage.value.trim() |
| | | if (leaveText) { |
| | | await send(leaveText, { conversation: newConversation }) |
| | | } |
| | | conversationStore.pushRecentForwardConversationKeyList([getConversationKey(newConversation)]) |
| | | message.success('å·²å建群è并转å') |
| | | } else { |
| | | message.warning('群已å建ï¼ä½æ¶æ¯è½¬å失败ï¼è¯·ç¨åå¨ç¾¤ééè¯') |
| | | } |
| | | // ç»ä¸éå¤é + å
³å¼¹çªï¼æå / 失败é½è¦éæºä¼è¯çå¤éæï¼é¿å
éç |
| | | if (multiSelect.state.active) { |
| | | multiSelect.exit() |
| | | } |
| | | visible.value = false |
| | | } finally { |
| | | sending.value = false |
| | | } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | è½¬åæ¶æ¯ï¼éæ¡ / åå¹¶ï¼ï¼éç®æ ä¼è¯ + çè¨åæ¹éåé |
| | | - dialog 壳æ¬ç»ä»¶ææï¼éæ© UI å§æ ConversationPickerPanel / FriendPickerPanel |
| | | - view='conversation'ï¼éå·²æä¼è¯åéï¼é»è®¤è§å¾ï¼ |
| | | - view='contact'ï¼ä»ãå建è天ãå
¥å£è¿å
¥ï¼é好å建群å转åï¼ä¸å¡å£³å±åè§å¾ |
| | | - footer slot å¡é¢è§å¡ï¼åå¹¶ / éæ¡ä¸åè§è§ï¼+ çè¨ + æäº¤æé® |
| | | - 坹夿¥å£æ²¿ç¨ï¼ref + open({ mode, messages, sourceConversation }) |
| | | --> |
| | | <Modal |
| | | v-model:open="visible" |
| | | width="720px" |
| | | :mask-closable="false" |
| | | :footer="view === 'conversation' ? null : undefined" |
| | | class="im-picker-dialog im-forward-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 im-forward-dialog__back" |
| | | @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" |
| | | :show-create-chat="true" |
| | | @create-chat="handleSwitchToContact" |
| | | @remove-recent="conversationStore.removeRecentForwardConversationKey" |
| | | > |
| | | <template #footer> |
| | | <div class="flex flex-col gap-3 px-4 py-3"> |
| | | <!-- å并模å¼é¢è§ï¼ã[è天记å½] æ é¢ + æè¦å表ãé¢è§å¡ --> |
| | | <div |
| | | v-if="state.mode === ImForwardMode.MERGE && mergePreview" |
| | | class="flex flex-col w-full overflow-hidden rounded-md bg-[var(--ant-color-bg-container)] border border-solid border-[var(--ant-color-border-secondary)]" |
| | | > |
| | | <div |
| | | class="px-3 py-2 text-sm font-medium truncate text-[var(--ant-color-text)]" |
| | | > |
| | | {{ mergePreview.title }} |
| | | </div> |
| | | <div class="flex flex-col px-3 pb-2 gap-0.5"> |
| | | <div |
| | | v-for="(line, idx) in mergePreview.lines" |
| | | :key="idx" |
| | | class="text-12px text-[var(--ant-color-text-secondary)] truncate" |
| | | > |
| | | {{ line }} |
| | | </div> |
| | | </div> |
| | | <div |
| | | class="px-3 py-1 text-12px border-t border-t-solid text-[var(--ant-color-text-placeholder)] border-[var(--ant-color-border-secondary)] bg-[var(--ant-color-fill-tertiary)]" |
| | | > |
| | | èå¤©è®°å½ |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- éæ¡æ¨¡å¼é¢è§ï¼æ¶æ¯æ° + 馿¡æè¦ --> |
| | | <div |
| | | v-else-if="state.mode === ImForwardMode.SINGLE && singlePreviewLines.length > 0" |
| | | class="flex flex-col w-full overflow-hidden rounded-md bg-[var(--ant-color-bg-container)] border border-solid border-[var(--ant-color-border-secondary)]" |
| | | > |
| | | <div class="flex flex-col px-3 py-2 gap-0.5"> |
| | | <div |
| | | v-for="(line, idx) in singlePreviewLines" |
| | | :key="idx" |
| | | class="text-13px text-[var(--ant-color-text)] truncate" |
| | | > |
| | | {{ line }} |
| | | </div> |
| | | </div> |
| | | <div |
| | | class="px-3 py-1 text-12px border-t border-t-solid text-[var(--ant-color-text-placeholder)] border-[var(--ant-color-border-secondary)] bg-[var(--ant-color-fill-tertiary)]" |
| | | > |
| | | å
± {{ state.messages.length }} æ¡æ¶æ¯ |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- çè¨ï¼åè¡ï¼ï¼å³ä¾§è¡¨æ
æé®è§¦å 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> |
| | | |
| | | <div class="flex gap-2 justify-end"> |
| | | <Button @click="visible = false">åæ¶</Button> |
| | | <Button |
| | | type="primary" |
| | | :loading="sending" |
| | | :disabled="selectedKeys.length === 0" |
| | | @click="handleSend" |
| | | > |
| | | {{ confirmButtonText }} |
| | | </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"> |
| | | /* å¤ç¨éæ©ç±»å¼¹çªçå
Œ
± mixinï¼ å
鍿¯ :deep ç©¿é el-dialog å
é¨ header / body çæ ·å¼ï¼æ æ³æ¿æ¢ä¸ºå·¥å
·ç±» */ |
| | | @use '#/views/im/home/components/picker/picker-dialog' as picker; |
| | | |
| | | .im-picker-dialog { |
| | | @include picker.styles; |
| | | } |
| | | </style> |