gaoluyang
2026-06-29 27cd042df9aca0383a49f3514bc21958dd890912
src/views/im/home/components/context-menu.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,99 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { IconifyIcon as Icon } from '#/packages/icons/src'
import { useImUiStore } from '../store/uiStore'
defineOptions({ name: 'ImContextMenu' })
const uiStore = useImUiStore()
const contextMenu = computed(() => uiStore.contextMenu)
/**
 * è®¡ç®—菜单实际渲染坐标:靠近视口右 / ä¸‹è¾¹ç¼˜æ—¶å›žå¼¹ï¼Œé¿å…èœå•被裁剪
 *
 * itemHeight / menuWidth æ˜¯å’Œæ¨¡æ¿é‡Œ px-4 py-2 + text-13px / min-w-30 é…å¥—的实际尺寸;
 * dividerHeight = 9px(my-1 ä¸Šä¸‹å„ 4 + 1px border),仅非首项的 divided è®¡å…¥ï¼›
 * menuHeight é¢å¤–加 8 æ˜¯å¤–层 py-1 çš„上下 padding ä¹‹å’Œï¼ˆ4px Ã— 2)
 */
const adjustedPosition = computed(() => {
  const items = contextMenu.value.items
  const itemHeight = 34
  const dividerCount = items.filter((it, i) => it.divided && i > 0).length
  const menuHeight = items.length * itemHeight + dividerCount * 9 + 8
  const menuWidth = 120
  let x = contextMenu.value.position.x
  let y = contextMenu.value.position.y
  // SSR å…œåº•:window ä¸å¯ç”¨æ—¶ç›´æŽ¥è¿”回原始坐标
  if (typeof window !== 'undefined') {
    if (y + menuHeight > window.innerHeight) {
      y = window.innerHeight - menuHeight
    }
    if (x + menuWidth > window.innerWidth) {
      x = window.innerWidth - menuWidth
    }
  }
  // è§†å£å¾ˆå° / èœå•项很多时上面减法会算出负值,把菜单顶 / å·¦è¾¹æŽ¨åˆ° 0 å…œåº•
  return { x: Math.max(0, x), y: Math.max(0, y) }
})
type MenuItem = (typeof contextMenu.value.items)[number]
/** é€‰ä¸­èœå•项:disabled é¡¹å¿½ç•¥ï¼›æ­£å¸¸é¡¹è°ƒ onSelect å›žè°ƒåŽå…³é—­èœå• */
function handleSelect(item: MenuItem) {
  if (item.disabled) {
    return
  }
  uiStore.contextMenu.onSelect?.(item)
  uiStore.closeContextMenu()
}
/** å…³é—­èœå•:点遮罩 / åœ¨é®ç½©ä¸Šå†æ¬¡å³é”®éƒ½ä¼šè§¦å‘ */
function handleClose() {
  uiStore.closeContextMenu()
}
</script>
<template>
  <!--
    é€šç”¨å³é”®èœå•
    ç”± useImUiStore.openContextMenu(position, items, onSelect) è§¦å‘全局单例展示
    è°ƒç”¨æ–¹åœ¨ @contextmenu.prevent äº‹ä»¶é‡Œè°ƒ openContextMenu å³å¯ï¼Œä¸éœ€è¦è‡ªå·±æŒ‚组件
  -->
  <teleport to="body">
    <div
      v-if="contextMenu.show"
      class="fixed inset-0 z-9999"
      @click.stop="handleClose"
      @contextmenu.prevent="handleClose"
    >
      <div
        class="fixed min-w-30 py-1 bg-[var(--ant-color-bg-elevated)] rounded-md shadow-lg"
        :style="{ left: `${adjustedPosition.x }px`, top: `${adjustedPosition.y }px` }"
      >
        <template v-for="(item, index) in contextMenu.items" :key="item.key">
          <!-- divided é¡¹ä¸Šæ–¹æ’一条分割线(首项跳过,避免空白) -->
          <div
            v-if="item.divided && index > 0"
            class="my-1 mx-2 h-[1px] bg-[var(--ant-color-border-secondary)]"
          ></div>
          <div
            class="flex gap-2 items-center px-4 py-2 text-13px text-left cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]"
            :class="[
              item.disabled
                ? '!text-[var(--ant-color-text-disabled)] cursor-not-allowed hover:!bg-transparent'
                : item.danger
                  ? 'text-[#f56c6c]'
                  : 'text-[var(--ant-color-text)]'
            ]"
            @click.stop="handleSelect(item)"
          >
            <Icon v-if="item.icon" :icon="item.icon" :size="14" />
            <span>{{ item.name }}</span>
          </div>
        </template>
      </div>
    </div>
  </teleport>
</template>