gaoluyang
2026-06-29 27cd042df9aca0383a49f3514bc21958dd890912
src/views/im/home/pages/conversation/components/input/face-picker.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,353 @@
<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>