This commit is contained in:
2025-12-18 16:37:33 +08:00
commit e974bf361d
4183 changed files with 497339 additions and 0 deletions

View File

@@ -0,0 +1,268 @@
import type { Ref } from 'vue'
import { ref } from 'vue'
import type { DropdownInstance, TreeV2Instance } from 'element-plus'
interface NodeBase<T> { id: string; label: string; children?: T[] }
type NodeType<T> = T & NodeBase<T>
type LocateResult<T> = { node: NodeType<T>; parent: NodeType<T> | null; container: NodeType<T>[]; index: number } | null
interface MenuItem { key: string; text: string; disabled?: boolean }
interface ContextMenuConfig<T> {
dataRef: Ref<NodeType<T>[]>;
treeRef: Ref<TreeV2Instance | undefined>;
expandedKeysRef: Ref<string[]>;
locate: (id: string) => LocateResult<T>;
startEdit: (node: NodeType<T>) => void;
}
interface ContextMenuContext<T> {
createNode: (prefix: string) => NodeType<T>;
setData: (next: NodeType<T>[]) => void;
expandNode: (id: string) => void;
setCurrentKey: (id: string) => void;
locate: (id: string) => LocateResult<T>;
startEdit: (node: NodeType<T>) => void;
dataRef: Ref<NodeType<T>[]>;
}
interface LevelConfig {
depth: number
addKey?: string
addText?: string
allowDelete?: boolean
}
interface HierarchyConfig {
rootKey: string
rootText: string
levels: LevelConfig[]
}
interface ContextMenuHandler<T> {
getMenuItems: (node: NodeType<T> | null, ctx: ContextMenuContext<T>) => MenuItem[];
execute: (cmd: string, node: NodeType<T> | null, ctx: ContextMenuContext<T>) => Promise<void> | void;
}
/**
* 获取节点深度
*/
const getDepth = <T>(node: NodeType<T>, ctx: ContextMenuContext<T>): number => {
const target = ctx.locate(node.id)
if (!target) return -1
let depth = 0
let p = target.parent
while (p) {
depth += 1
const pt = ctx.locate(p.id)
p = pt?.parent ?? null
}
return depth
}
/**
* 层级化上下文菜单处理器
*/
class HierarchyContextMenuHandler<T> implements ContextMenuHandler<T> {
constructor(private config: HierarchyConfig) {}
getMenuItems = (node: NodeType<T> | null, ctx: ContextMenuContext<T>): MenuItem[] => {
// 空白区域右键 - 添加根节点
if (!node) {
return [{ key: this.config.rootKey, text: this.config.rootText }]
}
const depth = getDepth(node, ctx)
const levelConfig = this.config.levels.find(l => l.depth === depth)
if (!levelConfig) {
// 未配置的层级,只显示删除
return [{ key: 'delete', text: '删除' }]
}
const items: MenuItem[] = []
// 添加子级菜单项
if (levelConfig.addKey && levelConfig.addText) {
items.push({ key: levelConfig.addKey, text: levelConfig.addText })
}
// 删除菜单项
if (levelConfig.allowDelete !== false) {
items.push({ key: 'delete', text: '删除' })
}
return items
}
execute = async (cmd: string, node: NodeType<T> | null, ctx: ContextMenuContext<T>) => {
// 添加根节点
if (!node && cmd === this.config.rootKey) {
const next = ctx.createNode('root')
next.label = this.config.rootText.replace('添加', '')
ctx.setData([...ctx.dataRef.value, next])
return
}
if (!node) return
// 删除节点
if (cmd === 'delete') {
const target = ctx.locate(node.id)
if (!target) return
target.container.splice(target.index, 1)
ctx.setData([...ctx.dataRef.value])
return
}
// 查找匹配的层级配置
const depth = getDepth(node, ctx)
const levelConfig = this.config.levels.find(l => l.depth === depth && l.addKey === cmd)
if (levelConfig) {
const target = ctx.locate(node.id)
if (!target) return
const next = ctx.createNode(node.id)
next.label = levelConfig.addText?.replace('添加', '') || '新目录'
target.node.children = target.node.children || []
target.node.children.push(next)
ctx.setData([...ctx.dataRef.value])
ctx.expandNode(target.node.id)
ctx.setCurrentKey(next.id)
}
}
}
/**
* 默认上下文菜单处理器
*/
class DefaultContextMenuHandler<T> implements ContextMenuHandler<T> {
getMenuItems = (node: NodeType<T> | null, _ctx: ContextMenuContext<T>): MenuItem[] => {
if (!node) return [{ key: 'add-root', text: '添加根目录' }]
return [
{ key: 'add-sibling', text: '添加目录' },
{ key: 'add-child', text: '添加子目录' },
{ key: 'rename', text: '重命名' },
{ key: 'delete', text: '删除' },
]
}
execute = async (cmd: string, node: NodeType<T> | null, ctx: ContextMenuContext<T>) => {
if (!node && cmd === 'add-root') {
ctx.setData([...ctx.dataRef.value, ctx.createNode('node')])
return
}
if (!node) return
if (cmd === 'add-sibling') {
const target = ctx.locate(node.id)
if (!target) return
const prefix = target.parent ? target.parent.id : 'node'
target.container.push(ctx.createNode(prefix))
ctx.setData([...ctx.dataRef.value])
return
}
if (cmd === 'add-child') {
const target = ctx.locate(node.id)
if (!target) return
target.node.children = target.node.children || []
target.node.children.push(ctx.createNode(target.node.id))
ctx.setData([...ctx.dataRef.value])
ctx.expandNode(target.node.id)
ctx.setCurrentKey(target.node.id)
return
}
if (cmd === 'rename') { ctx.startEdit(node); return }
if (cmd === 'delete') {
const target = ctx.locate(node.id)
if (!target) return
target.container.splice(target.index, 1)
ctx.setData([...ctx.dataRef.value])
}
}
}
class DbTreeContextMenu<T> {
dropdownRef = ref<DropdownInstance>()
position = ref({ top: 0, left: 0, bottom: 0, right: 0 } as DOMRect)
triggerRef = ref({ getBoundingClientRect: () => this.position.value })
currentNode = ref<NodeType<T> | null>(null)
private config: ContextMenuConfig<T>
private handler: ContextMenuHandler<T>
constructor(config: ContextMenuConfig<T>, handler?: ContextMenuHandler<T>) {
this.config = config
this.handler = handler ?? new DefaultContextMenuHandler<T>()
}
private createNode = (prefix: string): NodeType<T> => {
const suffix = Math.random().toString(36).slice(2, 8)
const id = `${prefix}-${suffix}`
return { id, label: '新目录' } as NodeType<T>
}
private setData = (next: NodeType<T>[]) => { this.config.dataRef.value = next }
private expandNode = (id: string) => {
const keys = Array.from(new Set([...this.config.expandedKeysRef.value, id]))
this.config.expandedKeysRef.value = keys
this.config.treeRef.value?.setExpandedKeys(keys)
}
private setCurrentKey = (id: string) => { this.config.treeRef.value?.setCurrentKey?.(id) }
private ctx = (): ContextMenuContext<T> => ({
createNode: this.createNode,
setData: this.setData,
expandNode: this.expandNode,
setCurrentKey: this.setCurrentKey,
locate: this.config.locate,
startEdit: this.config.startEdit,
dataRef: this.config.dataRef,
})
getMenuItems = (): MenuItem[] => this.handler.getMenuItems(this.currentNode.value, this.ctx())
openContextMenu = (event: MouseEvent, nodeData: NodeType<T>) => {
const { clientX, clientY } = event
this.position.value = DOMRect.fromRect({ x: clientX, y: clientY })
event.preventDefault()
this.currentNode.value = nodeData
this.dropdownRef.value?.handleOpen()
}
openBlankContextMenu = (event: MouseEvent) => {
const { clientX, clientY } = event
this.position.value = DOMRect.fromRect({ x: clientX, y: clientY })
event.preventDefault()
this.currentNode.value = null
this.dropdownRef.value?.handleOpen()
}
closeContextMenu = () => { this.dropdownRef.value?.handleClose() }
onCommand = async (cmd: string) => { await this.handler.execute(cmd, this.currentNode.value, this.ctx()) }
onGlobalCommand = (cmd: string) => { void this.handler.execute(cmd, this.currentNode.value, this.ctx()) }
}
/**
* 创建层级化的上下文菜单处理器
* @param config 层级配置
* @returns ContextMenuHandler
*/
const createHierarchyContextMenuHandler = <T>(config: HierarchyConfig): ContextMenuHandler<T> => {
return new HierarchyContextMenuHandler<T>(config)
}
const createContextMenu = <T>(config: ContextMenuConfig<T>, handler?: ContextMenuHandler<T> | HierarchyConfig) => {
// 如果传入的是 HierarchyConfig自动创建 HierarchyContextMenuHandler
if (handler && 'rootKey' in handler && 'rootText' in handler && 'levels' in handler) {
return new DbTreeContextMenu<T>(config, createHierarchyContextMenuHandler<T>(handler))
}
return new DbTreeContextMenu<T>(config, handler as ContextMenuHandler<T> | undefined)
}
export { DbTreeContextMenu, HierarchyContextMenuHandler, createHierarchyContextMenuHandler }
export type { ContextMenuConfig, ContextMenuHandler, MenuItem, NodeType, LocateResult, HierarchyConfig, LevelConfig }
export const useContextMenu = <T>(config: ContextMenuConfig<T>, handler?: ContextMenuHandler<T> | HierarchyConfig) => createContextMenu<T>(config, handler)