thomasm291678
VetCopilot-LCPS: LCPS 2.0 本地推理引擎替换 DeepSeek API
793e953
Raw History Blame Contribute Delete
9.93 kB
import React, { useState, useRef, useEffect, useCallback, createContext, useContext } from 'react'
/** 菜单项配置 */
interface MenuItemConfig {
/** 唯一标识 */
id: string
/** 显示文本 */
label: string
/** 图标 */
icon?: React.ReactNode
/** 快捷键提示文本 */
shortcut?: string
/** 子菜单 */
children?: MenuItemConfig[]
/** 分隔线(在此项之前显示分隔线) */
divider?: boolean
/** 危险操作(红色) */
danger?: boolean
/** 是否禁用 */
disabled?: boolean
/** 点击回调 */
onClick?: () => void
}
interface ContextMenuProps {
/** 菜单项配置 */
items: MenuItemConfig[]
/** 触发右键菜单的内容 */
children: React.ReactNode
/** 附加类名 */
className?: string
/** 自定义触发方式:'context'(右键)| 'click'(点击),默认 'context' */
trigger?: 'context' | 'click'
}
// ================================================================
// 嵌套子菜单上下文
// ================================================================
interface SubMenuContextValue {
openSubMenuId: string | null
setOpenSubMenuId: (id: string | null) => void
closeAll: () => void
}
const SubMenuContext = createContext<SubMenuContextValue>({
openSubMenuId: null,
setOpenSubMenuId: () => {},
closeAll: () => {},
})
// ================================================================
// 菜单项组件(含子菜单递归)
// ================================================================
function MenuItemComponent({
item,
depth = 0,
}: {
item: MenuItemConfig
depth?: number
}) {
const { openSubMenuId, setOpenSubMenuId, closeAll } = useContext(SubMenuContext)
const hasChildren = item.children && item.children.length > 0
const isOpen = openSubMenuId === item.id
const itemRef = useRef<HTMLButtonElement | HTMLDivElement>(null)
const [subMenuPos, setSubMenuPos] = useState<{ x: number; y: number }>({ x: 0, y: 0 })
const handleMouseEnter = useCallback(() => {
if (hasChildren) {
setOpenSubMenuId(item.id)
} else {
setOpenSubMenuId(null)
}
}, [hasChildren, item.id, setOpenSubMenuId])
const handleClick = useCallback(() => {
if (item.disabled) return
if (hasChildren) {
// toggle 子菜单
setOpenSubMenuId(isOpen ? null : item.id)
} else {
item.onClick?.()
closeAll()
}
}, [item, hasChildren, isOpen, setOpenSubMenuId, closeAll])
return (
<>
{item.divider && <div className="h-px bg-gray-100 my-1" />}
{hasChildren ? (
<div ref={itemRef as React.RefObject<HTMLDivElement>} className="relative">
<button
onClick={handleClick}
onMouseEnter={handleMouseEnter}
disabled={item.disabled}
className={`w-full flex items-center justify-between gap-2 px-3 py-2 text-sm text-left transition-colors
${item.disabled ? 'text-gray-300 cursor-not-allowed' : ''}
${item.danger ? 'text-red-600 hover:bg-red-50' : 'text-gray-700 hover:bg-gray-50'}
${isOpen ? 'bg-gray-50' : ''}
`}
>
<span className="flex items-center gap-2">
{item.icon && <span className="w-4 h-4 flex-shrink-0">{item.icon}</span>}
{item.label}
</span>
<svg className="w-3 h-3 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
{/* 子菜单 */}
{isOpen && item.children && (
<SubMenuList items={item.children} depth={depth + 1} parentRect={itemRef.current?.getBoundingClientRect() || null} />
)}
</div>
) : (
<button
ref={itemRef as React.RefObject<HTMLButtonElement>}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
disabled={item.disabled}
className={`w-full flex items-center justify-between gap-2 px-3 py-2 text-sm text-left transition-colors
${item.disabled ? 'text-gray-300 cursor-not-allowed' : ''}
${item.danger ? 'text-red-600 hover:bg-red-50' : 'text-gray-700 hover:bg-gray-50'}
`}
>
<span className="flex items-center gap-2">
{item.icon && <span className="w-4 h-4 flex-shrink-0">{item.icon}</span>}
{item.label}
</span>
{item.shortcut && (
<span className="text-xs text-gray-400 ml-4">{item.shortcut}</span>
)}
</button>
)}
</>
)
}
// ================================================================
// 子菜单列表(屏幕边缘溢出检测)
// ================================================================
function SubMenuList({
items,
depth,
parentRect,
}: {
items: MenuItemConfig[]
depth: number
parentRect: DOMRect | null
}) {
const menuRef = useRef<HTMLDivElement>(null)
const [adjustedStyle, setAdjustedStyle] = useState<React.CSSProperties>({})
useEffect(() => {
if (!parentRect || !menuRef.current) return
const menuRect = menuRef.current.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
let style: React.CSSProperties = {
position: 'fixed',
left: parentRect.right + 4,
top: parentRect.top,
}
// 右侧溢出检测
if (parentRect.right + menuRect.width + 8 > viewportWidth) {
style.left = parentRect.left - menuRect.width - 4
}
// 底部溢出检测
if (parentRect.top + menuRect.height > viewportHeight) {
style.top = Math.max(4, viewportHeight - menuRect.height - 4)
}
setAdjustedStyle(style)
}, [parentRect, items])
return (
<div
ref={menuRef}
className="fixed z-[110] bg-white border border-gray-200 rounded-xl shadow-xl py-1 min-w-[160px]"
style={adjustedStyle}
>
{items.map((item) => (
<MenuItemComponent key={item.id} item={item} depth={depth} />
))}
</div>
)
}
// ================================================================
// 根菜单容器
// ================================================================
function MenuList({
items,
style,
}: {
items: MenuItemConfig[]
style: React.CSSProperties
}) {
const menuRef = useRef<HTMLDivElement>(null)
const [adjustedStyle, setAdjustedStyle] = useState(style)
useEffect(() => {
if (!menuRef.current) return
// 等菜单渲染后检查屏幕溢出
const raf = requestAnimationFrame(() => {
if (!menuRef.current) return
const rect = menuRef.current.getBoundingClientRect()
const vw = window.innerWidth
const vh = window.innerHeight
const nextStyle = { ...style }
// 右侧溢出
if (rect.right > vw) {
nextStyle.left = Math.max(4, vw - rect.width - 4)
}
// 底部溢出
if (rect.bottom > vh) {
nextStyle.top = Math.max(4, vh - rect.height - 4)
}
setAdjustedStyle(nextStyle)
})
return () => cancelAnimationFrame(raf)
}, [style])
return (
<div
ref={menuRef}
className="fixed z-[100] bg-white border border-gray-200 rounded-xl shadow-xl py-1 min-w-[160px] dropdown-enter"
style={adjustedStyle}
>
{items.map((item) => (
<MenuItemComponent key={item.id} item={item} depth={0} />
))}
</div>
)
}
// ================================================================
// ContextMenu 根组件
// ================================================================
export default function ContextMenu({ items, children, className = '', trigger = 'context' }: ContextMenuProps) {
const [open, setOpen] = useState(false)
const [position, setPosition] = useState({ x: 0, y: 0 })
const [openSubMenuId, setOpenSubMenuId] = useState<string | null>(null)
const menuRef = useRef<HTMLDivElement>(null)
// 点击外部关闭 + 右键菜单外部关闭
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false)
setOpenSubMenuId(null)
}
}
if (open) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [open])
// Escape 关闭
useEffect(() => {
if (!open) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setOpen(false)
setOpenSubMenuId(null)
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open])
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
if (trigger === 'click') return
e.preventDefault()
setPosition({ x: e.clientX, y: e.clientY })
setOpen(true)
setOpenSubMenuId(null)
},
[trigger]
)
const handleClick = useCallback(
(e: React.MouseEvent) => {
if (trigger === 'click') {
e.stopPropagation()
const rect = e.currentTarget.getBoundingClientRect()
setPosition({ x: rect.left, y: rect.bottom + 4 })
setOpen((prev) => !prev)
setOpenSubMenuId(null)
}
},
[trigger]
)
const closeAll = useCallback(() => {
setOpen(false)
setOpenSubMenuId(null)
}, [])
return (
<SubMenuContext.Provider value={{ openSubMenuId, setOpenSubMenuId, closeAll }}>
<div
onContextMenu={handleContextMenu}
onClick={handleClick}
className={className}
>
{children}
</div>
{open && (
<div ref={menuRef}>
<MenuList items={items} style={{ left: position.x, top: position.y }} />
</div>
)}
</SubMenuContext.Provider>
)
}