工料机、定额基价、定额费率、定额取费

This commit is contained in:
2026-01-03 14:59:45 +08:00
parent e974bf361d
commit 618bb6699e
65 changed files with 13251 additions and 2624 deletions

View File

@@ -0,0 +1 @@
export { default as DbCalc } from './index.vue';

View File

@@ -0,0 +1,252 @@
<script setup lang="ts">
import { ElButton, ElDialog, ElInput } from 'element-plus'
import Handsontable from 'handsontable'
import 'handsontable/dist/handsontable.full.css'
import { computed, nextTick, ref, watch } from 'vue'
interface TableColumn {
prop: string
label: string
}
interface Props {
modelValue: boolean
currentValue?: string
tableData?: any[]
tableColumn?: TableColumn[]
tableProp?: string // 指定点击单元格时获取哪个字段的值
mode?: 'resource' | 'fee' // 模式resource=工料机返回类别IDfee=定额取费(返回对象)
}
interface ConfirmResult {
formula: string
variables: Record<string, number | { categoryId: number; priceField: string }>
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'confirm', result: ConfirmResult): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
currentValue: '',
tableData: () => [],
tableColumn: () => [],
tableProp: 'code', // 默认使用 code 字段
mode: 'resource' // 默认为工料机模式
})
const emit = defineEmits<Emits>()
// Handsontable 相关
const tableContainer = ref<HTMLElement | null>(null)
let hotInstance: Handsontable | null = null
// 计算基数弹窗数据 - 使用 computed 从 props 获取
const calcTableData = computed(() => props.tableData || [])
// 运算符号按钮
const operators = ['+', '-', '*', '/', '(', ')']
// 当前编辑的值
const editValue = ref<string>('')
// 监听 props.currentValue 变化
watch(() => props.currentValue, (newVal) => {
editValue.value = newVal || ''
}, { immediate: true })
// 监听弹窗关闭,清空编辑值
watch(() => props.modelValue, (newVal) => {
if (!newVal) {
editValue.value = ''
if (hotInstance) {
hotInstance.destroy()
hotInstance = null
}
} else {
editValue.value = props.currentValue || ''
// 弹窗打开时初始化表格
nextTick(() => {
initHandsontable()
})
}
})
// 初始化 Handsontable
const initHandsontable = () => {
if (!tableContainer.value || hotInstance) return
// 转换列配置
const columns = props.tableColumn.map(col => ({
data: col.prop,
title: col.label,
readOnly: true
}))
console.log('=== Handsontable 初始化 ===')
console.log('表格数据:', calcTableData.value)
console.log('列配置:', columns)
console.log('原始列配置:', props.tableColumn)
hotInstance = new Handsontable(tableContainer.value, {
data: calcTableData.value,
columns: columns,
colHeaders: true,
rowHeaders: false,
height: 300,
licenseKey: 'non-commercial-and-evaluation',
readOnly: true,
afterOnCellMouseDown: (_event: MouseEvent, coords: { row: number; col: number }) => {
if (coords.row >= 0 && coords.col >= 0) {
const rowData = hotInstance?.getSourceDataAtRow(coords.row) as any
const colProp = props.tableColumn[coords.col]?.prop
// 跳过序号列和类别列(不允许点击)
if (colProp === 'id' || colProp === 'name') {
return
}
// 使用 tableProp 指定的字段获取值
const targetProp = props.tableProp
const value = rowData?.[targetProp]
if (value !== undefined && value !== null) {
const cellValue = Array.isArray(value) ? value.join('') : String(value)
editValue.value += cellValue
}
}
}
})
}
// 添加运算符
const addOperator = (op: string) => {
editValue.value += op
}
// 关闭弹窗
const handleClose = () => {
emit('update:modelValue', false)
}
// 提取公式中使用的变量,构建 variables 对象
const extractVariables = (formula: string): Record<string, number | { categoryId: number; priceField: string }> => {
const variables: Record<string, number | { categoryId: number; priceField: string }> = {}
if (!formula || !props.tableData || props.tableData.length === 0) {
return variables
}
// 创建正则表达式,将运算符和数字替换为空格,用于分割
const operatorPattern = operators.map(op => {
// 转义特殊字符
return op.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}).join('|')
// 移除运算符、数字、小数点和空格,剩下的就是变量代码
const cleanFormula = formula.replace(new RegExp(`(${operatorPattern}|\\d+\\.?\\d*|\\s+)`, 'g'), ' ')
// 分割出所有可能的变量代码
const potentialCodes = cleanFormula.split(/\s+/).filter(code => code.trim() !== '')
// 价格字段类型映射
const priceFieldMap: Record<string, string> = {
'taxExclBaseCode': 'tax_excl_base_price',
'taxInclBaseCode': 'tax_incl_base_price',
'taxExclCompileCode': 'tax_excl_compile_price',
'taxInclCompileCode': 'tax_incl_compile_price'
}
// 遍历 tableData检查公式中是否包含该项的所有价格代码
props.tableData.forEach((item: any) => {
// 检查所有可能的价格代码字段
const priceCodeFields = ['taxExclBaseCode', 'taxInclBaseCode', 'taxExclCompileCode', 'taxInclCompileCode', 'code']
priceCodeFields.forEach(field => {
const codeValue = item[field]
if (codeValue && potentialCodes.includes(codeValue)) {
if (item.id !== undefined) {
// 根据模式返回不同格式
if (props.mode === 'resource') {
// 工料机模式直接返回类别ID
variables[codeValue] = item.id
} else {
// 定额取费模式:返回对象 { categoryId, priceField }
const priceFieldType = priceFieldMap[field] || 'tax_excl_base_price'
variables[codeValue] = {
categoryId: item.id,
priceField: priceFieldType
}
}
}
}
})
})
return variables
}
// 保存计算数据
const handleConfirm = () => {
const variables = extractVariables(editValue.value)
const result: ConfirmResult = {
formula: editValue.value,
variables: variables
}
// 只触发 confirm 事件,不自动关闭弹窗
// 让父组件在保存成功后再关闭弹窗
emit('confirm', result)
// 移除自动关闭逻辑
// emit('update:modelValue', false)
}
</script>
<template>
<ElDialog
:model-value="modelValue"
@update:model-value="emit('update:modelValue', $event)"
title="计算基数设置"
width="800px"
:close-on-click-modal="false"
>
<div style="margin-bottom: 15px;">
<div style="margin-bottom: 10px; color: #909399; font-size: 13px;">
限数字:0-9及小数点运算符号:+-*/()英文括号代码
</div>
<ElInput
size="large"
v-model="editValue"
style="width: 100%;"
placeholder="点击表格单元格或运算符按钮添加内容"
clearable
/>
</div>
<div style="margin-bottom: 15px;">
<span style="margin-right: 10px;">运算符号:</span>
<ElButton
v-for="op in operators"
:key="op"
size="large"
@click="addOperator(op)"
style="margin-right: 5px; font-size: 20px;"
>
{{ op }}
</ElButton>
</div>
<div ref="tableContainer" style="width: 100%; margin-bottom: 15px;"></div>
<template #footer>
<ElButton @click="handleClose">取消</ElButton>
<ElButton type="primary" @click="handleConfirm">确定</ElButton>
</template>
</ElDialog>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,75 @@
# 右键菜单组件使用说明
## 功能特性
### 全局菜单管理
- 当打开一个右键菜单时,会自动关闭其他已打开的菜单
- 支持多个菜单组件实例共存,互不干扰
- 自动清理资源,防止内存泄漏
- **Handsontable 内置右键菜单与自定义菜单互斥**
## 组件集成
### 1. db-hst 组件Handsontable
- 使用自定义右键菜单组件 `contextmenu.vue`(空白区域右键)
- 使用 Handsontable 内置 `contextMenu`(单元格右键)
- 两种菜单互斥,打开一个会自动关闭另一个
### 2. db-tree 组件Element Plus Tree
使用 Element Plus Dropdown 实现的右键菜单
## 工作原理
### contextMenuManager全局菜单管理器
- 维护所有活动菜单的关闭回调函数
- 当新菜单打开时,通知管理器关闭其他菜单
- 组件卸载时自动取消注册
### 使用流程
1. 组件挂载时,向管理器注册关闭回调
2. 打开菜单前,调用 `contextMenuManager.notifyOpening()`
3. 管理器关闭其他菜单,保留当前菜单
4. 组件卸载时,自动取消注册
### db-hst 组件特殊处理
-`afterOnCellContextMenu` 钩子中关闭自定义菜单
- 确保 Handsontable 内置菜单打开时,自定义菜单被关闭
## 示例代码
### 在组件中使用
```typescript
// 在组件中使用
import { contextMenuManager } from './contextMenuManager'
// 注册菜单
const unregister = contextMenuManager.register(hideContextMenu)
// 打开菜单时
const showMenu = () => {
contextMenuManager.notifyOpening(hideContextMenu)
// 显示菜单逻辑
}
// 组件卸载时
onUnmounted(() => {
unregister()
})
```
### Handsontable 配置
```typescript
const settings = {
// ... 其他配置
afterOnCellContextMenu: () => {
// 关闭自定义菜单
contextMenuRef.value?.hideContextMenu?.()
}
}
```
## 注意事项
- 确保每个菜单组件都正确注册和取消注册
- 打开菜单前必须调用 `notifyOpening()`
- 关闭回调函数应该是幂等的(可以多次调用)
- Handsontable 的 `contextMenu` 配置与自定义菜单会自动互斥

View File

@@ -0,0 +1,41 @@
import {ref } from 'vue'
// 边框样式配置
export const borderStyle = {
width: 2,
color: '#0000ff',
style: 'solid'
}
// 构建当前选中行的边框配置
export const selectCellBorderStyle = (_this:any, row: any) => {
const hot = _this as any
// 构建当前选中行的边框配置
if (row < 0) return
const colCount = hot.countCols()
if (colCount <= 0) return
const customBorders = hot.getPlugin('customBorders')
customBorders.clearBorders()
customBorders.setBorders([[row, 0, row, colCount - 1]], { top: borderStyle, bottom: borderStyle })
customBorders.setBorders([[row, 0, row, 0]], { start: borderStyle })
customBorders.setBorders([[row, colCount - 1, row, colCount - 1]], { end: borderStyle })
}
// 构建当前选中行的背景色配置
export const selectedVisualRowRange = ref<{ from: number; to: number } | null>(null)
export const setSelectedVisualRows = (row: number, row2: number) => {
if (row < 0 || row2 < 0) {
selectedVisualRowRange.value = null
return
}
const from = Math.min(row, row2)
const to = Math.max(row, row2)
selectedVisualRowRange.value = { from, to }
}
export const isVisualRowSelected = (visualRowIndex: number) => {
const range = selectedVisualRowRange.value
if (!range) return false
return visualRowIndex >= range.from && visualRowIndex <= range.to
}

View File

@@ -0,0 +1,56 @@
/**
* 全局右键菜单管理器
* 用于管理多个右键菜单实例,确保同一时间只有一个菜单处于打开状态
*/
type MenuCloseCallback = () => void
class ContextMenuManager {
private activeMenus: Set<MenuCloseCallback> = new Set()
/**
* 注册一个菜单实例
* @param closeCallback 关闭菜单的回调函数
* @returns 取消注册的函数
*/
register(closeCallback: MenuCloseCallback): () => void {
this.activeMenus.add(closeCallback)
// 返回取消注册的函数
return () => {
this.activeMenus.delete(closeCallback)
}
}
/**
* 关闭所有已注册的菜单
*/
closeAll(): void {
this.activeMenus.forEach(callback => {
try {
callback()
} catch (error) {
console.error('关闭菜单时出错:', error)
}
})
}
/**
* 通知即将打开新菜单,关闭其他所有菜单
* @param currentCloseCallback 当前菜单的关闭回调(不会被关闭)
*/
notifyOpening(currentCloseCallback: MenuCloseCallback): void {
this.activeMenus.forEach(callback => {
if (callback !== currentCloseCallback) {
try {
callback()
} catch (error) {
console.error('关闭其他菜单时出错:', error)
}
}
})
}
}
// 导出单例实例
export const contextMenuManager = new ContextMenuManager()

View File

@@ -0,0 +1,228 @@
<script setup lang="ts">
import { onMounted, onUnmounted, reactive, computed } from 'vue'
import { contextMenuManager } from './contextMenuManager'
// 接收父组件传递的 props
const props = defineProps<{
hotTableComponent?: any
menuItems?: Array<{
key: string
name: string
callback?: (hotInstance: any) => void
separator?: boolean
}>
}>()
// 自定义右键菜单状态
const contextMenu = reactive({
visible: false,
x: 0,
y: 0,
row: -1, // 记录右键点击的行索引
col: -1 // 记录右键点击的列索引
})
// 默认菜单项
const defaultMenuItems = [
{
key: 'addRow',
name: '新增一行',
callback: (hotInstance: any) => {
const rowCount = hotInstance.countRows()
hotInstance.alter('insert_row_below', rowCount - 1, 1)
}
}
]
// 合并默认菜单和自定义菜单
const menuItems = computed(() => {
return props.menuItems && props.menuItems.length > 0
? props.menuItems
: defaultMenuItems
})
// 获取 hotInstance
const getHotInstance = () => {
return props.hotTableComponent?.hotInstance
}
// 处理右键菜单
const handleContextMenu = (event: MouseEvent) => {
// 如果没有菜单项,不处理右键菜单
if (!props.menuItems || props.menuItems.length === 0) {
return
}
const target = event.target as HTMLElement
// 判断是否点击在空白区域(非单元格区域)
// 检查是否点击在 td 或 th 元素上
const isCell = target.closest('td') || target.closest('th')
if (!isCell) {
// 点击在空白区域,显示自定义菜单
event.preventDefault()
// 菜单尺寸(需要与 CSS 中的实际尺寸匹配)
const menuWidth = 180
const menuItemHeight = 32 // 单个菜单项高度padding + font-size
const menuPadding = 8 // 菜单上下 padding
const menuHeight = menuItems.value.length * menuItemHeight + menuPadding
// 获取容器元素Handsontable 的容器)
const container = target.closest('.handsontable') as HTMLElement || target.closest('[class*="hot-"]') as HTMLElement
// 如果找到容器,使用容器边界;否则使用视口边界
let viewportWidth = window.innerWidth
let viewportHeight = window.innerHeight
let offsetX = 0
let offsetY = 0
if (container) {
const rect = container.getBoundingClientRect()
viewportWidth = rect.right
viewportHeight = rect.bottom
offsetX = rect.left
offsetY = rect.top
}
// 计算菜单位置,确保不超出边界
let x = event.clientX
let y = event.clientY
// 如果右侧空间不足,向左显示
if (x + menuWidth > viewportWidth) {
x = Math.max(offsetX + 10, viewportWidth - menuWidth - 10)
}
// 如果底部空间不足,向上显示
if (y + menuHeight > viewportHeight) {
y = Math.max(offsetY + 10, viewportHeight - menuHeight - 10)
}
// 确保不会超出左边界和上边界
x = Math.max(offsetX + 10, x)
y = Math.max(offsetY + 10, y)
// 使用统一的显示方法
showContextMenu(x, y)
}
}
// 隐藏右键菜单
const hideContextMenu = () => {
contextMenu.visible = false
}
// 显示右键菜单(在打开前先关闭其他菜单)
const showContextMenu = (x: number, y: number) => {
// 通知管理器即将打开新菜单,关闭其他菜单
contextMenuManager.notifyOpening(hideContextMenu)
contextMenu.visible = true
contextMenu.x = x
contextMenu.y = y
}
// 菜单项点击处理
const handleMenuAction = (item: any) => {
console.log('菜单操作:', item.key)
const hotInstance = getHotInstance()
if (!hotInstance) {
console.warn('hotInstance 未初始化')
hideContextMenu()
return
}
// 执行自定义回调
if (item.callback && typeof item.callback === 'function') {
item.callback(hotInstance)
}
hideContextMenu()
}
// 暴露方法给父组件
defineExpose({
handleContextMenu,
hideContextMenu
})
// 取消注册函数
let unregister: (() => void) | null = null
onMounted(() => {
// 注册到全局菜单管理器
unregister = contextMenuManager.register(hideContextMenu)
// 监听全局点击事件,隐藏菜单
document.addEventListener('click', hideContextMenu)
})
onUnmounted(() => {
// 取消注册
if (unregister) {
unregister()
}
document.removeEventListener('click', hideContextMenu)
})
</script>
<template>
<!-- 自定义右键菜单 -->
<div
v-if="contextMenu.visible"
class="custom-context-menu"
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
@click.stop
>
<template v-for="(item, index) in menuItems" :key="item.key || index">
<!-- 分隔线 -->
<div v-if="'separator' in item && item.separator" class="menu-separator"></div>
<!-- 菜单项 -->
<div v-else class="menu-item" @click="handleMenuAction(item)">
<span>{{ item.name }}</span>
</div>
</template>
</div>
</template>
<style lang="css">
/* 自定义右键菜单样式 */
.custom-context-menu {
position: fixed;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 10000;
min-width: 180px;
padding: 4px 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Helvetica Neue, Arial, sans-serif;
}
.menu-item {
display: flex;
align-items: center;
padding: 4px 12px;
cursor: pointer;
transition: background-color 0.2s;
user-select: none;
}
.menu-item span{
font-size: 14px;
margin-inline: calc(2* 4px);
}
.menu-item:hover {
background-color: #f5f5f5;
}
.menu-separator {
height: 1px;
background-color: #e8e8e8;
margin: 4px 0;
}
</style>

View File

@@ -13,22 +13,38 @@ const closeDropdown = () => {
const openDropdown = (
td: HTMLTableCellElement,
value: unknown,
source: string[],
source: any[],
onSelect: (opt: string) => void,
isOptionDisabled?: (opt: string) => boolean,
onAfterSelect?: (oldValue: unknown, newValue: string, optData?: any) => void,
) => {
closeDropdown()
const menu = document.createElement('div')
menu.className = 'ht-dropdown-menu'
const frag = document.createDocumentFragment()
for (const opt of source) {
// 支持对象格式 {value, label} 和字符串格式
const optValue = typeof opt === 'object' && opt !== null ? opt.value : opt
const optLabel = typeof opt === 'object' && opt !== null ? opt.label : opt
const item = document.createElement('div')
item.className = 'ht-dropdown-item'
item.textContent = opt
if (String(value) === String(opt)) item.classList.add('is-selected')
const disabled = isDisabled.value && isOptionDisabled?.(opt) === true
item.textContent = optLabel ?? ''
if (String(value) === String(optValue)) item.classList.add('is-selected')
const disabled = isDisabled.value && isOptionDisabled?.(String(optValue)) === true
if (disabled) { item.classList.add('is-disabled'); item.setAttribute('aria-disabled', 'true') }
item.onclick = (e) => { e.stopPropagation(); if (disabled) return; onSelect(opt); closeDropdown() }
item.onclick = (e) => {
e.stopPropagation();
if (disabled) return;
onSelect(String(optValue));
// 调用选择后的回调,传递旧值、新值和完整数据
if (onAfterSelect) {
const optData = typeof opt === 'object' && opt !== null ? opt.data : undefined
onAfterSelect(value, String(optValue), optData)
}
closeDropdown()
}
frag.appendChild(item)
}
menu.appendChild(frag)
@@ -51,23 +67,71 @@ const openDropdown = (
currentOnDocClick = (ev: MouseEvent) => { const target = ev.target as Node; if (currentDropdownEl && !currentDropdownEl.contains(target)) closeDropdown() }
document.addEventListener('click', currentOnDocClick, true)
}
export function handlerDropdownRenderer(instance: any, td: HTMLTableCellElement, row: number, column: number, prop: string | number, value: any, cellProperties: any) {
td.innerHTML = ''
// 检查是否需要设置验证背景色
const cellMeta = instance.getCellMeta(row, column)
const isValid = cellMeta?.valid !== false
// 如果单元格被标记为无效,设置红色背景
if (!isValid) {
td.style.backgroundColor = '#ffbeba' // 淡红色背景
} else {
td.style.backgroundColor = '' // 清除背景色
}
const wrapper = document.createElement('div')
wrapper.className = 'ht-cell-dropdown'
const valueEl = document.createElement('span')
valueEl.className = 'ht-cell-value'
valueEl.textContent = value ?? ''
// 基于列配置启用"唯一选择"禁用逻辑
isDisabled.value = Boolean(cellProperties?.isOnlySelect)
//TODO 暂时性,后面要删除
// 如果 isOnlySelect 为 true设置当前单元格为只读
const isReadOnly = isDisabled.value && value !== null && value !== undefined && String(value).trim() !== ''
if (isReadOnly) {
instance.setCellMeta(row, column, 'readOnly', true)
// 添加只读样式
wrapper.classList.add('is-readonly')
td.style.cursor = 'not-allowed'
td.style.opacity = '0.6'
} else {
instance.setCellMeta(row, column, 'readOnly', false)
td.style.cursor = 'pointer'
td.style.opacity = '1'
}
const source: any[] = Array.isArray(cellProperties?.source)
? cellProperties.source
: Array.isArray(cellProperties?.customDropdownSource)
? cellProperties.customDropdownSource
: []
// 根据 value 查找对应的 label 显示
let displayText = value ?? ''
if (value !== null && value !== undefined) {
const matchedOption = source.find(opt => {
const optValue = typeof opt === 'object' && opt !== null ? opt.value : opt
return String(optValue) === String(value)
})
if (matchedOption) {
displayText = typeof matchedOption === 'object' && matchedOption !== null
? matchedOption.label
: matchedOption
}
}
valueEl.textContent = displayText
const caretEl = document.createElement('span')
caretEl.className = 'ht-cell-caret'
wrapper.appendChild(valueEl)
wrapper.appendChild(caretEl)
td.appendChild(wrapper)
const source: string[] = Array.isArray(cellProperties?.source)
? cellProperties.source
: Array.isArray(cellProperties?.customDropdownSource)
? cellProperties.customDropdownSource
: []
let disabledSet = new Set<string>()
if (isDisabled.value) {
const colValues = instance.getSourceDataAtCol(column) as unknown[]
@@ -75,6 +139,32 @@ export function handlerDropdownRenderer(instance: any, td: HTMLTableCellElement,
disabledSet = new Set((Array.isArray(colValues) ? colValues : []).map(v => String(v)))
disabledSet.delete(currentStr)
}
wrapper.onclick = (e) => { e.stopPropagation(); openDropdown(td, value, source, (opt) => instance.setDataAtCell(row, column, opt), (opt) => isDisabled.value && disabledSet.has(String(opt))) }
//在 dropdown 的点击事件中阻止冒泡(推荐)
wrapper.onclick = (e) => {
e.stopPropagation()
e.preventDefault()
//TODO 暂时性,后面要删除
// 如果是只读状态,不打开下拉框
if (isReadOnly) {
return
}
const onAfterSelect = cellProperties?.onAfterSelect
openDropdown(
td,
value,
source,
(opt) => instance.setDataAtCell(row, column, opt),
(opt) => isDisabled.value && disabledSet.has(String(opt)),
onAfterSelect ? (oldValue, newValue, optData) => onAfterSelect(instance, row, column, oldValue, newValue, optData) : undefined
)
}
// 阻止 mousedown 事件冒泡,防止触发 beforeOnCellMouseDown
wrapper.onmousedown = (e) => {
e.stopPropagation()
}
return td
}

View File

@@ -6,16 +6,28 @@ import { HotTable } from '@handsontable/vue3'
import { registerLanguageDictionary, zhCN } from 'handsontable/i18n'
import { registerAllModules } from 'handsontable/registry'
import 'handsontable/styles/handsontable.css'
import 'handsontable/styles/ht-theme-main.css'
// import 'handsontable/styles/ht-theme-main.css'
import 'handsontable/styles/ht-theme-classic.css';
registerAllModules()
registerLanguageDictionary(zhCN)
import { handlerDropdownRenderer } from './dropdown'
import { handlerTableRenderer } from './table'
import { handlerDuplicateCodeRenderer } from './text'
import { computeCodeColWidth,codeRenderer } from './tree'
import ContextMenu from './contextmenu.vue'
import { handleRowOperation } from '#/components/db-hst/tree'
// import { sourceDataObject } from './mockData'
// const language = ref('zh-CN')
defineOptions({ name: 'DbHst' });
const componentProps = defineProps<{ settings?: any }>()
const componentProps = defineProps<{
settings?: any
contextMenuItems?: Array<{
key: string
name: string
callback?: (hotInstance: any) => void
separator?: boolean
}>
}>()
// 导入和注册插件和单元格类型
// import { registerCellType, NumericCellType } from 'handsontable/cellTypes';
// import { registerPlugin, UndoRedo } from 'handsontable/plugins';
@@ -24,13 +36,15 @@ const componentProps = defineProps<{ settings?: any }>()
// const tableHeight = computed(() => componentProps.height ?? 0)
// const defaultData = ref<any[][]>(Array.from({ length: 30 }, () => Array(componentProps.columns?.length ?? 0).fill('')))
const hotTableComponent = ref<any>(null)
const selectedRow = ref<number | null>(null) // 记录当前选中的行
const codeColWidth = ref<number>(120)
// const colHeaders = ref<string[]>([])
let defaultSettings = {
themeName: 'ht-theme-main',
// themeName: 'ht-theme-main',
themeName: 'ht-theme-classic',
language: 'zh-CN',
// data: sourceDataObject,
// colWidths: [100, 120, 100, 100, 100, 100],
@@ -40,7 +54,7 @@ let defaultSettings = {
// return (index + 1) * 40;
// },
// colWidths: undefined,
rowHeights: '23px', // 固定行高
rowHeights: 23, // 固定行高
wordWrap: false,// 禁止单元格内容自动换行
//manualColumnMove: true,
@@ -90,25 +104,69 @@ let defaultSettings = {
// return cellProperties;
// },
modifyColWidth: (width: number, col: number) => {
const hot = hotInstance.value
if (!hot) return width
const codeCol = hot.propToCol('code')
// console.log('modifyColWidth',codeCol,width)
return col === codeCol ? (codeColWidth.value ?? width) : width
},
afterChange: (changes: any, source: string) => {
if (!changes || !hotInstance.value) return
if (source !== 'edit' && source !== 'Autofill' && source !== 'UndoRedo') return
const hot = hotInstance.value
const codeCol = hot.propToCol('code')
const hasCodeEdit = changes.some((c: any) => c && (c[1] === 'code' || c[1] === codeCol))
// console.log('afterChange',changes,hasCodeEdit, codeCol)
if (!hasCodeEdit) return
codeColWidth.value = computeCodeColWidth(hot)
// console.log('afterChange',codeColWidth.value)
hot.render()
// console.log('afterChange',codeColWidth.value)
// afterSelection(row1: number, _col1: number, _row2: number, _col2: number) {
// const hot = this as any
// if (selectedRow.value !== null && selectedRow.value !== row1) {
// const colCount = hot.countCols()
// for (let c = 0; c < colCount; c++) {
// const meta = hot.getCellMeta(selectedRow.value, c)
// const classes = (meta.className || '').split(' ').filter(Boolean)
// const idx = classes.indexOf('row-highlight')
// if (idx !== -1) classes.splice(idx, 1)
// hot.setCellMeta(selectedRow.value, c, 'className', classes.join(' '))
// }
// }
// selectedRow.value = row1
// const colCount = hot.countCols()
// for (let c = 0; c < colCount; c++) {
// const meta = hot.getCellMeta(row1, c)
// const classes = (meta.className || '').split(' ').filter(Boolean)
// if (!classes.includes('row-highlight')) classes.push('row-highlight')
// hot.setCellMeta(row1, c, 'className', classes.join(' '))
// }
// hot.render()
// },
//afterDeselect() {
// const hot = this as any
// if (selectedRow.value === null) return
// const colCount = hot.countCols()
// for (let c = 0; c < colCount; c++) {
// const meta = hot.getCellMeta(selectedRow.value, c)
// const classes = (meta.className || '').split(' ').filter(Boolean)
// const idx = classes.indexOf('row-highlight')
// if (idx !== -1) classes.splice(idx, 1)
// hot.setCellMeta(selectedRow.value, c, 'className', classes.join(' '))
// }
// selectedRow.value = null
// hot.render()
//},
// modifyColWidth(width: number, col: number) {
// const hot = hotInstance.value
// if (!hot) return width
// const codeCol = hot.propToCol('code')
// // console.log('modifyColWidth',codeCol,width)
// return col === codeCol ? (codeColWidth.value ?? width) : width
// },
// afterChange(changes: any, source: string) {
// if (!changes || !hotInstance.value) return
// if (source !== 'edit' && source !== 'Autofill' && source !== 'UndoRedo') return
// const hot = hotInstance.value
// const codeCol = hot.propToCol('code')
// const hasCodeEdit = changes.some((c: any) => c && (c[1] === 'code' || c[1] === codeCol))
// // console.log('afterChange',changes,hasCodeEdit, codeCol)
// if (!hasCodeEdit) return
// codeColWidth.value = computeCodeColWidth(hot)
// // console.log('afterChange',codeColWidth.value)
// hot.render()
// // console.log('afterChange',codeColWidth.value)
// },
afterOnCellContextMenu: () => {
// Handsontable 内置右键菜单打开后,关闭自定义菜单
contextMenuRef.value?.hideContextMenu?.()
},
}
// 合并外部 settings 和默认配置
@@ -116,6 +174,12 @@ let hotSettings = {}
// 保留必要的回调函数
const hotInstance = ref<any>(null)
const contextMenuRef = ref<any>(null)
// 处理右键菜单事件
const handleContextMenu = (event: MouseEvent) => {
contextMenuRef.value?.handleContextMenu(event)
}
onMounted(() => {
hotInstance.value = hotTableComponent.value?.hotInstance
@@ -127,15 +191,17 @@ watch(
() => componentProps.settings,
(newSettings) => {
if (!newSettings) return
const merged = {
...defaultSettings,
...newSettings,
}
Object.assign(hotSettings, merged)
hotSettings = merged
// console.log(merged)
},
{ immediate: true }
{ immediate: true,deep:true }
)
const loadData = (rows: any[][]) => {
@@ -143,12 +209,51 @@ const loadData = (rows: any[][]) => {
if (!hotInstance.value) return
// hotInstance.value.loadData(rows.length === 0?defaultData.value:rows)
hotInstance.value.loadData(rows)
console.log('Source Data:', hotInstance.value.getSourceData());
//console.log('Source Data:', hotInstance.value.getSourceData());
}
const updateCodeColWidth = () => {
if (!hotInstance.value) return
codeColWidth.value = computeCodeColWidth(hotInstance.value)
const newWidth = computeCodeColWidth(hotInstance.value)
// 如果宽度没有变化,不需要更新
if (newWidth === codeColWidth.value) return
codeColWidth.value = newWidth
// 查找配置了 code: true 的列
const currentSettings = hotInstance.value.getSettings()
const columns = currentSettings.columns || []
const codeColIndex = columns.findIndex((col: any) => col.code === true)
if (codeColIndex !== null && codeColIndex >= 0) {
// 获取当前列数
const colCount = hotInstance.value.countCols()
const currentColWidths = currentSettings.colWidths
// 构建新的列宽数组
const newColWidths: number[] = []
for (let i = 0; i < colCount; i++) {
if (i === codeColIndex) {
newColWidths[i] = codeColWidth.value
} else {
// 获取其他列的当前宽度
if (Array.isArray(currentColWidths)) {
newColWidths[i] = currentColWidths[i] || 100
} else if (typeof currentColWidths === 'function') {
newColWidths[i] = 100
} else {
newColWidths[i] = currentColWidths || 100
}
}
}
console.log(newColWidths)
// 更新列宽
hotInstance.value.updateSettings({
colWidths: newColWidths
})
}
hotInstance.value.render()
}
defineExpose({ loadData, hotTableComponent, hotInstance, updateCodeColWidth, codeColWidth })
@@ -156,11 +261,15 @@ defineExpose({ loadData, hotTableComponent, hotInstance, updateCodeColWidth, cod
Handsontable.renderers.registerRenderer("db-table", handlerTableRenderer);
Handsontable.renderers.registerRenderer("db-dropdown", handlerDropdownRenderer);
Handsontable.renderers.registerRenderer("db-duplicate", handlerDuplicateCodeRenderer);
</script>
<template>
<hot-table ref="hotTableComponent" :settings="hotSettings"></hot-table>
<div @contextmenu="handleContextMenu">
<hot-table ref="hotTableComponent" :settings="hotSettings"></hot-table>
<ContextMenu ref="contextMenuRef" :hotTableComponent="hotTableComponent" :menuItems="componentProps.contextMenuItems" />
</div>
<!-- <div id="hot-dialog-container" style="display:none">
<div class="ht-dialog-content">
<h3>执行操作</h3>
@@ -214,14 +323,14 @@ Handsontable.renderers.registerRenderer("db-dropdown", handlerDropdownRenderer);
.ht_master .wtHolder::-webkit-scrollbar-thumb:hover {
background-color: #555;
} */
/* 滚动条width */
.ht_master .wtHolder{
/* overflow: hidden !important; */
scrollbar-width: thin;
scrollbar-color: #a6a8ac #ecf0f1;
}
/* dropdown */
.ht-cell-dropdown { display: flex; align-items: center; justify-content: space-between; width: 100%; position: relative; box-sizing: border-box; height: 100%; }
.ht-cell-dropdown { display: flex; align-items: center; justify-content: center; width: 100%; position: relative; box-sizing: border-box; height: 100%; }
.ht-cell-value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ht-cell-value:empty::after { content: "\200b"; }
.ht-cell-caret { position: absolute; right: 0; top: 50%; transform: translateY(-50%); width: 0; height: 0; border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 5px solid #979797; }
@@ -283,66 +392,86 @@ Handsontable.renderers.registerRenderer("db-dropdown", handlerDropdownRenderer);
.hot-dropdown-search-input { width: 100%; padding: 6px 10px; border: 1px solid #d1d5db; border-radius: 4px; font-size: 12px; outline: none; transition: border-color 0.2s; }
.hot-dropdown-search-input:focus { border-color: #3b82f6; }
.hot-dropdown-table-wrapper { overflow: auto; flex: 1; }
.hot-dropdown-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.hot-dropdown-table thead th { position: sticky; top: 0; background: #f9fafb; font-weight: 600; color: #374151; padding: 8px; border-bottom: 1px solid #e5e7eb; text-align: left; }
.hot-dropdown-table { width: 100%; border-collapse: collapse; font-size: 12px; text-align: center; }
.hot-dropdown-table thead th { position: sticky; top: 0; background: #f9fafb; font-weight: 600; color: #374151; padding: 8px; border-bottom: 1px solid #e5e7eb; text-align: center; }
.hot-dropdown-table tbody td { padding: 8px; border-bottom: 1px solid #f3f4f6; color: #374151; }
.hot-dropdown-row { cursor: pointer; }
.hot-dropdown-row:hover { background: #f3f4f6; }
/** 指引线line */
tbody[role="rowgroup"] tr[role="row"] td .ht_nestingLevel_empty{
position: relative;
display: inline-block;
width: 5px;
height: 1px;
order: -2;
/* 整行高亮样式 */
.row-highlight {
background-color: #e9ecfc !important; /* 浅蓝色背景 */
}
tbody[role="rowgroup"] tr[role="row"] td .ht_nestingLevel_empty:last-child {
padding-left: calc(var(--ht-icon-size) + 5px);
/* 确保 Handsontable 右键菜单在 ElDialog 之上 - 必须是全局样式 */
.handsontable.htDropdownMenu:not(.htGhostTable),
.handsontable.htContextMenu:not(.htGhostTable),
.handsontable.htFiltersConditionsMenu:not(.htGhostTable) {
z-index: 9999 !important;
}
tbody[role="rowgroup"] tr[role="row"] td .ht_nestingLevel_empty::before{
content: '';
position: absolute;
top: -13px;
height: 26px;
width: 1px;
border-left: 1px solid #ababab;
.ht-id-cell {
position: relative !important;
z-index: 3 !important;
overflow: visible !important;
}
tbody[role="rowgroup"] tr[role="row"] td .ht_nestingLevel_empty::after{
content: '';
.ht-id-icon {
position: absolute;
top: 0px;
width: 16px;
height: 1px;
border-top: 1px solid #ababab;
}
tbody[role="rowgroup"] tr[role="row"] td .ht_nestingLevel_empty{
padding-left: 7px;
right: -14px;
width: 14px;
height: 14px;
display: none;
cursor: pointer;
z-index: 4;
}
/* 最后一个 ht_nestingLevel_emptyrowHeader 前面的那个) */
tbody[role="rowgroup"] tr[role="row"] td .ht_nestingLevel_empty + .rowHeader {
/* 通过相邻选择器反向选择 */
padding-left: 10px !important
}
/* 选择后面还有 ht_nestingLevel_empty 的元素(不是最后一个) */
tbody[role="rowgroup"] tr[role="row"] td .ht_nestingLevel_empty:has(+ .ht_nestingLevel_empty)::before {
/* 你的样式 */
/* height: 0px; */
}
/* 选择后面还有 ht_nestingLevel_empty 的元素(不是最后一个) */
tbody[role="rowgroup"] tr[role="row"] td .ht_nestingLevel_empty:has(+ .ht_nestingLevel_empty)::after {
/* 你的样式 */
width: 0px !important;
.ht-id-cell.current .ht-id-icon,
.ht-id-cell.area .ht-id-icon {
display: inline-flex;
}
/* 或者用这个:选择后面不是 ht_nestingLevel_empty 的那个 */
tbody[role="rowgroup"] tr[role="row"] td .ht_nestingLevel_empty:not(:has(+ .ht_nestingLevel_empty))::before {
/* height: 13px; */
.handsontable {
--ht-tree-line-color: #7c7c7c;
--ht-tree-line-style: solid;
--ht-tree-line-width: 1px;
--ht-tree-indent: 14px;
}
/** 新树形连接线 */
.handsontable .ht_treeCell {
display: flex;
align-items: stretch;
height: 100%;
}
tbody[role="rowgroup"] tr[role="row"] td .ht_nestingLevel_empty:not(:has(+ .ht_nestingLevel_empty))::after {
/* 你的特殊样式 */
.handsontable .ht_treeIndentLayer {
flex: 0 0 auto;
height: 100%;
}
.handsontable .ht_treeContent {
display: flex;
align-items: center;
gap: 6px;
}
.handsontable .ht_treeToggleSpacer {
display: inline-block;
width: var(--ht-icon-size);
height: var(--ht-icon-size);
flex: 0 0 auto;
}
.handsontable .ht_treeContent {
min-width: 0;
}
/* 整行高亮样式 */
.handsontable td.ht_rowHighlight:not(.current):not(.area) {
background-color: #e9ecfc;
}
</style>

View File

@@ -0,0 +1,490 @@
import { useDebounceFn } from '@vueuse/core'
import { ref } from 'vue'
export type TreeNode = Record<string, unknown> & {
__id?: string
__children?: TreeNode[]
level?: string | null
}
type FlatRowMeta = {
id: string
hasChildren: boolean
lineKey: string
path: string
node: TreeNode
siblings: TreeNode[]
indexWithinParent: number
depth: number
}
type FlatTreeIndex = {
root: TreeNode[]
rows: TreeNode[]
metaByRow: FlatRowMeta[]
}
type TreeLinePaint = {
key: string
width: string
backgroundImage: string
backgroundSize: string
backgroundPosition: string
}
type UseParentChildLineOptions = {
getHotInstance: () => any
}
export const useParentChildLineNestedRowsFalse = (options: UseParentChildLineOptions) => {
const treeLinePaintCache = new Map<string, TreeLinePaint>()
const collapsedNodeIds = ref(new Set<string>())
const sourceTreeData = ref<TreeNode[]>([])
const flatIndex = ref<FlatTreeIndex | null>(null)
let nextNodeId = 1
const maybeBumpNextNodeId = (id: string) => {
if (!/^\d+$/.test(id)) return
const num = Number(id)
if (!Number.isSafeInteger(num)) return
if (num >= nextNodeId) nextNodeId = num + 1
}
const getNodeId = (node: TreeNode) => {
if (node.__id) {
maybeBumpNextNodeId(node.__id)
return node.__id
}
const id = String(nextNodeId++)
node.__id = id
return id
}
const normalizeNode = (node: TreeNode): TreeNode => {
getNodeId(node)
if (!Array.isArray(node.__children)) node.__children = []
return node
}
const buildFlatTreeIndex = (root: TreeNode[], collapsedIds: Set<string>): FlatTreeIndex => {
const rows: TreeNode[] = []
const metaByRow: FlatRowMeta[] = []
const visit = (list: TreeNode[], depth: number, flagsStr: string, basePath: string) => {
for (let index = 0; index < list.length; index++) {
const node = normalizeNode(list[index])
const id = getNodeId(node)
const isLast = index === list.length - 1
const children = node.__children ?? []
const hasChildren = children.length > 0
const path = depth === 0 ? String(index) : `${basePath}-${index + 1}`
const lineKey = depth <= 0 ? '0' : `${depth}|${isLast ? 1 : 0}|${flagsStr}`
rows.push(node)
metaByRow.push({
id,
depth,
hasChildren,
lineKey,
path,
node,
siblings: list,
indexWithinParent: index,
})
if (hasChildren && !collapsedIds.has(id)) {
const nextFlagsStr = depth >= 1 ? `${flagsStr}${isLast ? '0' : '1'}` : flagsStr
visit(children, depth + 1, nextFlagsStr, path)
}
}
}
visit(root, 0, '', '')
return { root, rows, metaByRow }
}
const getTreeLinePaint = (key: string) => {
const cached = treeLinePaintCache.get(key)
if (cached) return cached
if (key === '0') {
const empty = { key, width: '0px', backgroundImage: '', backgroundSize: '', backgroundPosition: '' }
treeLinePaintCache.set(key, empty)
return empty
}
const firstSep = key.indexOf('|')
const secondSep = key.indexOf('|', firstSep + 1)
const level = Number(key.slice(0, firstSep))
const isLast = key.slice(firstSep + 1, secondSep) === '1'
const flagsStr = key.slice(secondSep + 1)
const color = 'var(--ht-tree-line-color)'
const width = 'var(--ht-tree-line-width)'
const indent = 'var(--ht-tree-indent)'
const images: string[] = []
const sizes: string[] = []
const positions: string[] = []
for (let i = 0; i < flagsStr.length; i++) {
if (flagsStr.charCodeAt(i) !== 49) continue
images.push(`linear-gradient(${color}, ${color})`)
sizes.push(`${width} 100%`)
positions.push(`calc(${indent} * ${i} + (${indent} / 2)) 0`)
}
const selfDepth = level - 1
images.push(`linear-gradient(${color}, ${color})`)
sizes.push(`${width} ${isLast ? '50%' : '100%'}`)
positions.push(`calc(${indent} * ${selfDepth} + (${indent} / 2)) 0`)
images.push(`linear-gradient(to right, ${color}, ${color})`)
sizes.push(`calc(${indent} / 2) ${width}`)
positions.push(`calc(${indent} * ${selfDepth} + (${indent} / 2)) 50%`)
const paint = {
key,
width: `calc(var(--ht-tree-indent) * ${level})`,
backgroundImage: images.join(', '),
backgroundSize: sizes.join(', '),
backgroundPosition: positions.join(', '),
}
if (treeLinePaintCache.size > 5000) treeLinePaintCache.clear()
treeLinePaintCache.set(key, paint)
return paint
}
const getTreeCellDom = (TD: HTMLTableCellElement) => {
const currentTreeCell = TD.firstElementChild as HTMLElement | null
if (currentTreeCell && currentTreeCell.classList.contains('ht_treeCell')) {
const indentLayer = currentTreeCell.firstElementChild as HTMLElement
const content = currentTreeCell.lastElementChild as HTMLElement
return {
treeCell: currentTreeCell,
indentLayer,
content,
toggleEl: content.firstElementChild as HTMLElement,
textEl: content.lastElementChild as HTMLElement,
}
}
const treeCell = document.createElement('div')
treeCell.className = 'ht_treeCell'
treeCell.style.pointerEvents = 'none'
const indentLayer = document.createElement('span')
indentLayer.className = 'ht_treeIndentLayer'
// indentLayer 不需要交互,让事件穿透
//indentLayer.style.pointerEvents = 'none'
const content = document.createElement('div')
content.className = 'ht_treeContent'
// content 需要响应点击(折叠/展开按钮),但允许右键菜单冒泡
//content.style.pointerEvents = 'none'
const toggleSpacer = document.createElement('span')
toggleSpacer.className = 'ht_treeToggleSpacer'
const text = document.createElement('span')
text.className = 'rowHeader'
content.appendChild(toggleSpacer)
content.appendChild(text)
treeCell.appendChild(indentLayer)
treeCell.appendChild(content)
TD.replaceChildren(treeCell)
return {
treeCell,
indentLayer,
content,
toggleEl: toggleSpacer,
textEl: text,
}
}
const rebuildDataFromTree = (hotOverride?: any) => {
const index = buildFlatTreeIndex(sourceTreeData.value, collapsedNodeIds.value)
flatIndex.value = index
const hot = hotOverride ?? options.getHotInstance()
if (hot) hot.loadData(index.rows)
}
const toggleNodeCollapsed = (nodeId: string) => {
if (collapsedNodeIds.value.has(nodeId)) collapsedNodeIds.value.delete(nodeId)
else collapsedNodeIds.value.add(nodeId)
rebuildDataFromTree()
}
const normalizeTreeData = (input: unknown): TreeNode[] => {
const root = Array.isArray(input)
? (input as TreeNode[])
: input && typeof input === 'object'
? [input as TreeNode]
: []
const stack: unknown[] = [...root]
while (stack.length) {
const current = stack.pop()
if (!current || typeof current !== 'object') continue
const node = current as TreeNode
const maybeChildren = (node as any).__children
if (!Array.isArray(maybeChildren)) {
const altChildren = (node as any).children
node.__children = Array.isArray(altChildren) ? (altChildren as TreeNode[]) : []
} else {
node.__children = maybeChildren as TreeNode[]
}
for (let i = node.__children.length - 1; i >= 0; i--) {
const child = node.__children[i]
if (!child || typeof child !== 'object') node.__children.splice(i, 1)
}
normalizeNode(node)
for (let i = 0; i < node.__children.length; i++) stack.push(node.__children[i])
}
return root
}
const load = (data: unknown) => {
collapsedNodeIds.value.clear()
treeLinePaintCache.clear()
nextNodeId = 1
sourceTreeData.value = normalizeTreeData(data)
rebuildDataFromTree()
}
const createNode = (dataSchema: any): TreeNode => {
const node = {
...dataSchema,
__children: [],
} as TreeNode
normalizeNode(node)
return node
}
const getSelectedVisualRowRange = (hot: any): { startRow: number; amount: number } | null => {
const sel = hot.getSelectedLast?.() ?? hot.getSelected?.()?.[0]
if (!sel) return null
const [r1, , r2] = sel as [number, number, number, number]
const startRow = Math.min(r1, r2)
const endRow = Math.max(r1, r2)
return { startRow, amount: endRow - startRow + 1 }
}
const collectDescendantIds = (node: TreeNode): string[] => {
const ids: string[] = []
const stack: TreeNode[] = [...(node.__children ?? [])]
while (stack.length) {
const current = stack.pop()!
if (current.__id) ids.push(current.__id)
if (Array.isArray(current.__children) && current.__children.length) stack.push(...current.__children)
}
return ids
}
const handleRowOperation = (hot: any, type: 'above' | 'below' | 'child'| 'append'|'delete') => {
if (!hot) return
const index = flatIndex.value
if (!index) return
if (type === 'append') {
index.root.push(createNode(hot.getSettings().dataSchema))
rebuildDataFromTree(hot)
return
}
if (type === 'delete') {
const range = getSelectedVisualRowRange(hot)
if (!range) return
const selected = index.metaByRow.slice(range.startRow, range.startRow + range.amount)
const uniqueById = new Map<string, FlatRowMeta>()
for (const meta of selected) uniqueById.set(meta.id, meta)
const metas = [...uniqueById.values()]
metas.sort((a, b) => b.depth - a.depth)
const collapsed = collapsedNodeIds.value
const bySiblings = new Map<TreeNode[], FlatRowMeta[]>()
for (const meta of metas) {
const list = bySiblings.get(meta.siblings)
if (list) list.push(meta)
else bySiblings.set(meta.siblings, [meta])
}
for (const meta of metas) {
for (const descendantId of collectDescendantIds(meta.node)) collapsed.delete(descendantId)
collapsed.delete(meta.id)
}
for (const [siblings, list] of bySiblings.entries()) {
const sorted = [...list].sort((a, b) => b.indexWithinParent - a.indexWithinParent)
for (const meta of sorted) siblings.splice(meta.indexWithinParent, 1)
}
rebuildDataFromTree(hot)
return
}
const selection = hot.getSelectedLast?.() ?? hot.getSelected?.()?.[0]
if (!selection) return
const [r1] = selection as [number, number, number, number]
const meta = index.metaByRow[r1]
if (!meta) return
if (type === 'child') {
meta.node.__children ??= []
meta.node.__children.push(createNode(hot.getSettings().dataSchema))
collapsedNodeIds.value.delete(meta.id)
rebuildDataFromTree(hot)
return
}
meta.siblings.splice(type === 'above' ? meta.indexWithinParent : meta.indexWithinParent + 1, 0, createNode(hot.getSettings().dataSchema))
rebuildDataFromTree(hot)
}
const codeRenderer = (
hot: any,
TD: HTMLTableCellElement,
row: number,
col: number,
prop: any,
value: any,
cellProperties: any
) => {
// 检查是否需要设置验证背景色
const cellMeta = hot.getCellMeta(row, col)
const isValid = cellMeta?.valid !== false
// 如果单元格被标记为无效,设置红色背景
if (!isValid) {
TD.style.backgroundColor = '#ffbeba' // 淡红色背景
} else {
//TD.style.backgroundColor = cellProperties.className == "row-highlight"?'#e9ecfc':'' // 清除背景色
}
const meta = flatIndex.value?.metaByRow?.[row]
const key = meta?.lineKey ?? '0'
const { indentLayer, content, toggleEl, textEl } = getTreeCellDom(TD)
if (indentLayer.dataset.paintKey !== key) {
indentLayer.dataset.paintKey = key
const paint = getTreeLinePaint(key)
indentLayer.style.width = paint.width
indentLayer.style.backgroundImage = paint.backgroundImage
indentLayer.style.backgroundSize = paint.backgroundSize
indentLayer.style.backgroundPosition = paint.backgroundPosition
indentLayer.style.backgroundRepeat = paint.backgroundImage ? 'no-repeat' : ''
}
if (meta?.hasChildren && meta?.id) {
const isCollapsed = collapsedNodeIds.value.has(meta.id)
const needsButton = toggleEl.tagName !== 'DIV' || !toggleEl.classList.contains('ht_nestingButton')
const btn = needsButton ? document.createElement('div') : toggleEl
btn.className = `ht_nestingButton ${isCollapsed ? 'ht_nestingExpand' : 'ht_nestingCollapse'}`
btn.dataset.nodeId = meta.id
// 确保按钮可以响应点击事件
btn.style.pointerEvents = 'auto'
if (!(btn as any).__htTreeToggleBound) {
;(btn as any).__htTreeToggleBound = true
btn.addEventListener('mousedown', (ev) => {
ev.stopPropagation()
ev.preventDefault()
const nodeId = (ev.currentTarget as HTMLElement | null)?.dataset.nodeId
if (!nodeId) return
toggleNodeCollapsed(nodeId)
})
}
if (needsButton) content.replaceChild(btn, toggleEl)
} else {
const needsSpacer = toggleEl.tagName !== 'SPAN' || !toggleEl.classList.contains('ht_treeToggleSpacer')
if (needsSpacer) {
const spacer = document.createElement('span')
spacer.className = 'ht_treeToggleSpacer'
content.replaceChild(spacer, toggleEl)
}
}
textEl.textContent = value == null ? '' : String(value)
return TD
}
const levelRenderer = (
hot: any,
TD: HTMLTableCellElement,
row: number,
col: number,
prop: any,
value: any,
cellProperties: any
) => {
TD.textContent = flatIndex.value?.metaByRow?.[row]?.path ?? ''
return TD
}
const initSchema = (columns: any[]) => {
let rowSchema: any = {__id:null, level: null, __children: []}
// 根据 columns 的 data 字段生成对象结构
columns.forEach((col: any) => {
if (col.data && col.data !== 'level' && col.data !== '__children'&& col.data !== '__id') {
rowSchema[col.data] = null
}
})
return rowSchema
}
//把最后一次选中的行重新选回来,保持高亮
const highlightDeselect = (_this: any, coords: any) => {
if (coords !== null) queueMicrotask(() => _this.selectCell(coords?.row, coords?.col))
}
return {
load,
initSchema,
highlightDeselect,
codeRenderer,
levelRenderer,
handleRowOperation,
flatIndex,
}
}
///当设置 manualColumnResize: true 时,双击列边界触发自动适应列宽的方法是 autoColumnSize 插件。
function applyDblClickAutoFit (hot: any, visualCol: number) {
const autoFit = useDebounceFn(()=>{
if (!hot || !Number.isInteger(visualCol)) return
const manualColumnResize = hot.getPlugin?.('manualColumnResize')
if (!manualColumnResize?.setManualSize) return
const baseWidth = hot.getColWidth?.(visualCol) ?? 0
const hookedWidth = hot.runHooks?.('beforeColumnResize', baseWidth, visualCol, true)
const newWidth = typeof hookedWidth === 'number' ? hookedWidth : baseWidth
manualColumnResize.setManualSize(visualCol, newWidth)
manualColumnResize.saveManualColumnWidths?.()
hot.runHooks?.('afterColumnResize', newWidth, visualCol, true)
hot.view?.adjustElementsSize?.()
hot.render?.()
},300)
autoFit()
}
export function applyAutoFitColum (hot: any, changes: any[]) {
const columns = hot.getSettings?.().columns
for (const change of changes) {
const [, prop, oldValue, newValue] = change
if (!columns || !Array.isArray(columns)) continue
const visualCol = columns.findIndex((col) => col?.autoWidth === true && col?.data === prop)
if (visualCol < 0) continue
if (oldValue === newValue) continue
applyDblClickAutoFit(hot, visualCol)
console.log('scheduleDblClickAutoFit')
break
}
}

View File

@@ -0,0 +1,169 @@
import Handsontable from 'handsontable'
import type { Ref } from 'vue'
import { onMounted, onUnmounted } from 'vue'
type SelectRenderDeps = {
visible: Ref<boolean>
buttonRef: Ref<HTMLElement | null | undefined>
}
// 设置全局点击监听器的 Hook
export const usePopoverClickOutside = (
visible: Ref<boolean>,
onClickOutside: () => void
) => {
const handler = (event: PointerEvent) => {
visible.value = false
onClickOutside()
}
onMounted(() => {
document.addEventListener('pointerdown', handler, true)
})
onUnmounted(() => {
document.removeEventListener('pointerdown', handler, true)
})
return handler
}
const debounce = <T extends (...args: any[]) => void>(func: T, wait: number) => {
let timeout: ReturnType<typeof setTimeout> | undefined
return function (this: unknown, ...args: Parameters<T>) {
const context = this
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
func.apply(context, args)
}, wait)
}
}
export const createPopoverCellRenderer = ({ visible, buttonRef }: SelectRenderDeps) => {
const openPopover = (container: HTMLElement, virtualEl: HTMLElement) => {
buttonRef.value = virtualEl
visible.value = true
}
return (
instance: Handsontable,
td: HTMLTableCellElement,
row: number,
col: number,
prop: any,
value: any,
cellProperties: any
) => {
const rowData = instance.getSourceDataAtRow(row)
//parent存在并ture时或者没有parent对象时
if((rowData.hasOwnProperty('parent') && rowData?.parent) || !rowData.hasOwnProperty('parent')){
td.innerHTML = ''
const container = document.createElement('div')
container.style.display = 'flex'
container.style.alignItems = 'center'
container.style.justifyContent = 'space-between'
container.style.width = '100%'
container.style.height = '100%'
const input = document.createElement('input')
input.type = 'text'
input.value = value || ''
input.style.flex = '1'
input.style.border = 'none'
input.style.outline = 'none'
input.style.background = 'transparent'
input.style.padding = '0'
input.style.width = '100%'
input.style.fontSize = 'inherit'
input.style.fontFamily = 'inherit'
input.setAttribute('data-cell-row', row.toString())
input.setAttribute('data-cell-col', col.toString())
input.addEventListener('keydown', (e) => {
e.stopPropagation()
})
input.addEventListener('keyup', (e) => {
e.stopPropagation()
})
input.addEventListener('keypress', (e) => {
e.stopPropagation()
})
input.addEventListener('mousedown', (e) => {
e.stopPropagation()
})
input.addEventListener('click', (e) => {
e.stopPropagation()
openPopover(container, input)
})
const debouncedInputHandler = debounce((newValue: string) => {
console.log(`输入变化(防抖后) - 行: ${row}, 列: ${col}, 新值: ${newValue}`)
openPopover(container, input)
if (cellProperties.inputCallback && typeof cellProperties.inputCallback === 'function') {
const rowData = (instance as any).getSourceDataAtRow(row)
cellProperties.inputCallback(row, col, prop, newValue, rowData, instance, td)
}
}, 300)
input.addEventListener('input', (e) => {
const target = e.target as HTMLInputElement
const newValue = target.value
console.log(`输入中 - 行: ${row}, 列: ${col}, 新值: ${newValue}`)
debouncedInputHandler(newValue)
})
input.addEventListener('focus', () => {
console.log(`输入框获得焦点 - 行: ${row}, 列: ${col}`)
openPopover(container, input)
if (cellProperties.focusCallback && typeof cellProperties.focusCallback === 'function') {
const rowData = (instance as any).getSourceDataAtRow(row)
cellProperties.focusCallback(row, col, prop, input.value, rowData, instance, td)
}
})
input.addEventListener('blur', () => {
console.log(`输入框失焦 - 行: ${row}, 列: ${col}, 最终值: ${input.value}`)
})
const searchIcon = document.createElement('span')
searchIcon.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>`
searchIcon.style.cursor = 'pointer'
searchIcon.style.marginLeft = '5px'
searchIcon.style.fontSize = '14px'
searchIcon.style.flexShrink = '0'
searchIcon.addEventListener('click', (e) => {
e.stopPropagation()
console.log(`搜索图标被点击 - 行: ${row}, 列: ${col}, 值: ${input.value}`)
openPopover(container, searchIcon)
if (cellProperties.selectCallback && typeof cellProperties.selectCallback === 'function') {
const rowData = (instance as any).getSourceDataAtRow(row)
cellProperties.selectCallback(row, col, prop, input.value, rowData, instance, td)
}
})
container.appendChild(input)
container.appendChild(searchIcon)
td.appendChild(container)
}else{
td.innerHTML = value || ''
}
return td
}
}

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ElPopover, ElTableV2 } from 'element-plus'
import type { PopoverInstance, RowEventHandlers } from 'element-plus'
interface Props {
visible: boolean
buttonRef: HTMLElement | null | undefined
columns: any[]
data: any[]
rowEventHandlers?: RowEventHandlers
width?: number
height?: number
popperHeight?: string
}
const props = withDefaults(defineProps<Props>(), {
width: 955,
height: 350,
popperHeight: '380px'
})
const emit = defineEmits<{
'end-reached': []
}>()
const popoverRef = ref<PopoverInstance>()
// 暴露 popoverRef 供父组件调用
defineExpose({ popoverRef })
</script>
<template>
<ElPopover
ref="popoverRef"
:virtual-ref="buttonRef"
virtual-triggering
:visible="visible"
placement="bottom"
:width="width"
:popper-style="{ height: popperHeight }"
>
<ElTableV2
:columns="columns"
:data="data"
:width="width - 25"
:height="height"
fixed
:row-height="25"
:header-height="25"
:row-event-handlers="rowEventHandlers"
:cell-props="{
style: {
background: 'transparent !important'
}
}"
:teleported="false"
@end-reached="emit('end-reached')"
/>
</ElPopover>
</template>

View File

@@ -4,31 +4,62 @@ let currentAnchor: { instance: any; row: number; col: number; td: HTMLTableCellE
const clamp = (x: number, min: number, max: number) => (x < min ? min : x > max ? max : x)
/**
* 获取嵌套属性值的辅助函数
* @param obj 对象
* @param path 属性路径,如 'calcBase.formula'
* @returns 属性值
*/
const getNestedValue = (obj: any, path: string): any => {
if (!path || !obj) return ''
const keys = path.split('.')
let value = obj
for (const key of keys) {
if (value === null || value === undefined) return ''
value = value[key]
}
return value ?? ''
}
/**
* 创建表格数据结构的辅助函数
* @param dataSource 数据源数组
* @param fieldKeys 字段键数组,按顺序对应表格列
* @param getLabelFn 获取标签的函数
* @param getDisplayText 获取显示文本的函数
* @returns 格式化后的表格行HTML和数据属性
*/
export function createTableDataStructure(
dataSource: any[],
fieldKeys: string[],
getLabelFn?: (item: any) => string
getDisplayText?: (item: any) => string
) {
const getLabel = getLabelFn ?? ((x: any) => x?.name ?? '')
const getLabel = getDisplayText ?? ((x: any) => x?.name ?? '')
return dataSource.map(item => {
// 动态生成单元格
const cells = fieldKeys.map(key => `<td>${String(item?.[key] ?? '')}</td>`).join('')
// 动态生成单元格 - 支持嵌套属性
const cells = fieldKeys.map(key => {
const value = getNestedValue(item, key)
return `<td>${String(value)}</td>`
}).join('')
// 动态生成 data 属性
// 动态生成 data 属性 - 支持嵌套属性
const dataAttrs = fieldKeys
.map(key => `data-${key.toLowerCase()}="${String(item?.[key] ?? '')}"`)
.map(key => {
const value = getNestedValue(item, key)
// 将嵌套路径转换为有效的 data 属性名,如 'calcBase.formula' -> 'calcbase-formula'
const attrName = key.toLowerCase().replace(/\./g, '-')
return `data-${attrName}="${String(value)}"`
})
.join(' ')
// 将完整的 item 数据序列化存储
const itemDataJson = JSON.stringify(item).replace(/"/g, '&quot;')
return {
html: `<tr class="hot-dropdown-row" data-label="${String(getLabel(item) ?? '')}" ${dataAttrs}>${cells}</tr>`,
html: `<tr class="hot-dropdown-row" data-label="${String(getLabel(item) ?? '')}" data-item="${itemDataJson}" ${dataAttrs}>${cells}</tr>`,
data: item
}
})
@@ -93,21 +124,18 @@ export function handlerTableRenderer(instance: any, td: HTMLTableCellElement, ro
return el
}
const labelFn: ((x: any) => string) | undefined = cellProperties.customGetLabel
const display = createEl('div', 'hot-dropdown-display')
const labelText = typeof value === 'string' ? value : labelFn?.(value) ?? (value && typeof value === 'object' ? String(value.name ?? '') : '')
const labelText = typeof value === 'string' ? value : (value && typeof value === 'object' ? String(value.name ?? '') : '')
if (labelText && labelText.length > 0) display.appendChild(createEl('span', 'hot-dropdown-text', labelText))
else display.appendChild(createEl('span', 'hot-dropdown-placeholder', '选择'))
const trigger = createEl('button', 'hot-dropdown-trigger', '▼') as HTMLButtonElement
trigger.type = 'button'
const buildDropdown = () => {
const src = cellProperties.customDropdownSource as any[] | undefined
const buildDropdown = async () => {
const headers: string[] | undefined = cellProperties.customTableHeaders
const dropdown = createEl('div', 'hot-dropdown')
const getLabel = labelFn ?? ((x: any) => x?.name ?? '')
const getDisplayText = (x: any) => x?.name ?? ''
// 创建搜索栏
const searchContainer = createEl('div', 'hot-dropdown-search')
@@ -119,29 +147,51 @@ export function handlerTableRenderer(instance: any, td: HTMLTableCellElement, ro
const theadHtml = headers && headers.length ? `<thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead>` : ''
// 使用自定义字段键或默认字段键
const fieldKeys = cellProperties.customFieldKeys || ['code', 'name', 'spec', 'category', 'unit', 'taxRate', 'priceExTax', 'priceInTax', 'priceExTaxComp', 'priceInTaxComp', 'calcBase']
const rowsHtml = Array.isArray(src)
? createTableDataStructure(src, fieldKeys, labelFn).map(row => row.html).join('')
: ''
const fieldKeys = cellProperties.customFieldKeys || []
// 创建加载提示
const tableEl = createEl('div', 'hot-dropdown-table-wrapper')
tableEl.innerHTML = `<table class="hot-dropdown-table">${theadHtml}<tbody>${rowsHtml}</tbody></table>`
tableEl.innerHTML = `<table class="hot-dropdown-table">${theadHtml}<tbody><tr><td colspan="${fieldKeys.length}" style="text-align: center; padding: 20px;">加载中...</td></tr></tbody></table>`
dropdown.appendChild(searchContainer)
dropdown.appendChild(tableEl)
const tbody = dropdown.querySelector('tbody') as HTMLTableSectionElement
const allRows = Array.from(tbody.querySelectorAll('tr')) as HTMLTableRowElement[]
// 异步加载数据
let src: any[] = []
const dataSource = cellProperties.customDropdownSource
// 搜索功能 - 动态搜索所有字段
if (typeof dataSource === 'function') {
try {
src = await dataSource()
} catch (error) {
console.error('加载下拉数据失败:', error)
tableEl.innerHTML = `<table class="hot-dropdown-table">${theadHtml}<tbody><tr><td colspan="${fieldKeys.length}" style="text-align: center; padding: 20px; color: red;">加载失败</td></tr></tbody></table>`
return dropdown
}
} else if (Array.isArray(dataSource)) {
src = dataSource
}
// 渲染数据
const rowsHtml = Array.isArray(src) && src.length > 0
? createTableDataStructure(src, fieldKeys, getDisplayText).map(row => row.html).join('')
: `<tr><td colspan="${fieldKeys.length}" style="text-align: center; padding: 20px;">暂无数据</td></tr>`
tableEl.innerHTML = `<table class="hot-dropdown-table">${theadHtml}<tbody>${rowsHtml}</tbody></table>`
const tbody = dropdown.querySelector('tbody') as HTMLTableSectionElement
const allRows = Array.from(tbody.querySelectorAll('tr.hot-dropdown-row')) as HTMLTableRowElement[]
// 搜索功能 - 动态搜索所有字段(支持嵌套属性)
const searchFieldKeys = cellProperties.customSearchFields || cellProperties.customFieldKeys || ['code', 'name', 'spec', 'category', 'unit']
searchInput.addEventListener('input', (ev) => {
ev.stopPropagation()
const keyword = searchInput.value.toLowerCase().trim()
allRows.forEach(tr => {
const matches = searchFieldKeys.some((key: string) => {
const value = (tr.dataset[key.toLowerCase()] ?? '').toLowerCase()
// 将嵌套路径转换为 data 属性名,如 'calcBase.formula' -> 'calcbase-formula'
const attrName = key.toLowerCase().replace(/\./g, '-')
const value = (tr.dataset[attrName] ?? '').toLowerCase()
return value.includes(keyword)
})
tr.style.display = matches ? '' : 'none'
@@ -154,9 +204,25 @@ export function handlerTableRenderer(instance: any, td: HTMLTableCellElement, ro
tbody.addEventListener('click', (ev) => {
ev.stopPropagation()
const tr = (ev.target as HTMLElement).closest('tr') as HTMLTableRowElement | null
if (!tr) return
const next = tr.dataset.label ?? ''
instance.setDataAtCell(row, column, next)
if (!tr || !tr.classList.contains('hot-dropdown-row')) return
// 从 data-item 属性中恢复完整的 item 数据
const itemJson = tr.dataset.item ?? '{}'
const selectedItem = JSON.parse(itemJson.replace(/&quot;/g, '"'))
// 获取当前行的完整数据
const currentRowData = instance.getSourceDataAtRow(row)
// 调用自定义回调函数
const callbackFn = cellProperties.customCallbackRow
if (typeof callbackFn === 'function') {
callbackFn(currentRowData, selectedItem, row, column, instance)
} else {
// 默认行为:设置显示文本
const displayValue = tr.dataset.label ?? ''
instance.setDataAtCell(row, column, displayValue)
}
if (dropdown.parentNode) dropdown.parentNode.removeChild(dropdown)
currentDropdownEl = null
if (currentOnDocClick) {
@@ -171,11 +237,11 @@ export function handlerTableRenderer(instance: any, td: HTMLTableCellElement, ro
return dropdown
}
const openDropdown = () => {
const openDropdown = async () => {
if (currentDropdownEl && currentDropdownEl.parentNode) currentDropdownEl.parentNode.removeChild(currentDropdownEl)
if (currentOnDocClick) document.removeEventListener('click', currentOnDocClick)
const dropdown = buildDropdown()
const dropdown = await buildDropdown()
document.body.appendChild(dropdown)
currentDropdownEl = dropdown
currentAnchor = { instance, row, col: column, td }

View File

@@ -0,0 +1,28 @@
export function handlerDuplicateCodeRenderer(instance: any, td: HTMLTableCellElement, row: number, column: number, prop: string | number, value: any, cellProperties: any){
td.textContent = value || ''
// 检查是否需要设置验证背景色
const cellMeta = instance.getCellMeta(row, column)
const isValid = cellMeta?.valid !== false
// 如果单元格被标记为无效,设置红色背景
if (!isValid) {
td.style.backgroundColor = '#fa4d3233' // 淡红色背景
} else {
td.style.backgroundColor = '' // 清除背景色
}
// 检查当前值是否重复(排除空值)
if (value && value.toString().trim() !== '') {
// 获取所有数据
const data = instance.getSourceData()
const duplicateCount = data.filter((item: any) => item.code === value).length
if (duplicateCount > 1) {
td.style.color = 'red'
td.style.fontWeight = 'bold'
} else {
td.style.color = ''
td.style.fontWeight = ''
}
}
return td
}

View File

@@ -1,3 +1,38 @@
import { getTreeLine, getTreeLinePaint, getTreeCellDom } from './treeLine'
// 通用的验证 renderer用于显示验证背景色
export const validationRenderer = (
hot: any,
TD: HTMLTableCellElement,
row: number,
col: number,
prop: any,
value: any,
cellProperties: any
) => {
// 清空单元格
while (TD.firstChild) TD.removeChild(TD.firstChild)
// 检查是否需要设置验证背景色
const columns = this.getSettings().columns || []
const currentColumn = columns[col]
const isEmpty = value === null || value === undefined || String(value).trim() === ''
// 如果列配置了 allowInvalid: true 且值为空,设置红色背景
if (currentColumn?.allowInvalid === true && isEmpty) {
TD.style.backgroundColor = '#ff4d4f20' // 淡红色背景
} else {
TD.style.backgroundColor = '' // 清除背景色
}
// 创建文本节点
const text = document.createElement('span')
text.textContent = value == null ? '' : String(value)
TD.appendChild(text)
return TD
}
export const codeRenderer = (
hot: any,
TD: HTMLTableCellElement,
@@ -7,43 +42,71 @@ export const codeRenderer = (
value: any,
cellProperties: any
) => {
while (TD.firstChild) TD.removeChild(TD.firstChild)
// 检查是否需要设置验证背景色
const cellMeta = hot.getCellMeta(row, col)
const isValid = cellMeta?.valid !== false
// 如果单元格被标记为无效,设置红色背景
if (!isValid) {
TD.style.backgroundColor = '#fa4d3233' // 淡红色背景
} else {
TD.style.backgroundColor = cellProperties.className == "row-highlight"?'#e9ecfc':'' // 清除背景色
}
const nestedRowsPlugin = hot.getPlugin('nestedRows')
const physicalRow = nestedRowsPlugin ? nestedRowsPlugin.collapsingUI.translateTrimmedRow(row) : hot.toPhysicalRow(row)
const rowObj = hot.getSourceDataAtRow(physicalRow)
const container = document.createElement('div')
container.style.display = 'flex'
container.style.alignItems = 'center'
container.style.gap = '6px'
const level = nestedRowsPlugin?.dataManager.getRowLevel(physicalRow) ?? 0
for (let i = 0; i < (level || 0); i++) {
const spacer = document.createElement('span')
spacer.className = 'ht_nestingLevel_empty'
container.appendChild(spacer)
const dataManager = nestedRowsPlugin?.dataManager
const dataNode = dataManager?.getDataObject?.(physicalRow)
const root = (dataManager?.getData?.() as any[] | undefined) ?? []
const line = nestedRowsPlugin && dataManager && dataNode && Array.isArray(root)
? getTreeLine(dataNode, dataManager, root)
: ({ key: '0', hasChildren: false } as const)
const { indentLayer, content, toggleEl, textEl } = getTreeCellDom(TD)
if (indentLayer.dataset.paintKey !== line.key) {
indentLayer.dataset.paintKey = line.key
const paint = getTreeLinePaint(line.key)
indentLayer.style.width = paint.width
indentLayer.style.backgroundImage = paint.backgroundImage
indentLayer.style.backgroundSize = paint.backgroundSize
indentLayer.style.backgroundPosition = paint.backgroundPosition
indentLayer.style.backgroundRepeat = paint.backgroundImage ? 'no-repeat' : ''
}
if (rowObj && Array.isArray(rowObj.__children) && rowObj.__children.length > 0) {
const isCollapsed = nestedRowsPlugin?.collapsingUI.areChildrenCollapsed(physicalRow) ?? false
const btn = document.createElement('div')
if (line.hasChildren && nestedRowsPlugin) {
const isCollapsed = nestedRowsPlugin.collapsingUI.areChildrenCollapsed(physicalRow)
const needsButton = toggleEl.tagName !== 'DIV' || !toggleEl.classList.contains('ht_nestingButton')
const btn = needsButton ? document.createElement('div') : toggleEl
btn.className = `ht_nestingButton ${isCollapsed ? 'ht_nestingExpand' : 'ht_nestingCollapse'}`
btn.addEventListener('mousedown', (ev) => {
if (ev.button !== 0) return
ev.stopPropagation()
ev.preventDefault()
if (!nestedRowsPlugin) return
const nowCollapsed = nestedRowsPlugin.collapsingUI.areChildrenCollapsed(physicalRow)
if (nowCollapsed) nestedRowsPlugin.collapsingUI.expandChildren(physicalRow)
else nestedRowsPlugin.collapsingUI.collapseChildren(physicalRow)
})
container.appendChild(btn)
}/*else{
container.classList.add('text-relative')
}*/
const text = document.createElement('span')
text.textContent = value == null ? '' : String(value)
text.classList.add('rowHeader')
btn.dataset.row = String(physicalRow)
if (!(btn as any).__htTreeToggleBound) {
;(btn as any).__htTreeToggleBound = true
btn.addEventListener('mousedown', (ev) => {
ev.stopPropagation()
ev.preventDefault()
const rowStr = (ev.currentTarget as HTMLElement | null)?.dataset.row
const targetRow = rowStr ? Number(rowStr) : NaN
if (!nestedRowsPlugin || Number.isNaN(targetRow)) return
const nowCollapsed = nestedRowsPlugin.collapsingUI.areChildrenCollapsed(targetRow)
if (nowCollapsed) nestedRowsPlugin.collapsingUI.expandChildren(targetRow)
else nestedRowsPlugin.collapsingUI.collapseChildren(targetRow)
})
}
if (needsButton) content.replaceChild(btn, toggleEl)
} else {
const needsSpacer = toggleEl.tagName !== 'SPAN' || !toggleEl.classList.contains('ht_treeToggleSpacer')
if (needsSpacer) {
const spacer = document.createElement('span')
spacer.className = 'ht_treeToggleSpacer'
content.replaceChild(spacer, toggleEl)
}
}
textEl.textContent = value == null ? '' : String(value)
//解决右键行头触发上下文菜单事件
text.addEventListener('contextmenu', (ev) => {
textEl.addEventListener('contextmenu', (ev) => {
ev.preventDefault()
ev.stopPropagation()
const e = new MouseEvent('contextmenu', {
@@ -56,19 +119,52 @@ export const codeRenderer = (
})
TD.dispatchEvent(e)
})
container.appendChild(text)
TD.appendChild(container)
}
return TD
// codeRenderer 的编辑后回调处理
export const handleCodeCallback = (hot: any, changes: any[], codeCallbackRow?: Function) => {
if (!changes || !codeCallbackRow) return
const columns = hot.getSettings().columns || []
changes.forEach(([row, prop, oldValue, newValue]) => {
// 查找当前列配置
const colIndex = hot.propToCol(prop)
const currentColumn = columns[colIndex]
//console.log('currentColumn?.code',currentColumn?.code)
// 只处理配置了 code: true 的列的变化
if (currentColumn?.code === true && oldValue !== newValue) {
const nestedRowsPlugin = hot.getPlugin('nestedRows')
const physicalRow = nestedRowsPlugin ? nestedRowsPlugin.collapsingUI.translateTrimmedRow(row) : hot.toPhysicalRow(row)
const currentRowData = hot.getSourceDataAtRow(physicalRow)
// 调用回调函数,传递参数:当前行数据、旧值、新值、行索引、列索引、实例
if (typeof codeCallbackRow === 'function') {
codeCallbackRow(currentRowData, oldValue, newValue, row, colIndex, hot)
}
}
})
}
export const computeCodeColWidth = (hot: any): number => {
// if (!hot) return codeColWidth.value
if (!hot) return 120
// 查找配置了 code: true 的列
const columns = hot.getSettings().columns || []
const codeColumn = columns.find((col: any) => col.code === true)
if (!codeColumn || !codeColumn.data) return 120
const data = hot.getSourceData() || []
const codeDataKey = codeColumn.data
// 计算该列的最大长度
const maxLen = data.reduce((m: number, r: any) => {
const v = r && r.code != null ? String(r.code) : ''
const v = r && r[codeDataKey] != null ? String(r[codeDataKey]) : ''
return Math.max(m, v.length)
}, 0)
const charWidth = 9
const basePadding = 24
const nested = hot.getPlugin('nestedRows')
@@ -84,100 +180,71 @@ export const computeCodeColWidth = (hot: any): number => {
return Math.min(Math.max(80, width), 480)
}
// 工具函数:解析 level 字符串
const parseLevel = (level: string): number[] =>
level?.split('-').map(n => Number(n)).filter(n => !Number.isNaN(n)) ?? []
// 工具函数:根据 level 获取容器和索引
const getContainerAndIndexByLevel = (data: any[], level: string) => {
const seg = parseLevel(level)
if (seg.length === 0) return null
if (seg.length === 1) return { container: data, index: seg[0], parentLevel: null }
let parent = data[seg[0]]
for (let i = 1; i < seg.length - 1; i++) {
if (!Array.isArray(parent.__children)) parent.__children = []
parent = parent.__children[seg[i] - 1]
if (!parent) return null
const createNode = (dataSchema: any,level: string): any => ({
...dataSchema,
level,
__children: []
})
const getSelectedVisualRowRange = (hot: any): { startRow: number; amount: number } | null => {
const sel = hot.getSelectedLast?.() ?? hot.getSelected?.()?.[0]
if (!sel) return null
const [r1, , r2] = sel as [number, number, number, number]
const startRow = Math.min(r1, r2)
const endRow = Math.max(r1, r2)
return { startRow, amount: endRow - startRow + 1 }
}
/** 生成树结构 */
const computeRowPath = (node: any, dm: any, root: any[]): string => {
const chain: any[] = []
for (let n = node; n; n = dm.getRowParent(n)) chain.unshift(n)
const segs: string[] = [String(root.indexOf(chain[0]))]
for (let i = 1; i < chain.length; i++) segs.push(String((chain[i - 1].__children?.indexOf(chain[i]) ?? -1) + 1))
return segs.join('-')
}
export const handleRowOperation = (hot: any, type: 'above' | 'below' | 'child' | 'append' | 'delete') => {
//空白处右键 新增行
if (type === 'append') {
//const root = hot.getSourceData() as any[]
// 使用正确的 level 格式索引值从0开始
// hot.getSettings().data.push(createNode(hot.getSettings().dataSchema, "0"))
// hot.loadData(hot.getSettings().data)
// console.log('append',root,hot.getSettings().data)
const root = hot.getSourceData() as any[]
hot.alter('insert_row_below', root.length, 1, 'insert_row_alter')
console.log('append',root)
return
}
const container = Array.isArray(parent.__children) ? parent.__children : (parent.__children = [])
const index = seg[seg.length - 1] - 1
const parentLevel = seg.slice(0, -1).join('-')
return { container, index, parentLevel }
}
// 工具函数:根据 level 查找节点
const findNodeByLevel = (data: any[], level: string) => {
const loc = getContainerAndIndexByLevel(data, level)
return loc ? (loc.container[loc.index] ?? null) : null
}
// 工具函数:重新索引 level
const reindexLevels = (container: any[], parentLevel: string | null): void => {
container.forEach((row, i) => {
const currentLevel = parentLevel == null ? String(i) : `${parentLevel}-${i + 1}`
row.level = currentLevel
if (Array.isArray(row.__children) && row.__children.length > 0) {
reindexLevels(row.__children, currentLevel)
}
})
}
// 统一的行操作处理函数
export const handleRowOperation = (hot: any, type: 'above' | 'below' | 'child' | 'delete') => {
const selected = hot.getSelected()
if (!selected || selected.length === 0) return
const row = selected[0][0]
const nestedRowsPlugin = hot.getPlugin('nestedRows')
if (!nestedRowsPlugin) return
const physicalRow = nestedRowsPlugin ? nestedRowsPlugin.collapsingUI.translateTrimmedRow(row) : hot.toPhysicalRow(row)
const rowData = hot.getSourceDataAtRow(physicalRow)
const currentLevel = String(rowData.level ?? '')
const data = JSON.parse(JSON.stringify(hot.getSettings().data))
if (type === 'delete') {
// 删除行
const loc = getContainerAndIndexByLevel(data, currentLevel)
if (!loc) return
const { container, index, parentLevel } = loc
container.splice(index, 1)
reindexLevels(container, parentLevel)
} else {
// 根据 columns 配置动态生成 newRow 对象结构
const columns = hot.getSettings().columns || []
const newRow: any = {
level: null,
__children: []
}
// 根据 columns 的 data 字段生成对象结构
columns.forEach((col: any) => {
if (col.data && col.data !== 'level' && col.data !== '__children') {
newRow[col.data] = null
}
})
if (type === 'child') {
// 添加子行
const parentNode = findNodeByLevel(data, currentLevel)
if (!parentNode) return
if (!Array.isArray(parentNode.__children)) parentNode.__children = []
const nextIndex = parentNode.__children.length + 1
newRow.level = `${currentLevel}-${nextIndex}`
parentNode.__children.push(newRow)
} else {
// 在上方或下方插入行
const loc = getContainerAndIndexByLevel(data, currentLevel)
if (!loc) return
const { container, index, parentLevel } = loc
const insertIndex = type === 'above' ? Math.max(index, 0) : Math.max(index + 1, 0)
container.splice(insertIndex, 0, newRow)
reindexLevels(container, parentLevel)
}
const range = getSelectedVisualRowRange(hot)
if (!range) return
hot.alter('remove_row', range.startRow, range.amount, 'remove_row_alter')
return
}
hot.updateSettings({ data })
// nestedRowsPlugin.headersUI.updateRowHeaderWidth()
hot.render()
const sel = hot.getSelected()
if (!sel?.length) return
const plugin = hot.getPlugin('nestedRows')
if (!plugin) return
const dm = plugin.dataManager
const row = plugin.collapsingUI.translateTrimmedRow(sel[0][0])
const target = dm.getDataObject(row)
const parent = dm.getRowParent(row)
const root = dm.getRawSourceData() as any[]
console.log('target',target)
if (type === 'child') {
const base = target.level && typeof target.level === 'string' ? target.level : computeRowPath(target, dm, root)
const next = (target.__children?.length ?? 0) + 1
;(target.__children ??= []).push(createNode(hot.getSettings().dataSchema,`${base}-${next}`))
hot.loadData(root)
return
}
const list = parent ? parent.__children : root
const pos = dm.getRowIndexWithinParent(row)
const lvl = String(dm.getRowLevel(row) ?? 0)
list.splice(type === 'above' ? pos : pos + 1, 0, createNode(hot.getSettings().dataSchema,lvl))
hot.loadData(root)
}

View File

@@ -0,0 +1,140 @@
type TreeLinePaint = {
key: string
width: string
backgroundImage: string
backgroundSize: string
backgroundPosition: string
}
let treeLineCache = new WeakMap<any, { key: string; hasChildren: boolean }>()
const treeLinePaintCache = new Map<string, TreeLinePaint>()
export const resetTreeLineCaches = () => {
treeLineCache = new WeakMap<any, { key: string; hasChildren: boolean }>()
treeLinePaintCache.clear()
}
export const getTreeLine = (node: any, dataManager: any, root: any[]) => {
const cached = treeLineCache.get(node)
if (cached) return cached
const level = Math.max(0, dataManager.getRowLevel(node) ?? 0)
const hasChildren = !!(node && Array.isArray(node.__children) && node.__children.length)
if (level <= 0) {
const next = { key: '0', hasChildren }
treeLineCache.set(node, next)
return next
}
const isLast = (n: any) => {
const parent = dataManager.getRowParent(n)
const siblings = (parent ? parent.__children : root) as any[] | undefined
if (!Array.isArray(siblings) || siblings.length === 0) return true
return siblings[siblings.length - 1] === n
}
const flags: boolean[] = []
let parent = dataManager.getRowParent(node)
while (parent && (dataManager.getRowLevel(parent) ?? 0) > 0) {
flags.push(!isLast(parent))
parent = dataManager.getRowParent(parent)
}
flags.reverse()
const key = `${level}|${isLast(node) ? 1 : 0}|${flags.map(v => (v ? '1' : '0')).join('')}`
const next = { key, hasChildren }
treeLineCache.set(node, next)
return next
}
export const getTreeLinePaint = (key: string) => {
const cached = treeLinePaintCache.get(key)
if (cached) return cached
if (key === '0') {
const empty = { key, width: '0px', backgroundImage: '', backgroundSize: '', backgroundPosition: '' }
treeLinePaintCache.set(key, empty)
return empty
}
const [levelStr, isLastStr, flagsStr] = key.split('|')
const level = Number(levelStr)
const isLast = isLastStr === '1'
const flags = flagsStr ? flagsStr.split('').map(v => v === '1') : []
const color = 'var(--ht-tree-line-color)'
const width = 'var(--ht-tree-line-width)'
const indent = 'var(--ht-tree-indent)'
const images: string[] = []
const sizes: string[] = []
const positions: string[] = []
for (let i = 0; i < flags.length; i++) {
if (!flags[i]) continue
images.push(`linear-gradient(${color}, ${color})`)
sizes.push(`${width} 100%`)
positions.push(`calc(${indent} * ${i} + (${indent} / 2)) 0`)
}
const selfDepth = level - 1
images.push(`linear-gradient(${color}, ${color})`)
sizes.push(`${width} ${isLast ? '50%' : '100%'}`)
positions.push(`calc(${indent} * ${selfDepth} + (${indent} / 2)) 0`)
images.push(`linear-gradient(to right, ${color}, ${color})`)
sizes.push(`calc(${indent} / 2) ${width}`)
positions.push(`calc(${indent} * ${selfDepth} + (${indent} / 2)) 50%`)
const paint = {
key,
width: `calc(var(--ht-tree-indent) * ${level})`,
backgroundImage: images.join(', '),
backgroundSize: sizes.join(', '),
backgroundPosition: positions.join(', '),
}
treeLinePaintCache.set(key, paint)
return paint
}
export const getTreeCellDom = (TD: HTMLTableCellElement) => {
const currentTreeCell = TD.firstElementChild as HTMLElement | null
if (currentTreeCell && currentTreeCell.classList.contains('ht_treeCell')) {
const indentLayer = currentTreeCell.firstElementChild as HTMLElement
const content = currentTreeCell.lastElementChild as HTMLElement
return {
treeCell: currentTreeCell,
indentLayer,
content,
toggleEl: content.firstElementChild as HTMLElement,
textEl: content.lastElementChild as HTMLElement,
}
}
const treeCell = document.createElement('div')
treeCell.className = 'ht_treeCell'
const indentLayer = document.createElement('span')
indentLayer.className = 'ht_treeIndentLayer'
const content = document.createElement('div')
content.className = 'ht_treeContent'
const toggleSpacer = document.createElement('span')
toggleSpacer.className = 'ht_treeToggleSpacer'
const text = document.createElement('span')
text.className = 'rowHeader'
content.appendChild(toggleSpacer)
content.appendChild(text)
treeCell.appendChild(indentLayer)
treeCell.appendChild(content)
TD.replaceChildren(treeCell)
return {
treeCell,
indentLayer,
content,
toggleEl: toggleSpacer,
textEl: text,
}
}

View File

@@ -0,0 +1,89 @@
// 验证行
export const validatorRow = (_this: any,changes: any) => {
// 获取列配置
const columns = _this.getSettings().columns;
// 收集所有需要验证的行(去重)
const rowsToValidate = new Set<number>();
for (let i = 0; i < changes.length; i++) {
const currChange = changes[i];
const row = currChange[0]; // 当前修改的行索引
const prop = currChange[1]; // 当前修改的列(属性名)
const oldValue = currChange[2]; // 修改前的值
const newValue = currChange[3]; // 修改后的值
// console.log(`行${row}, 列${prop}, 从 ${oldValue} 改为 ${newValue}`)
// 将当前行加入待验证列表
rowsToValidate.add(row);
}
let hasEmptyCell = false
// 验证所有受影响的行
for (const row of rowsToValidate) {
// console.log(`验证第 ${row} 行的所有必填列`)
// 遍历所有列,验证 required: true 的列
columns.forEach((columnConfig: any, colIndex: number) => {
if (columnConfig.required === true) {
// 获取当前单元格的值
const cellValue = _this.getDataAtCell(row, colIndex);
// 检查值是否为空null、undefined、空字符串
let isEmpty = false
// 对于 使用 db-dropdown renderer需要特殊处理
if (columnConfig.renderer === 'db-dropdown' || columnConfig.renderer === 'db-duplicate') {
// 检查值是否为空或不在 source 列表中
isEmpty = cellValue === null || cellValue === undefined || cellValue === ''
// 如果有值,还需要验证是否在允许的选项中
if (!isEmpty && Array.isArray(columnConfig.source)) {
const validValues = columnConfig.source.map((opt: any) =>
typeof opt === 'object' && opt !== null ? opt.value : opt
)
isEmpty = !validValues.includes(cellValue)
}
} else {
// 其他列的常规验证
isEmpty = cellValue === null || cellValue === undefined || cellValue === ''
}
if (isEmpty) {
// 值为空,标记为无效
_this.setCellMeta(row, colIndex, 'valid', false);
hasEmptyCell = true
// console.log(` 列 ${columnConfig.data} (索引${colIndex}) 值为空,标记为无效`);
} else {
// 对于 numeric 类型,额外验证是否为有效数字
if (columnConfig.type === 'numeric') {
const numValue = Number(cellValue)
if (isNaN(numValue)) {
// 不是有效数字,标记为无效
_this.setCellMeta(row, colIndex, 'valid', false);
hasEmptyCell = true
// console.log(` 列 ${columnConfig.data} (索引${colIndex}) 不是有效数字,标记为无效`);
} else {
// 是有效数字,标记为有效
_this.setCellMeta(row, colIndex, 'valid', true);
// console.log(` 列 ${columnConfig.data} (索引${colIndex}) 是有效数字,标记为有效`);
}
} else {
// 值不为空,标记为有效
_this.setCellMeta(row, colIndex, 'valid', true);
//console.log(` 列 ${columnConfig.data} (索引${colIndex}) 值不为空,标记为有效`);
}
}
}
});
}
// 重新渲染表格以显示验证状态
_this.render();
// 如果有空单元格,提前返回,不执行后续操作
if(hasEmptyCell){
console.log('存在空单元格,验证失败,不执行后续操作')
return false
}
return true
}

View File

@@ -1,6 +1,7 @@
import type { Ref } from 'vue'
import { ref } from 'vue'
import type { DropdownInstance, TreeV2Instance } from 'element-plus'
import { contextMenuManager } from '../db-hst/contextMenuManager'
interface NodeBase<T> { id: string; label: string; children?: T[] }
type NodeType<T> = T & NodeBase<T>
@@ -31,11 +32,19 @@ interface LevelConfig {
addKey?: string
addText?: string
allowDelete?: boolean
condition?: (node: any) => boolean // 添加条件判断函数,用于控制是否显示添加菜单
sort?: string // 排序字段名,如 'sortOrder'
customMenuItems?: Array<{ key: string; text: string; condition?: (node: any) => boolean }> // 自定义菜单项
onAdd?: (parentNode: any, newNode: any, allChildren: any[]) => void | Promise<void>
onDelete?: (node: any) => void | Promise<void>
onCustomCommand?: (cmd: string, node: any) => void | Promise<void> // 自定义命令处理
}
interface HierarchyConfig {
rootKey: string
rootText: string
onRootAdd?: (newNode: any, allRootNodes: any[]) => void | Promise<void>
onRootDelete?: (node: any) => void | Promise<void>
levels: LevelConfig[]
}
@@ -73,7 +82,9 @@ class HierarchyContextMenuHandler<T> implements ContextMenuHandler<T> {
}
const depth = getDepth(node, ctx)
console.log('getMenuItems - 节点:', node, '深度:', depth)
const levelConfig = this.config.levels.find(l => l.depth === depth)
console.log('找到的 levelConfig:', levelConfig)
if (!levelConfig) {
// 未配置的层级,只显示删除
@@ -82,9 +93,26 @@ class HierarchyContextMenuHandler<T> implements ContextMenuHandler<T> {
const items: MenuItem[] = []
// 添加子级菜单项
// 添加子级菜单项(检查条件)
if (levelConfig.addKey && levelConfig.addText) {
items.push({ key: levelConfig.addKey, text: levelConfig.addText })
console.log('检查 addKey 条件, levelConfig.condition:', levelConfig.condition)
// 如果有条件函数,检查是否满足条件
if (!levelConfig.condition || levelConfig.condition(node)) {
console.log('条件满足,添加菜单项:', levelConfig.addText)
items.push({ key: levelConfig.addKey, text: levelConfig.addText })
} else {
console.log('条件不满足,不添加菜单项')
}
}
// 添加自定义菜单项
if (levelConfig.customMenuItems) {
for (const customItem of levelConfig.customMenuItems) {
// 如果有条件函数,检查是否满足条件
if (!customItem.condition || customItem.condition(node)) {
items.push({ key: customItem.key, text: customItem.text })
}
}
}
// 删除菜单项
@@ -100,7 +128,26 @@ class HierarchyContextMenuHandler<T> implements ContextMenuHandler<T> {
if (!node && cmd === this.config.rootKey) {
const next = ctx.createNode('root')
next.label = this.config.rootText.replace('添加', '')
// 先添加到数据中
ctx.setData([...ctx.dataRef.value, next])
// 调用根节点添加回调,传入所有根节点数据
if (this.config.onRootAdd) {
try {
await this.config.onRootAdd(next, ctx.dataRef.value)
// 回调完成后,重新设置数据以确保更新
ctx.setData([...ctx.dataRef.value])
} catch (error) {
// 如果回调失败,移除刚添加的节点
const index = ctx.dataRef.value.findIndex(n => n.id === next.id)
if (index !== -1) {
ctx.dataRef.value.splice(index, 1)
ctx.setData([...ctx.dataRef.value])
}
throw error
}
}
return
}
@@ -110,29 +157,108 @@ class HierarchyContextMenuHandler<T> implements ContextMenuHandler<T> {
if (cmd === 'delete') {
const target = ctx.locate(node.id)
if (!target) return
// 查找当前节点的层级配置
const depth = getDepth(node, ctx)
const levelConfig = this.config.levels.find(l => l.depth === depth)
// 如果是根节点depth === -1 或 parent === null调用根节点删除回调
if (!target.parent && this.config.onRootDelete) {
await this.config.onRootDelete(node)
} else if (levelConfig?.onDelete) {
// 否则调用层级删除回调
await levelConfig.onDelete(node)
}
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 depth = getDepth(node, ctx)
const levelConfig = this.config.levels.find(l => l.depth === depth && l.addKey === cmd)
const next = ctx.createNode(node.id)
next.label = levelConfig.addText?.replace('添加', '') || '新目录'
if (levelConfig) {
const target = ctx.locate(node.id)
if (!target) return
target.node.children = target.node.children || []
// 如果配置了排序字段,先对现有子节点排序
if (levelConfig.sort) {
const sortField = levelConfig.sort
target.node.children.sort((a: any, b: any) => {
const aVal = a[sortField] ?? 0
const bVal = b[sortField] ?? 0
return aVal - bVal
})
// 计算新节点的排序号(取最大值 + 1
const maxSort = target.node.children.reduce((max: number, child: any) => {
const childSort = child[sortField] ?? 0
return Math.max(max, childSort)
}, 0)
const next = ctx.createNode(node.id)
next.label = levelConfig.addText?.replace('添加', '') || '新目录'
;(next as any)[sortField] = maxSort + 1
target.node.children.push(next)
ctx.setData([...ctx.dataRef.value])
ctx.expandNode(target.node.id)
// 调用添加回调,传递父节点的所有子节点
if (levelConfig.onAdd) {
try {
await levelConfig.onAdd(node, next, target.node.children)
// 回调完成后,重新设置数据以确保更新
ctx.setData([...ctx.dataRef.value])
} catch (error) {
// 如果回调失败,移除刚添加的节点
const index = target.node.children!.findIndex(n => n.id === next.id)
if (index !== -1) {
target.node.children!.splice(index, 1)
ctx.setData([...ctx.dataRef.value])
}
throw error
}
}
} else {
// 没有配置排序字段,使用原逻辑
const next = ctx.createNode(node.id)
next.label = levelConfig.addText?.replace('添加', '') || '新目录'
target.node.children.push(next)
ctx.setData([...ctx.dataRef.value])
ctx.expandNode(target.node.id)
// 调用添加回调,传递父节点的所有子节点
if (levelConfig.onAdd) {
try {
await levelConfig.onAdd(node, next, target.node.children)
// 回调完成后,重新设置数据以确保更新
ctx.setData([...ctx.dataRef.value])
} catch (error) {
// 如果回调失败,移除刚添加的节点
const index = target.node.children!.findIndex(n => n.id === next.id)
if (index !== -1) {
target.node.children!.splice(index, 1)
ctx.setData([...ctx.dataRef.value])
}
throw error
}
}
}
return
}
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)
// 处理自定义命令
const customLevelConfig = this.config.levels.find(l => l.depth === getDepth(node, ctx))
if (customLevelConfig?.onCustomCommand) {
await customLevelConfig.onCustomCommand(cmd, node)
}
}
}
}
/**
@@ -170,7 +296,7 @@ class DefaultContextMenuHandler<T> implements ContextMenuHandler<T> {
target.node.children.push(ctx.createNode(target.node.id))
ctx.setData([...ctx.dataRef.value])
ctx.expandNode(target.node.id)
ctx.setCurrentKey(target.node.id)
// ctx.setCurrentKey(target.node.id)
return
}
if (cmd === 'rename') { ctx.startEdit(node); return }
@@ -191,10 +317,22 @@ class DbTreeContextMenu<T> {
private config: ContextMenuConfig<T>
private handler: ContextMenuHandler<T>
private unregister: (() => void) | null = null
constructor(config: ContextMenuConfig<T>, handler?: ContextMenuHandler<T>) {
this.config = config
this.handler = handler ?? new DefaultContextMenuHandler<T>()
// 注册到全局菜单管理器
this.unregister = contextMenuManager.register(this.closeContextMenu)
}
destroy() {
// 取消注册
if (this.unregister) {
this.unregister()
this.unregister = null
}
}
private createNode = (prefix: string): NodeType<T> => {
@@ -223,18 +361,30 @@ class DbTreeContextMenu<T> {
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 })
// console.log('openContextMenu',nodeData)
// 通知管理器即将打开新菜单,关闭其他菜单
contextMenuManager.notifyOpening(this.closeContextMenu)
event.preventDefault()
const { clientX, clientY } = event
this.currentNode.value = nodeData
const items = this.handler.getMenuItems(this.currentNode.value, this.ctx())
if (!items.length) return
this.position.value = DOMRect.fromRect({ x: clientX, y: clientY })
this.dropdownRef.value?.handleOpen()
}
openBlankContextMenu = (event: MouseEvent) => {
const { clientX, clientY } = event
this.position.value = DOMRect.fromRect({ x: clientX, y: clientY })
// console.log('openBlankContextMenu')
// 通知管理器即将打开新菜单,关闭其他菜单
contextMenuManager.notifyOpening(this.closeContextMenu)
event.preventDefault()
const { clientX, clientY } = event
this.currentNode.value = null
const items = this.handler.getMenuItems(this.currentNode.value, this.ctx())
if (!items.length) return
this.position.value = DOMRect.fromRect({ x: clientX, y: clientY })
this.dropdownRef.value?.handleOpen()
}

View File

@@ -7,11 +7,12 @@
placeholder="请输入关键字"
@input="onQueryChanged"
/>
<!-- @contextmenu="openBlankContextMenu" -->
<ElTreeV2
class="treeLine-2"
:indent="0"
ref="treeRef"
style="max-width: 600px"
:data="data"
:props="props"
:filter-method="filterMethod"
@@ -19,49 +20,64 @@
:height="treeHeight"
@node-expand="onNodeExpand"
@node-collapse="onNodeCollapse"
@contextmenu="openBlankContextMenu"
@contextmenu="handleContextMenu"
@nodeContextmenu="handleNodeContextMenu"
highlight-current
scrollbar-always-on
:expand-on-click-node="false"
@nodeClick="onNodeSingleClick"
>
<template #default="{ node, data: nodeData }">
<!-- 根据层级生成占位符level 1 不需要占位 -->
<!-- :style="{ paddingLeft: node.isLeaf ? '0px' : '0px' }" -->
<span v-for="i in (node.level - 1)" :key="i" class="node_nestingLevel_empty"></span>
<div class="node-content-wrapper" :style="{ paddingLeft: node.isLeaf ? '0px' : '11px' }">
<span class="node-icon-wrapper">
<IconifyIcon
v-if="!node.isLeaf"
:icon="node.expanded ? 'ep:remove' : 'ep:circle-plus'"
class="custom-expand-icon"
/>
</span>
<template v-if="editingId === nodeData.id">
<ElInput
:id="`edit-${nodeData.id}`"
v-model="editingLabel"
@blur="saveEdit"
@keydown.enter.prevent="saveEdit"
@keydown.esc.prevent="cancelEdit"
@click.stop
size="small"
class="node-edit-input"
/>
</template>
<template v-else>
<span
class="node-label"
:style="getNodeStyles(nodeData)"
draggable="true"
@dragstart="onDragStart(nodeData, $event)"
@dragover.prevent="onDragOver(nodeData, $event)"
@drop.prevent="onDrop(nodeData, $event)"
@dragend="onDragEnd"
@contextmenu.stop="(e) => openContextMenu(e, nodeData)"
@click="onNodeSingleClick(nodeData, $event)"
@dblclick.stop="onNodeDblClick(nodeData, $event)"
>
{{ nodeData.label }}
<div class="node-content-wrapper" :style="{ paddingLeft: '5px' }">
<div class="node-left-content">
<span class="node-icon-wrapper" >
<IconifyIcon
v-if="!node.isLeaf"
:icon="node.expanded ? 'ep:remove' : 'ep:circle-plus'"
class="custom-expand-icon"
@click.stop="onNodeIconWrapperClick(node)"
/>
</span>
</template>
<template v-if="editingId === nodeData.id">
<ElInput
:id="`edit-${nodeData.id}`"
v-model="editingLabel"
@blur="saveEdit"
@keydown.enter.prevent="saveEdit"
@keydown.esc.prevent="cancelEdit"
@click.stop
size="small"
class="node-edit-input"
/>
</template>
<template v-else>
<!-- @contextmenu.stop="(e) => openContextMenu(e, nodeData)" -->
<span
class="node-label"
:style="getNodeStyles(nodeData)"
draggable="true"
@dragstart="onDragStart(nodeData, $event)"
@dragover.prevent="onDragOver(nodeData, $event)"
@drop.prevent="onDrop(nodeData, $event)"
@dragend="onDragEnd"
@dblclick.stop="onNodeDblClick(nodeData, $event)"
>
{{ nodeData.label }}
</span>
</template>
</div>
<!-- 默认是type="info"有值时type="primary" -->
<el-button
v-if="node.isLeaf && shouldShowDecideButton(nodeData)"
:type="nodeData.selected?'primary':'info'"
circle
size="small"
:class="['decide-button', { 'decide-button-highlight': isDecideHighlighted(nodeData) }]"
@click.stop="onDecideClick(nodeData, $event)"
>{{ decideText }}</el-button>
</div>
</template>
</ElTreeV2>
@@ -91,18 +107,27 @@
</template>
<script lang="ts" setup>
import { ref, nextTick, computed, watch } from 'vue'
import { ElTreeV2, ElDropdown, ElDropdownMenu, ElDropdownItem, ElInput } from 'element-plus'
import type { TreeNodeData, TreeV2Instance } from 'element-plus'
import { IconifyIcon } from '@vben/icons'
import { useContextMenu } from './contextMenu'
import type { TreeNodeData, TreeV2Instance } from 'element-plus'
import { ElDropdown, ElDropdownItem, ElDropdownMenu, ElInput, ElTreeV2 } from 'element-plus'
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import type { ContextMenuHandler, HierarchyConfig } from './contextMenu'
import { useInlineEdit } from './inlineEdit'
import { useContextMenu } from './contextMenu'
import { useDragAndDrop } from './draggable'
import { useInlineEdit } from './inlineEdit'
defineOptions({ name: 'DbTree' });
const componentProps = defineProps<{ height?: number; data?: Tree[]; defaultExpandedKeys?: number | string | string[]; contextMenuHandler?: ContextMenuHandler<Tree> | HierarchyConfig; search?: boolean }>()
const emit = defineEmits<{ (e: 'select', node: Tree): void }>()
const componentProps = defineProps<{
height?: number;
data?: Tree[];
defaultExpandedKeys?: number | string | string[];
contextMenuHandler?: ContextMenuHandler<Tree> | HierarchyConfig;
search?: boolean;
decide?: boolean;
decideText?: string;
decideHighlightKey?: string;
decideFilter?: (node: Tree) => boolean; // 新增:过滤哪些节点可以显示工字按钮
}>()
const emit = defineEmits<{ (e: 'select', node: Tree): void; (e: 'edit-save', node: Tree, oldLabel: string, newLabel: string): void; (e: 'decide', node: Tree): void }>()
interface Tree {
id: string
@@ -122,6 +147,14 @@ watch(
const treeHeight = computed(() => componentProps.height ?? 400)
const isSearchEnabled = computed(() => componentProps.search ?? true)
const isDecideEnabled = computed(() => componentProps.decide ?? false)
const decideText = computed(() => componentProps.decideText ?? '定')
const decideHighlightKey = computed(() => componentProps.decideHighlightKey ?? '')
const decideFilter = computed(() => componentProps.decideFilter ?? (() => true)) // 默认所有节点都可以显示
const isContextMenuEnabled = computed(() => {
// console.log(componentProps.contextMenuHandler)
return componentProps.contextMenuHandler ?? false
})
const props = {
value: 'id',
label: 'label',
@@ -173,6 +206,17 @@ const onQueryChanged = (query: string) => {
const filterMethod = (query: string, node: TreeNodeData) =>
node.label!.includes(query)
const isDecideHighlighted = (nodeData: Tree) => {
const key = decideHighlightKey.value
if (!key) return false
return Boolean((nodeData as Record<string, any>)[key])
}
const shouldShowDecideButton = (nodeData: Tree) => {
if (!isDecideEnabled.value) return false
return decideFilter.value(nodeData)
}
type LocateResult = { node: Tree; parent: Tree | null; container: Tree[]; index: number }
const locate = (id: string): LocateResult | null => {
@@ -187,36 +231,97 @@ const locate = (id: string): LocateResult | null => {
}
return null
}
const { editingId, editingLabel, editingOriginalLabel, startEdit, saveEdit, cancelEdit } = useInlineEdit<Tree>({ dataRef: data, locate })
type TreeKey = string | number
interface TreeNode {
key: TreeKey
level: number
parent?: TreeNode
children?: TreeNode[]
data: TreeNodeData
disabled?: boolean
label?: string
isLeaf?: boolean
expanded?: boolean
isEffectivelyChecked?: boolean
}
const onNodeIconWrapperClick = (node: TreeNode) => {
if (!treeRef.value) return
if (node.isLeaf) return
if (node.expanded) treeRef.value.collapseNode(node)
else treeRef.value.expandNode(node)
}
const handleEditSave = async (node: Tree, oldLabel: string, newLabel: string) => {
emit('edit-save', node, oldLabel, newLabel)
}
const { editingId, editingLabel, editingOriginalLabel, startEdit, saveEdit, cancelEdit } = useInlineEdit<Tree>({ dataRef: data, locate, onSave: handleEditSave })
const { dropdownRef, position, triggerRef, currentNode, openContextMenu, openBlankContextMenu, closeContextMenu, onGlobalCommand, getMenuItems } = useContextMenu<Tree>({ dataRef: data, treeRef, expandedKeysRef: expandedKeys, locate, startEdit }, componentProps.contextMenuHandler)
const { draggingId, dropState, getNodeStyles, onDragStart, onDragOver, onDrop, onDragEnd } = useDragAndDrop<Tree>({ dataRef: data, treeRef, expandedKeysRef: expandedKeys, locate })
// 包装右键菜单处理函数,根据 isContextMenuEnabled 决定是否执行
const handleContextMenu = (e: MouseEvent) => {
// 始终阻止浏览器默认右键菜单
e.preventDefault()
if (isContextMenuEnabled.value) {
openBlankContextMenu(e)
}
}
const handleNodeContextMenu = (e: MouseEvent, data: Tree) => {
// 始终阻止浏览器默认右键菜单
e.preventDefault()
if (isContextMenuEnabled.value) {
openContextMenu(e, data)
}
}
//防止误触发
let clickTimer: ReturnType<typeof setTimeout> | null = null
const clickDelayMs = 250
const triggerSingleClick = (node: Tree) => {
closeContextMenu()
emit('select', node)
}
const onNodeSingleClick = (node: Tree, e: MouseEvent) => {
// 清除之前的定时器
if (clickTimer) {
clearTimeout(clickTimer)
clickTimer = null
}
// 单击延迟执行,避免与双击冲突
clickTimer = setTimeout(() => {
triggerSingleClick(node)
closeContextMenu()
emit('select', node)
clickTimer = null
}, clickDelayMs)
}, 250)
}
const onNodeDblClick = (node: Tree, e: MouseEvent) => {
console.log('onNodeDblClick')
// 清除单击定时器,避免触发单击事件
if (clickTimer) {
clearTimeout(clickTimer)
clickTimer = null
}
// 直接启动编辑,不需要防抖
startEdit(node)
}
const onDecideClick = (node: Tree, e: MouseEvent) => {
// 阻止事件冒泡和默认行为
e.stopPropagation()
e.preventDefault()
// 清除可能存在的单击定时器
if (clickTimer) {
clearTimeout(clickTimer)
clickTimer = null
}
// 触发 decide 事件
emit('decide', node)
}
const onNodeExpand = (data: TreeNodeData) => {
const key = (data as any)[props.value] as string
if (!key) return
@@ -233,6 +338,25 @@ const onNodeCollapse = (data: TreeNodeData) => {
}
}
// 暴露方法给父组件
const setCurrentKey = (key: string | number | null) => {
if (treeRef.value) {
treeRef.value.setCurrentKey(key)
}
}
defineExpose({
setCurrentKey
})
// 组件卸载时清理
onUnmounted(() => {
// 如果 contextMenu 实例有 destroy 方法,调用它
if (typeof (dropdownRef.value as any)?.destroy === 'function') {
(dropdownRef.value as any).destroy()
}
})
</script>
<style lang="scss" scoped>
@@ -244,9 +368,23 @@ const onNodeCollapse = (data: TreeNodeData) => {
// gap: 8px;
}
.treeLine-2 {
:deep(.el-tree__empty-block) {
height: v-bind('treeHeight + "px"');
}
:deep(.el-tree-node.is-current > .el-tree-node__content) {
// 设置颜色
background-color: #e9ecfc !important; // 透明度为0.2的skyblue作者比较喜欢的颜色
color: #273fe2; // 节点的字体颜色
// font-weight: bold; // 字体加粗
box-shadow: inset 2px 0 0 0 #0000ff !important;
}
// :deep(.el-tree .el-tree-node.is-current > .el-tree-node__content) {
// box-shadow: inset 2px 0 0 0 var(--992e623a);
// }
.node_nestingLevel_empty {
display: inline-block;
padding-left: 18px;
padding-left: 13px;
}
.node_nestingLevel_empty::before {
content: '';
@@ -271,6 +409,16 @@ const onNodeCollapse = (data: TreeNodeData) => {
align-items: center;
gap: 0px;
line-height: 1;
width: 100%;
justify-content: space-between;
}
.node-left-content {
display: flex;
align-items: center;
gap: 0px;
flex: 1;
min-width: 0;
}
.node-icon-wrapper {
@@ -303,19 +451,34 @@ const onNodeCollapse = (data: TreeNodeData) => {
flex: 1;
min-width: 100px;
}
.decide-button {
width: 20px !important;
height: 20px !important;
padding: 0 !important;
font-size: 12px !important;
min-width: 20px !important;
margin-left: 4px;
}
.decide-button-highlight {
background-color: #cfe8ff !important;
border-color: #cfe8ff !important;
color: #1f3b66 !important;
}
/* 选择后面还有 ht_nestingLevel_empty 的元素(不是最后一个) */
.node_nestingLevel_empty:has(+ .node_nestingLevel_empty)::after {
/* 你的样式 */
width: 0px;
}
:deep(.el-tree-node){
left: -9px !important;
// left: -9px !important;
}
:deep(.el-tree-node__expand-icon){
display: none !important;
}
// :deep(.el-tree-node__content){
// display: none !important;
// }
:deep(.el-tree-node .el-tree-node__content){
// padding-left: 5px !important;
}
}
</style>

View File

@@ -7,8 +7,9 @@ type LocateResult<T> = { node: NodeType<T>; parent: NodeType<T> | null; containe
export const useInlineEdit = <T>(params: {
dataRef: Ref<NodeType<T>[]>;
locate: (id: string) => LocateResult<T>;
onSave?: (node: NodeType<T>, oldLabel: string, newLabel: string) => void | Promise<void>;
}) => {
const { dataRef, locate } = params
const { dataRef, locate, onSave } = params
const editingId = ref<string | null>(null)
const editingLabel = ref('')
const editingOriginalLabel = ref('')
@@ -24,12 +25,18 @@ export const useInlineEdit = <T>(params: {
})
}
const saveEdit = () => {
const saveEdit = async () => {
if (!editingId.value) return
const target = locate(editingId.value)
if (!target) { editingId.value = null; return }
const next = editingLabel.value.trim()
if (next) target.node.label = next
const oldLabel = editingOriginalLabel.value
if (next && next !== oldLabel) {
target.node.label = next
if (onSave) {
await onSave(target.node, oldLabel, next)
}
}
dataRef.value = [...dataRef.value]
editingId.value = null
}