gaoluyang
昨天 b64a0deae5b5d33f9e20671a68936b27f0b9b00b
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
<script lang="ts" setup>
import type { Conversation, FriendLite } from '../../types'
 
import { computed, 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 { CardBubble } from '#/views/im/home/components/card'
 
import { ImContentType, ImConversationType, isGroupConversation } from '../../../utils/constants'
import { getConversationKey } from '../../../utils/conversation'
import { buildDefaultGroupName } from '../../../utils/group'
import { type CardTarget, serializeMessage } from '../../../utils/message'
import { getGroupDisplayName, isGroupQuit } from '../../../utils/user'
import { useMessageSender } from '../../composables/useMessageSender'
import { FacePicker } from '../../pages/conversation/components/input'
import { useConversationStore } from '../../store/conversationStore'
import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore'
import { ConversationPickerPanel } from '../picker'
import { FriendPickerPanel } from '../picker'
 
defineOptions({ name: 'ImRecommendCardDialog' })
 
const conversationStore = useConversationStore()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const { sendRaw, send } = useMessageSender()
 
const visible = ref(false)
const target = ref<CardTarget | null>(null)
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) // 表情面板显隐:右侧 smile icon 切换
 
defineExpose({
  /** 打开推荐弹窗:reset → 灌参 → visible=true */
  open(opts: { target: CardTarget }) {
    target.value = opts.target
    view.value = 'conversation'
    selectedKeys.value = []
    selectedFriendIds.value = []
    leaveMessage.value = ''
    emojiVisible.value = false
    sending.value = false
    visible.value = true
  }
})
 
/** 弹窗标题:会话视图按 target 类型分文案;好友视图固定为「选择好友」 */
const headerTitle = computed(() => {
  if (view.value === 'contact') {
    return '选择好友'
  }
  return isGroupConversation(target.value?.targetType) ? '把这个群推荐给朋友' : '把他推荐给朋友'
})
 
/** 候选会话:从 store 拿排序后的列表(hide 由 Panel 接 hideKeys 过滤);历史退群群不可被推荐选中(选了后端也会拒) */
const candidateConversations = computed<Conversation[]>(() =>
  conversationStore.getSortedConversationList.filter(
    (conversation) =>
      !(
        conversation.type === ImConversationType.GROUP &&
        isGroupQuit(groupStore.getGroup(conversation.targetId))
      )
  )
)
 
/** 隐藏 key:不能把名片推回名片本身的会话(用户名片避免自推、群名片避免推回该群) */
const hideKeys = computed<string[]>(() => {
  const t = target.value
  if (!t) {
    return []
  }
  return [getConversationKey({ type: t.targetType, targetId: t.targetId })]
})
 
/** 好友视图候选列表:直接复用 friendStore Lite 视图 */
const friends = computed<FriendLite[]>(() => friendStore.getActiveFriendLiteList)
 
/** 把选中的 emoji 拼到留言末尾;FacePicker 自身负责关闭面板 */
function handleEmojiSelect(emoji: string) {
  leaveMessage.value = `${leaveMessage.value}${emoji}`
}
 
/** 切到好友视图:清掉之前在会话视图输入的留言,避免在不可见输入框里把留言静默发到新群 */
function handleSwitchToContact() {
  view.value = 'contact'
  leaveMessage.value = ''
  emojiVisible.value = false
}
 
/**
 * 确认发送(会话视图):每个选中会话先发 CARD,CARD 成功后才发留言(保证「先看到名片」的顺序意图)
 *
 * 文案聚合:全部成功「已转发」、全部失败「转发失败:A、B」、部分失败「已转发,但 X、Y 失败」;
 * 失败的消息以 FAILED 状态留在对应会话气泡里,可右键重试
 */
async function handleSend() {
  const card = target.value
  if (!card?.targetId || selectedKeys.value.length === 0) {
    return
  }
  const byKey = new Map(candidateConversations.value.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 cardContent = serializeMessage({ ...card })
  const leaveText = leaveMessage.value.trim()
  sending.value = true
  try {
    const tasks = targets.map(async (conversation) => {
      const cardOk = await sendRaw(ImContentType.CARD, cardContent, { conversation })
      if (!cardOk) {
        return { conversation, ok: false }
      }
      const ok = leaveText ? await send(leaveText, { conversation }) : true
      return { conversation, ok }
    })
    const results = await Promise.all(tasks)
    const failedNames = results.filter((r) => !r.ok).map((r) => r.conversation.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('、')} 失败`)
    }
    visible.value = false
  } finally {
    sending.value = false
  }
}
 
/**
 * 好友视图发送:先建群(同时邀请所选好友)→ 给新群发名片 → 发留言 → 关弹窗
 *
 * 跟会话视图的差别:先要 createGroup 拿到 groupId,之后构造一个 GROUP 类型的 conversation 对象给 sendRaw 用
 * (sendRaw 内部会自动 insertMessage 把新群登记进 store,最近转发列表也能正常推)
 */
async function handleCreateGroupAndSend() {
  const card = target.value
  if (!card?.targetId || selectedFriendIds.value.length === 0) {
    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 对象给 sendRaw 用;sendRaw 内部会自动 insertMessage 登记
    const newConversation: Conversation = {
      type: ImConversationType.GROUP,
      targetId: group.id,
      name: getGroupDisplayName(group) || name,
      avatar: group.avatar || '',
      unreadCount: 0,
      lastContent: '',
      lastSendTime: 0
    }
    const cardOk = await sendRaw(ImContentType.CARD, serializeMessage({ ...card }), {
      conversation: newConversation
    })
    if (!cardOk) {
      message.warning('群已创建,但名片发送失败,请稍后在群里重试')
      visible.value = false
      return
    }
    const leaveText = leaveMessage.value.trim()
    if (leaveText) {
      await send(leaveText, { conversation: newConversation })
    }
    conversationStore.pushRecentForwardConversationKeyList([getConversationKey(newConversation)])
    message.success('已创建群聊并发送')
    visible.value = false
  } finally {
    sending.value = false
  }
}
</script>
 
<template>
  <!--
    把名片推荐给朋友(用户 / 群通用)
    - dialog 壳由本组件持有;选择 UI 委托 ConversationPickerPanel / FriendPickerPanel
    - view='conversation':选已有会话发名片(默认视图)
    - view='contact':从「创建聊天」入口进入,选好友建群再发名片,业务壳层切视图,两个 Panel 互不知道对方
    - 选 1 走「发送」、多个走「分别发送(n)」文案,与微信一致
    - 失败的消息以 FAILED 状态留在对应会话气泡里,供右键重试
    - 对外接口:ref + open({ target }),不再走 v-model
  -->
  <Modal
    v-model:open="visible"
    width="720px"
    :mask-closable="false"
    :footer="view === 'conversation' ? null : undefined"
    class="im-picker-dialog im-recommend-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 text-[var(--ant-color-text-secondary)] transition-colors duration-150 hover:text-[var(--ant-color-primary)]"
          @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"
        :hide-keys="hideKeys"
        :show-create-chat="true"
        @create-chat="handleSwitchToContact"
        @remove-recent="conversationStore.removeRecentForwardConversationKey"
      >
        <template #footer>
          <div class="flex flex-col gap-3 px-4 py-3">
            <!-- 名片预览卡 -->
            <CardBubble v-if="target" :card="target" />
 
            <!-- 留言(单行):右侧表情按钮触发 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>
 
            <!-- 操作按钮:选 0/1 显示「发送」、多个显示「分别发送(n)」 -->
            <div class="flex gap-2 justify-end">
              <Button @click="visible = false">取消</Button>
              <Button
                type="primary"
                :loading="sending"
                :disabled="selectedKeys.length === 0"
                @click="handleSend"
              >
                {{ selectedKeys.length > 1 ? `分别发送(${selectedKeys.length})` : '发送' }}
              </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">
@use '../picker/picker-dialog' as picker;
 
/* :deep 穿透 el-dialog 内部类;复用 picker 公共 mixin */
.im-picker-dialog {
  @include picker.styles;
}
</style>