From cb9cd49627b65a4c0e137e08063271a8cefe1826 Mon Sep 17 00:00:00 2001
From: gaoluyang <2820782392@qq.com>
Date: 星期四, 23 七月 2026 17:48:49 +0800
Subject: [PATCH] Merge remote-tracking branch 'origin/dev_pro2.0' into dev_pro2.0
---
src/views/im/home/pages/conversation/components/message/forward/message-forward-dialog.vue | 446 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 files changed, 446 insertions(+), 0 deletions(-)
diff --git a/src/views/im/home/pages/conversation/components/message/forward/message-forward-dialog.vue b/src/views/im/home/pages/conversation/components/message/forward/message-forward-dialog.vue
new file mode 100644
index 0000000..b88eb23
--- /dev/null
+++ b/src/views/im/home/pages/conversation/components/message/forward/message-forward-dialog.vue
@@ -0,0 +1,446 @@
+<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') // 褰撳墠瑙嗗浘锛氶粯璁や細璇濋�夋嫨锛屻�屽垱寤鸿亰澶┿�嶅叆鍙e垏鍒板ソ鍙嬮�夋嫨
+const selectedKeys = ref<string[]>([])
+const selectedFriendIds = ref<number[]>([])
+const leaveMessage = ref('')
+const sending = ref(false)
+const emojiVisible = ref(false) // emoji picker 鏄鹃殣锛氬彸渚х瑧鑴告寜閽垏鎹�
+
+defineExpose({
+ /** 鎵撳紑杞彂寮圭獥锛歳eset 鈫� 鐏屽弬 鈫� 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锛氭嫾鍒扮暀瑷�鏈熬锛汧acePicker 鑷韩璐熻矗鍏抽棴闈㈡澘 */
+function handleEmojiSelect(emoji: string) {
+ leaveMessage.value = `${leaveMessage.value}${emoji}`
+}
+
+/** 鍚堝苟 payload + 搴忓垪鍖� content锛沵erge 妯″紡涓嬩竴娆℃瀯閫狅紝棰勮 / 鍙戦�佸叡鐢� */
+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銆乻ingle 鎸夋椂闂村簭閫愭潯 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銆丅銆嶃�侀儴鍒嗗け璐ャ�屽凡杞彂锛屼絾 X銆乊 澶辫触銆嶏紱
+ * 鐣欒█鍙湪璇ョ洰鏍囩殑杞彂娑堟伅鍏ㄩ儴鎴愬姛鍚庢墠杩藉姞锛岄伩鍏嶉敊搴�
+ */
+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
+ * 锛坰endRaw 鍐呴儴浼氳嚜鍔� 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'锛氫粠銆屽垱寤鸿亰澶┿�嶅叆鍙h繘鍏ワ紝閫夊ソ鍙嬪缓缇ゅ啀杞彂锛屼笟鍔″3灞傚垏瑙嗗浘
+ - footer slot 濉為瑙堝崱锛堝悎骞� / 閫愭潯涓嶅悓瑙嗚锛�+ 鐣欒█ + 鎻愪氦鎸夐挳
+ - 瀵瑰鎺ュ彛娌跨敤锛歳ef + 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>
--
Gitblit v1.9.3