| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { ImFacePackApi } from '#/api/im/face/pack' |
| | | import type { ImFaceUserItemApi } from '#/api/im/face/userItem' |
| | | |
| | | import { computed, onUnmounted, ref, useTemplateRef, watch } from 'vue' |
| | | |
| | | import { IconifyIcon as Icon } from '#/packages/icons/src' |
| | | |
| | | import { message, Tooltip } from 'ant-design-vue' |
| | | |
| | | import { uploadFile } from '#/api/infra/file' |
| | | import { useFaceStore } from '#/views/im/home/store/faceStore' |
| | | import { IM_EMOJI_LIST } from '#/views/im/utils/emoji' |
| | | import { probeImageSize } from '#/views/im/utils/image' |
| | | |
| | | defineOptions({ name: 'ImFacePicker' }) |
| | | |
| | | const props = withDefaults( |
| | | defineProps<{ |
| | | /** fullï¼emoji + 个人表æ
+ ç³»ç»å
ï¼è天主è¾å
¥ç¨ï¼ï¼emojiï¼ä»
emojiï¼çè¨ / è¯è®ºåºæ¯ï¼ */ |
| | | mode?: FacePickerMode |
| | | visible: boolean |
| | | }>(), |
| | | { mode: 'full' } |
| | | ) |
| | | |
| | | const emit = defineEmits<{ |
| | | /** éä¸ Unicode emojiï¼å¦ ðï¼ï¼è°ç¨æ¹åºæå
¥å°è¾å
¥æ¡èµ° TEXT éé */ |
| | | selectEmoji: [emoji: string] |
| | | /** éä¸è¡¨æ
è´´å¾ï¼è°ç¨æ¹åºèµ° FACE æ¶æ¯åé */ |
| | | selectFace: [face: { height: number; name?: string; url: string; width: number; }] |
| | | 'update:visible': [value: boolean] |
| | | }>() |
| | | |
| | | /** 颿¿æ¨¡å¼ */ |
| | | type FacePickerMode = 'emoji' | 'full' |
| | | |
| | | const rootRef = useTemplateRef<HTMLDivElement>('rootRef') |
| | | const uploadInputRef = useTemplateRef<HTMLInputElement>('uploadInputRef') |
| | | |
| | | const faceStore = useFaceStore() |
| | | |
| | | // tab æ è¯å¸¸éï¼pack:N ç±»ç¨ packTabKey() æ¼åºï¼é¿å
æ£è½å符串åé¢é |
| | | const FACE_TAB = { |
| | | EMOJI: 'emoji', |
| | | MINE: 'mine' |
| | | } as const |
| | | const packTabKey = (packId: number) => `pack:${packId}` |
| | | |
| | | const activeTab = ref<string>(FACE_TAB.EMOJI) // å½åæ¿æ´»ç tab |
| | | |
| | | /** æ¯å¦å®æ´æ¨¡å¼ï¼å«ä¸ªäºº / ç³»ç»å
tabï¼ */ |
| | | const isFullMode = computed(() => props.mode === 'full') |
| | | |
| | | const uploading = ref(false) // ä¸ä¼ 䏿 è®°ï¼é¿å
è¿ç»ç¹å»è§¦åå¹¶åä¸ä¼ |
| | | |
| | | /** é emoji åç¬¦ï¼æå°è¾å
¥æ¡ï¼éå®ä¸å
³é¢æ¿ï¼æ¹ä¾¿ç¨æ·è¿åå¤ä¸ª */ |
| | | function handleSelectEmoji(emoji: string) { |
| | | emit('selectEmoji', emoji) |
| | | } |
| | | |
| | | /** é个人表æ
ï¼ç´æ¥åï¼ç¹å®å
³é¢æ¿ï¼å¯¹é½å¾®ä¿¡ */ |
| | | function handleSelectFaceUserItem(item: ImFaceUserItemApi.FaceUserItem) { |
| | | emit('selectFace', { url: item.url, width: item.width, height: item.height, name: item.name }) |
| | | emit('update:visible', false) |
| | | } |
| | | |
| | | /** éç³»ç»è¡¨æ
å
å
表æ
ï¼ç´æ¥åï¼ç¹å®å
³é¢æ¿ */ |
| | | function handleSelectPackItem(item: ImFacePackApi.FacePackUserItem) { |
| | | emit('selectFace', { url: item.url, width: item.width, height: item.height, name: item.name }) |
| | | emit('update:visible', false) |
| | | } |
| | | |
| | | /** é¿æ / å³é®å é¤ä¸ªäººè¡¨æ
*/ |
| | | async function handleDeleteUserItem(item: ImFaceUserItemApi.FaceUserItem) { |
| | | if (!confirm('确认å é¤è¯¥è¡¨æ
ï¼')) { |
| | | return |
| | | } |
| | | await faceStore.removeFaceUserItem(item.id) |
| | | } |
| | | |
| | | /** ç¹ + 触åæä»¶éæ© */ |
| | | function onUploadClick() { |
| | | uploadInputRef.value?.click() |
| | | } |
| | | |
| | | /** æä»¶éå®å³ä¸ä¼ ï¼æåååå
¥ faceStore 个人表æ
å表 */ |
| | | async function onUploadPicked(e: Event) { |
| | | const input = e.target as HTMLInputElement |
| | | const file = input.files?.[0] |
| | | input.value = '' |
| | | if (!file) { |
| | | return |
| | | } |
| | | uploading.value = true |
| | | let size: { height: number; width: number; } |
| | | try { |
| | | size = await probeImageSize(file) |
| | | } catch (error) { |
| | | console.warn('[IM] è§£æä¸ªäººè¡¨æ
失败', error) |
| | | message.error('å¾çè§£æå¤±è´¥') |
| | | uploading.value = false |
| | | return |
| | | } |
| | | |
| | | try { |
| | | const url = await uploadFile({ file }) |
| | | if (!url) { |
| | | message.error('ä¸ä¼ 失败') |
| | | return |
| | | } |
| | | const payload = { url, width: size.width, height: size.height } |
| | | await faceStore.addFaceUserItem(payload) |
| | | } finally { |
| | | uploading.value = false |
| | | } |
| | | } |
| | | |
| | | /** 颿¿å±å¼æ¶ææ°æ® + æå
¨å± clickï¼mode='emoji' æ¶ä¸æç³»ç»å
/ 个人表æ
*/ |
| | | watch( |
| | | () => props.visible, |
| | | (visible) => { |
| | | if (visible) { |
| | | document.addEventListener('click', handleDocumentClick) |
| | | if (isFullMode.value) { |
| | | // ç³»ç»å
é常已被 home onMounted 颿è¿ï¼ensureXxx å
é¨ promise ç¼åé¿å
éå¤è¯·æ± |
| | | void faceStore.ensureFacePackList() |
| | | void faceStore.ensureFaceUserItemList() |
| | | } |
| | | } else { |
| | | document.removeEventListener('click', handleDocumentClick) |
| | | } |
| | | }, |
| | | { immediate: true } |
| | | ) |
| | | |
| | | /** ç¹å»é¢æ¿å¤é¨å
³é */ |
| | | function handleDocumentClick(e: MouseEvent) { |
| | | if (!props.visible || !rootRef.value) { |
| | | return |
| | | } |
| | | if (!rootRef.value.contains(e.target as Node)) { |
| | | emit('update:visible', false) |
| | | } |
| | | } |
| | | |
| | | onUnmounted(() => { |
| | | document.removeEventListener('click', handleDocumentClick) |
| | | }) |
| | | </script> |
| | | |
| | | <template> |
| | | <!-- |
| | | 表æ
颿¿ï¼å¤ tabï¼ï¼emoji / 个人表æ
/ N 个系ç»è¡¨æ
å
|
| | | - 对é½å¾®ä¿¡ PCï¼åºé¨ tab æ 忢颿¿å
容ï¼emoji ä¿æ Unicodeï¼ä»ç± TEXT ééåéï¼ |
| | | - 个人表æ
/ ç³»ç»è¡¨æ
èµ° FACE å
容类åï¼éè¿ selectFace äºä»¶ç±è°ç¨æ¹èµ° sendRaw åé |
| | | - mode='emoji' æ¶åªæ¾ç¤º emoji tab + éèåºé¨ tab æ ï¼ç»çè¨ / è¯è®ºè¿ç±»åªåææ¬çåºæ¯ç¨ |
| | | - å®ä½ç±è°ç¨æ¹å³å®ï¼é常浮å¨è¡¨æ
æé®ä¸æ¹ï¼ |
| | | --> |
| | | <div |
| | | v-if="visible" |
| | | ref="rootRef" |
| | | class="im-popover-arrow absolute z-100 flex flex-col w-[380px] rounded-md bg-[var(--ant-color-bg-container)] shadow-[0_4px_16px_rgba(0,0,0,0.12)]" |
| | | @click.stop |
| | | > |
| | | <!-- 主å
容åºï¼é«åº¦åºå® + å tab ç¨ v-show 忢ï¼é¿å
æ¯æ¬¡å tab é建 scrollbar é ææ»å¨ä½ç½®ä¸¢å¤± --> |
| | | <div class="relative h-[300px] overflow-hidden"> |
| | | <!-- emoji ç½æ ¼ --> |
| | | <div v-show="activeTab === FACE_TAB.EMOJI" style="height: 300px; overflow-y: auto"> |
| | | <div class="grid grid-cols-10 gap-0.5 p-2"> |
| | | <button |
| | | v-for="emoji in IM_EMOJI_LIST" |
| | | :key="emoji" |
| | | class="p-1 text-xl leading-none bg-transparent border-none rounded cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]" |
| | | type="button" |
| | | @click="handleSelectEmoji(emoji)" |
| | | > |
| | | {{ emoji }} |
| | | </button> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 个人表æ
ï¼5 åæ¹æ ¼ï¼æ ååæ ç¾ï¼æ«å°¾ã+ãä¸ä¼ --> |
| | | <div v-if="isFullMode" v-show="activeTab === FACE_TAB.MINE" style="height: 300px; overflow-y: auto"> |
| | | <div class="grid grid-cols-5 gap-2 p-3"> |
| | | <!-- ä¸ä¼ å
¥å£åºå®æ¾ç¬¬ä¸æ ¼ï¼dashed border ä¸è¡¨æ
æ ¼ååºåè§è§è¯ä¹ï¼å¯¹é½ el-upload è§æ --> |
| | | <button |
| | | class="aspect-square flex items-center justify-center rounded-md cursor-pointer transition-colors border border-dashed border-[var(--ant-color-border)] bg-transparent text-[var(--ant-color-text-placeholder)] hover:border-[var(--ant-color-primary)] hover:text-[var(--ant-color-primary)] disabled:cursor-not-allowed disabled:text-[var(--ant-color-text-disabled)]" |
| | | type="button" |
| | | :disabled="uploading" |
| | | :title="uploading ? 'ä¸ä¼ ä¸â¦' : 'ä¸ä¼ å¾çå°ä¸ªäººè¡¨æ
'" |
| | | @click="onUploadClick" |
| | | > |
| | | <Icon |
| | | :icon="uploading ? 'eos-icons:bubble-loading' : 'ant-design:plus-outlined'" |
| | | :size="22" |
| | | /> |
| | | </button> |
| | | <div |
| | | v-for="item in faceStore.faceUserItems" |
| | | :key="item.id" |
| | | class="im-face-grid-cell group relative aspect-square flex items-center justify-center rounded-md bg-[var(--ant-color-fill-tertiary)] cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]" |
| | | :title="item.name || 'ç¹å»åé / å³é®å é¤'" |
| | | @click="handleSelectFaceUserItem(item)" |
| | | @contextmenu.prevent="handleDeleteUserItem(item)" |
| | | > |
| | | <img |
| | | :src="item.url" |
| | | :alt="item.name || '表æ
'" |
| | | class="max-w-full max-h-full object-contain" |
| | | draggable="false" |
| | | /> |
| | | </div> |
| | | <div |
| | | v-if="faceStore.faceUserItems.length === 0" |
| | | class="col-span-5 flex items-center justify-center py-12 text-sm text-[var(--ant-color-text-placeholder)]" |
| | | > |
| | | è¿æ²¡æä¸ªäººè¡¨æ
ï¼ç¹ + ä¸ä¼ / å¨è天éé¿ææ¶æ¯æ·»å |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- ç³»ç»è¡¨æ
å
ï¼5 åæ¹æ ¼ + 䏿¹è¡¨æ
åï¼æ name æ¶ä¸æ¾ç¤ºæ ç¾ï¼ä¿æé«åº¦ä¸è´ï¼ --> |
| | | <template v-if="isFullMode"> |
| | | <div |
| | | v-for="pack in faceStore.facePacks" |
| | | v-show="activeTab === packTabKey(pack.id)" |
| | | :key="pack.id" |
| | | style="height: 300px; overflow-y: auto" |
| | | > |
| | | <div class="grid grid-cols-5 gap-2 p-3"> |
| | | <div |
| | | v-for="item in pack.items" |
| | | :key="item.id" |
| | | class="im-face-grid-cell flex flex-col items-center gap-1 cursor-pointer rounded-md p-1 transition-colors hover:bg-[var(--ant-color-fill)]" |
| | | :title="item.name || ''" |
| | | @click="handleSelectPackItem(item)" |
| | | > |
| | | <div |
| | | class="aspect-square w-full flex items-center justify-center bg-[var(--ant-color-fill-tertiary)] rounded" |
| | | > |
| | | <img |
| | | :src="item.url" |
| | | :alt="item.name || '表æ
'" |
| | | class="max-w-full max-h-full object-contain" |
| | | draggable="false" |
| | | /> |
| | | </div> |
| | | <div |
| | | class="h-4 text-xs text-[var(--ant-color-text-secondary)] truncate w-full text-center leading-4" |
| | | > |
| | | {{ item.name || '' }} |
| | | </div> |
| | | </div> |
| | | <div |
| | | v-if="pack.items.length === 0" |
| | | class="col-span-5 flex items-center justify-center py-12 text-sm text-[var(--ant-color-text-placeholder)]" |
| | | > |
| | | 该表æ
å
ææ è¡¨æ
|
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | </div> |
| | | |
| | | <!-- åºé¨ tab æ ï¼[ emoji / 个人 / ç³»ç»å
1..N ]ï¼mode='emoji' æ¶éè --> |
| | | <div |
| | | v-if="isFullMode" |
| | | class="flex flex-shrink-0 items-center gap-1 px-2 py-1.5 border-t border-t-solid border-[var(--ant-color-border-secondary)]" |
| | | > |
| | | <Tooltip title="Emoji 表æ
" placement="top" :mouse-enter-delay="300"> |
| | | <button |
| | | class="inline-flex items-center justify-center w-[30px] h-[30px] border-none rounded bg-transparent cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]" |
| | | :class=" |
| | | activeTab === FACE_TAB.EMOJI |
| | | ? 'bg-[var(--ant-color-fill)] text-[var(--ant-color-primary)]' |
| | | : 'text-[var(--ant-color-text)]' |
| | | " |
| | | type="button" |
| | | @click="activeTab = FACE_TAB.EMOJI" |
| | | > |
| | | <Icon icon="ant-design:smile-outlined" :size="18" /> |
| | | </button> |
| | | </Tooltip> |
| | | <Tooltip title="个人表æ
" placement="top" :mouse-enter-delay="300"> |
| | | <button |
| | | class="inline-flex items-center justify-center w-[30px] h-[30px] border-none rounded bg-transparent cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]" |
| | | :class=" |
| | | activeTab === FACE_TAB.MINE |
| | | ? 'bg-[var(--ant-color-fill)] text-[var(--ant-color-primary)]' |
| | | : 'text-[var(--ant-color-text)]' |
| | | " |
| | | type="button" |
| | | @click="activeTab = FACE_TAB.MINE" |
| | | > |
| | | <Icon icon="ant-design:heart-outlined" :size="18" /> |
| | | </button> |
| | | </Tooltip> |
| | | <Tooltip |
| | | v-for="pack in faceStore.facePacks" |
| | | :key="pack.id" |
| | | :title="pack.name" |
| | | placement="top" |
| | | :mouse-enter-delay="300" |
| | | > |
| | | <button |
| | | class="inline-flex items-center justify-center w-[30px] h-[30px] border-none rounded bg-transparent cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]" |
| | | :class=" |
| | | activeTab === packTabKey(pack.id) |
| | | ? 'bg-[var(--ant-color-fill)] text-[var(--ant-color-primary)]' |
| | | : 'text-[var(--ant-color-text)]' |
| | | " |
| | | type="button" |
| | | @click="activeTab = packTabKey(pack.id)" |
| | | > |
| | | <img |
| | | v-if="pack.icon" |
| | | :src="pack.icon" |
| | | :alt="pack.name" |
| | | class="w-[18px] h-[18px] object-contain" |
| | | draggable="false" |
| | | /> |
| | | <Icon v-else icon="ant-design:appstore-outlined" :size="18" /> |
| | | </button> |
| | | </Tooltip> |
| | | </div> |
| | | |
| | | <!-- éèçæä»¶éæ©å¨ï¼ç¹ + æ¶è§¦åï¼ --> |
| | | <input |
| | | v-if="isFullMode" |
| | | ref="uploadInputRef" |
| | | type="file" |
| | | accept="image/*" |
| | | hidden |
| | | @change="onUploadPicked" |
| | | /> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | /* åºé¨å°ä¸è§ï¼æå触å徿 ï¼ä»¿å¾®ä¿¡ PC æ°æ³¡æéï¼left å移对åºè¡¨æ
æé®ï¼å·¥å
·æ 1st iconï¼ */ |
| | | .im-popover-arrow::after { |
| | | content: ''; |
| | | position: absolute; |
| | | top: calc(100% - 1px); |
| | | left: 10px; |
| | | border-style: solid; |
| | | border-width: 6px 6px 0 6px; |
| | | border-color: var(--ant-color-bg-container) transparent transparent transparent; |
| | | filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.08)); |
| | | } |
| | | |
| | | </style> |