gaoluyang
2026-06-29 27cd042df9aca0383a49f3514bc21958dd890912
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') // å½“前视图:默认会话选择,「创建聊天」入口切到好友选择
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>