gaoluyang
2026-06-29 27cd042df9aca0383a49f3514bc21958dd890912
src/views/im/home/components/user/recommend-card-dialog.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,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>