工料机、定额基价、定额费率、定额取费
This commit is contained in:
1
apps/web-ele/src/components/db-calc/index.ts
Normal file
1
apps/web-ele/src/components/db-calc/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as DbCalc } from './index.vue';
|
||||
252
apps/web-ele/src/components/db-calc/index.vue
Normal file
252
apps/web-ele/src/components/db-calc/index.vue
Normal 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=工料机(返回类别ID),fee=定额取费(返回对象)
|
||||
}
|
||||
|
||||
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>
|
||||
75
apps/web-ele/src/components/db-hst/README.md
Normal file
75
apps/web-ele/src/components/db-hst/README.md
Normal 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` 配置与自定义菜单会自动互斥
|
||||
41
apps/web-ele/src/components/db-hst/base.ts
Normal file
41
apps/web-ele/src/components/db-hst/base.ts
Normal 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
|
||||
}
|
||||
56
apps/web-ele/src/components/db-hst/contextMenuManager.ts
Normal file
56
apps/web-ele/src/components/db-hst/contextMenuManager.ts
Normal 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()
|
||||
228
apps/web-ele/src/components/db-hst/contextmenu.vue
Normal file
228
apps/web-ele/src/components/db-hst/contextmenu.vue
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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_empty(rowHeader 前面的那个) */
|
||||
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>
|
||||
|
||||
490
apps/web-ele/src/components/db-hst/nestedRows.ts
Normal file
490
apps/web-ele/src/components/db-hst/nestedRows.ts
Normal 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
|
||||
}
|
||||
}
|
||||
169
apps/web-ele/src/components/db-hst/popover.ts
Normal file
169
apps/web-ele/src/components/db-hst/popover.ts
Normal 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
|
||||
}
|
||||
}
|
||||
61
apps/web-ele/src/components/db-hst/popover.vue
Normal file
61
apps/web-ele/src/components/db-hst/popover.vue
Normal 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>
|
||||
@@ -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, '"')
|
||||
|
||||
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(/"/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 }
|
||||
|
||||
28
apps/web-ele/src/components/db-hst/text.ts
Normal file
28
apps/web-ele/src/components/db-hst/text.ts
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
140
apps/web-ele/src/components/db-hst/treeLine.ts
Normal file
140
apps/web-ele/src/components/db-hst/treeLine.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
89
apps/web-ele/src/components/db-hst/validator.ts
Normal file
89
apps/web-ele/src/components/db-hst/validator.ts
Normal 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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user