gaoluyang
2026-06-24 bfdc0e0e6d5e47aa501f9b6fadd143d9c97cf00a
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
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>