| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |