From 27cd042df9aca0383a49f3514bc21958dd890912 Mon Sep 17 00:00:00 2001
From: gaoluyang <2820782392@qq.com>
Date: 星期一, 29 六月 2026 15:42:23 +0800
Subject: [PATCH] 银川 1.联调产品维护页面 2.添加IM即时通讯模块

---
 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