第二阶段代码
This commit is contained in:
@@ -1,127 +1,137 @@
|
||||
<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'
|
||||
import { DbHst } from '#/components/db-hst';
|
||||
import { ElButton, ElDialog, ElInput, ElSegmented } from 'element-plus';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
|
||||
type TableType = '代号' | '类别'
|
||||
|
||||
interface TableColumn {
|
||||
prop: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface TableItem {
|
||||
type: TableType
|
||||
data: any[]
|
||||
columns: TableColumn[]
|
||||
prop: 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 }>
|
||||
tables?: TableItem[]
|
||||
mode?: 'resource' | 'fee'
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'confirm', result: ConfirmResult): void
|
||||
(e: 'confirm', result: any): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
currentValue: '',
|
||||
tableData: () => [],
|
||||
tableColumn: () => [],
|
||||
tableProp: 'code', // 默认使用 code 字段
|
||||
mode: 'resource' // 默认为工料机模式
|
||||
tables: () => [],
|
||||
mode: 'resource'
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// Handsontable 相关
|
||||
const tableContainer = ref<HTMLElement | null>(null)
|
||||
let hotInstance: Handsontable | null = null
|
||||
const hstRef = ref<InstanceType<typeof DbHst>>()
|
||||
|
||||
// 计算基数弹窗数据 - 使用 computed 从 props 获取
|
||||
const calcTableData = computed(() => props.tableData || [])
|
||||
|
||||
// 运算符号按钮
|
||||
const operators = ['+', '-', '*', '/', '(', ')']
|
||||
|
||||
// 当前编辑的值
|
||||
const editValue = ref<string>('')
|
||||
|
||||
// 监听 props.currentValue 变化
|
||||
const activeType = ref<TableType>('类别')
|
||||
|
||||
const typeOptions = computed(() => {
|
||||
const types = props.tables.map(t => t.type)
|
||||
return [...new Set(types)].map(t => ({ label: t, value: t }))
|
||||
})
|
||||
|
||||
const currentTable = computed(() => props.tables.find(t => t.type === activeType.value))
|
||||
|
||||
watch(() => props.currentValue, (newVal) => {
|
||||
editValue.value = newVal || ''
|
||||
}, { immediate: true })
|
||||
|
||||
// 监听弹窗关闭,清空编辑值
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
console.log('DbCalc: modelValue changed =', newVal)
|
||||
|
||||
if (!newVal) {
|
||||
editValue.value = ''
|
||||
if (hotInstance) {
|
||||
hotInstance.destroy()
|
||||
hotInstance = null
|
||||
}
|
||||
} else {
|
||||
editValue.value = props.currentValue || ''
|
||||
// 弹窗打开时初始化表格
|
||||
if (props.tables.length > 0) {
|
||||
activeType.value = props.tables[0]?.type || '类别'
|
||||
}
|
||||
nextTick(() => {
|
||||
initHandsontable()
|
||||
updateTable()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 初始化 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]
|
||||
|
||||
watch(activeType, () => {
|
||||
nextTick(() => {
|
||||
updateTable()
|
||||
})
|
||||
})
|
||||
|
||||
const updateTable = () => {
|
||||
const table = currentTable.value
|
||||
if (table && hstRef.value?.hotInstance) {
|
||||
hstRef.value.hotInstance.updateSettings({
|
||||
columns: createColumns(table.columns),
|
||||
afterOnCellMouseDown: createCellClickHandler(table)
|
||||
})
|
||||
hstRef.value.hotInstance.loadData(table.data)
|
||||
hstRef.value.hotInstance.render()
|
||||
}
|
||||
}
|
||||
|
||||
const createColumns = (columns: TableColumn[]) => columns.map(col => ({
|
||||
data: col.prop,
|
||||
title: col.label,
|
||||
// readOnly: true,
|
||||
className: 'htCenter'
|
||||
}))
|
||||
// 检查所有可能的价格代码字段
|
||||
const priceCodeFields = ['taxExclBaseCode', 'taxInclBaseCode', 'taxExclCompileCode', 'taxInclCompileCode', 'code']
|
||||
|
||||
const createCellClickHandler = (table: TableItem) => {
|
||||
return (_event: MouseEvent, coords: { row: number; col: number }) => {
|
||||
if (coords.row >= 0 && coords.col >= 0) {
|
||||
const rowData = hstRef.value?.hotInstance?.getSourceDataAtRow(coords.row) as any
|
||||
const colProp = table.columns[coords.col]?.prop
|
||||
//console.log('DbCalc: cell click:', { rowData, colProp })
|
||||
if (colProp && priceCodeFields.includes(colProp)) {
|
||||
const value = rowData?.[colProp]
|
||||
if (value !== undefined && value !== null) {
|
||||
const cellValue = Array.isArray(value) ? value.join('') : String(value)
|
||||
editValue.value += cellValue
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 添加运算符
|
||||
const settings = computed(() => {
|
||||
const table = currentTable.value
|
||||
if (!table) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
data: table.data,
|
||||
columns: createColumns(table.columns),
|
||||
colHeaders: true,
|
||||
rowHeaders: false,
|
||||
editor: false,
|
||||
afterOnCellMouseDown: createCellClickHandler(table)
|
||||
}
|
||||
})
|
||||
|
||||
const addOperator = (op: string) => {
|
||||
editValue.value += op
|
||||
}
|
||||
@@ -135,17 +145,15 @@ const handleClose = () => {
|
||||
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) {
|
||||
const allTableData = props.tables.flatMap(t => t.data)
|
||||
|
||||
if (!formula || allTableData.length === 0) {
|
||||
return variables
|
||||
}
|
||||
|
||||
// 创建正则表达式,将运算符和数字替换为空格,用于分割
|
||||
const operatorPattern = operators.map(op => {
|
||||
// 转义特殊字符
|
||||
return op.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}).join('|')
|
||||
|
||||
// 移除运算符、数字、小数点和空格,剩下的就是变量代码
|
||||
const operatorPattern = operators.map(op => op.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
|
||||
const cleanFormula = formula.replace(new RegExp(`(${operatorPattern}|\\d+\\.?\\d*|\\s+)`, 'g'), ' ')
|
||||
|
||||
// 分割出所有可能的变量代码
|
||||
@@ -159,10 +167,9 @@ const extractVariables = (formula: string): Record<string, number | { categoryId
|
||||
'taxInclCompileCode': 'tax_incl_compile_price'
|
||||
}
|
||||
|
||||
// 遍历 tableData,检查公式中是否包含该项的所有价格代码
|
||||
props.tableData.forEach((item: any) => {
|
||||
// 检查所有可能的价格代码字段
|
||||
const priceCodeFields = ['taxExclBaseCode', 'taxInclBaseCode', 'taxExclCompileCode', 'taxInclCompileCode', 'code']
|
||||
// 遍历合并后的 tableData,检查公式中是否包含该项的所有价格代码
|
||||
allTableData.forEach((item: any) => {
|
||||
|
||||
|
||||
priceCodeFields.forEach(field => {
|
||||
const codeValue = item[field]
|
||||
@@ -188,20 +195,12 @@ const extractVariables = (formula: string): Record<string, number | { categoryId
|
||||
return variables
|
||||
}
|
||||
|
||||
// 保存计算数据
|
||||
const handleConfirm = () => {
|
||||
const variables = extractVariables(editValue.value)
|
||||
|
||||
const result: ConfirmResult = {
|
||||
emit('confirm', {
|
||||
formula: editValue.value,
|
||||
variables: variables
|
||||
}
|
||||
|
||||
// 只触发 confirm 事件,不自动关闭弹窗
|
||||
// 让父组件在保存成功后再关闭弹窗
|
||||
emit('confirm', result)
|
||||
// 移除自动关闭逻辑
|
||||
// emit('update:modelValue', false)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -212,34 +211,51 @@ const handleConfirm = () => {
|
||||
title="计算基数设置"
|
||||
width="800px"
|
||||
:close-on-click-modal="false"
|
||||
body-class="calc-body-height"
|
||||
>
|
||||
<div style="margin-bottom: 15px;">
|
||||
<div style="margin-bottom: 10px; color: #909399; font-size: 13px;">
|
||||
限数字:0-9及小数点。运算符号:+加、-减、*乘、/除、()英文括号,代码。
|
||||
<div class="h-full w-full flex flex-col">
|
||||
<div>
|
||||
<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>
|
||||
<span style="margin-right: 10px;">运算符号:</span>
|
||||
<ElButton
|
||||
v-for="op in operators"
|
||||
:key="op"
|
||||
@click="addOperator(op)"
|
||||
circle
|
||||
>
|
||||
{{ op }}
|
||||
</ElButton>
|
||||
</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>
|
||||
<div class="flex items-start">
|
||||
<ElSegmented
|
||||
v-if="typeOptions.length > 0"
|
||||
class="hst-segmented"
|
||||
v-model="activeType"
|
||||
:options="typeOptions"
|
||||
direction="vertical"
|
||||
/>
|
||||
<div class="flex-1 h-[400px] overflow-auto">
|
||||
<DbHst
|
||||
ref="hstRef"
|
||||
:settings="settings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<template #footer>
|
||||
<ElButton @click="handleClose">取消</ElButton>
|
||||
@@ -248,5 +264,15 @@ const handleConfirm = () => {
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<style lang="scss">
|
||||
.calc-body-height{
|
||||
height: 500px;
|
||||
}
|
||||
.hst-segmented{
|
||||
--el-border-radius-base: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.hst-segmented .el-segmented__item {
|
||||
writing-mode: vertical-rl !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
# 右键菜单组件使用说明
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 全局菜单管理
|
||||
- 当打开一个右键菜单时,会自动关闭其他已打开的菜单
|
||||
- 支持多个菜单组件实例共存,互不干扰
|
||||
- 自动清理资源,防止内存泄漏
|
||||
- **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` 配置与自定义菜单会自动互斥
|
||||
@@ -38,4 +38,16 @@ export const isVisualRowSelected = (visualRowIndex: number) => {
|
||||
const range = selectedVisualRowRange.value
|
||||
if (!range) return false
|
||||
return visualRowIndex >= range.from && visualRowIndex <= range.to
|
||||
}
|
||||
}
|
||||
export const formatNumeric = (value: any): string => {
|
||||
if (value === null || value === undefined || value === '') return ''
|
||||
const num = parseFloat(value)
|
||||
if (isNaN(num)) return ''
|
||||
return num.toFixed(2)
|
||||
}
|
||||
|
||||
// const iconSvg = '<svg t="1766200593500" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8130" width="16" height="16"><path d="M251.2 387H320v68.8c0 1.8 1.8 3.2 4 3.2h48c2.2 0 4-1.4 4-3.3V387h68.8c1.8 0 3.2-1.8 3.2-4v-48c0-2.2-1.4-4-3.3-4H376v-68.8c0-1.8-1.8-3.2-4-3.2h-48c-2.2 0-4 1.4-4 3.2V331h-68.8c-1.8 0-3.2 1.8-3.2 4v48c0 2.2 1.4 4 3.2 4z m328 0h193.6c1.8 0 3.2-1.8 3.2-4v-48c0-2.2-1.4-4-3.3-4H579.2c-1.8 0-3.2 1.8-3.2 4v48c0 2.2 1.4 4 3.2 4z m0 265h193.6c1.8 0 3.2-1.8 3.2-4v-48c0-2.2-1.4-4-3.3-4H579.2c-1.8 0-3.2 1.8-3.2 4v48c0 2.2 1.4 4 3.2 4z m0 104h193.6c1.8 0 3.2-1.8 3.2-4v-48c0-2.2-1.4-4-3.3-4H579.2c-1.8 0-3.2 1.8-3.2 4v48c0 2.2 1.4 4 3.2 4z m-195.7-81l61.2-74.9c4.3-5.2 0.7-13.1-5.9-13.1H388c-2.3 0-4.5 1-5.9 2.9l-34 41.6-34-41.6c-1.5-1.8-3.7-2.9-5.9-2.9h-50.9c-6.6 0-10.2 7.9-5.9 13.1l61.2 74.9-62.7 76.8c-4.4 5.2-0.8 13.1 5.8 13.1h50.8c2.3 0 4.5-1 5.9-2.9l35.5-43.5 35.5 43.5c1.5 1.8 3.7 2.9 5.9 2.9h50.8c6.6 0 10.2-7.9 5.9-13.1L383.5 675z" p-id="8131"></path><path d="M880 112H144c-17.7 0-32 14.3-32 32v736c0 17.7 14.3 32 32 32h736c17.7 0 32-14.3 32-32V144c0-17.7-14.3-32-32-32z m-36 732H180V180h664v664z" p-id="8132"></path></svg>'
|
||||
// // 替代方案(注释状态)
|
||||
// const utf8Bytes = new TextEncoder().encode(iconSvg);
|
||||
// const base64 = 'data:image/svg+xml;base64,' + btoa(String.fromCharCode(...utf8Bytes));
|
||||
// console.log('base64', base64)
|
||||
69
apps/web-ele/src/components/db-hst/checkbox.ts
Normal file
69
apps/web-ele/src/components/db-hst/checkbox.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { Ref } from 'vue';
|
||||
/**
|
||||
* 递归设置所有子节点的选中状态
|
||||
*/
|
||||
const setChildrenSelected = (node: any, selected: boolean): void => {
|
||||
if (!node.__children?.length) return;
|
||||
|
||||
node.__children.forEach((child: any) => {
|
||||
child.selected = selected;
|
||||
setChildrenSelected(child, selected);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 递归检查并更新父节点的选中状态
|
||||
* 只要有子节点选中,父节点就选中
|
||||
*/
|
||||
const updateParentSelected = (node: any, dataManager: any): void => {
|
||||
const parent = dataManager.getRowParent(node);
|
||||
if (!parent) return;
|
||||
|
||||
// 检查是否有子节点被选中
|
||||
parent.selected = parent.__children?.some((child: any) => child.selected) ?? false;
|
||||
|
||||
// 递归向上更新父节点
|
||||
updateParentSelected(parent, dataManager);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理 checkbox 变化事件
|
||||
*/
|
||||
export const hstCheckboxChange = (hstRef: Ref<any>) => {
|
||||
return (changes: any[] | null, source: string): void => {
|
||||
if (!changes || source === 'loadData') return;
|
||||
|
||||
const hotInstance = hstRef.value.hotInstance;
|
||||
if (!hotInstance) return;
|
||||
|
||||
const nestedRowsPlugin = hotInstance.getPlugin('nestedRows');
|
||||
if (!nestedRowsPlugin?.enabled){
|
||||
console.error('需开启db.nestedRows')
|
||||
return;
|
||||
}
|
||||
|
||||
const dataManager = nestedRowsPlugin.dataManager;
|
||||
|
||||
for (const [row, prop, , newValue] of changes) {
|
||||
if (prop !== 'selected') continue;
|
||||
|
||||
const dataObject = dataManager.getDataObject(row);
|
||||
if (!dataObject) continue;
|
||||
|
||||
// 如果是父节点,递归设置所有子节点的选中状态
|
||||
if (dataManager.isParent(row)) {
|
||||
setChildrenSelected(dataObject, newValue);
|
||||
}
|
||||
|
||||
// 向上更新父节点的选中状态
|
||||
updateParentSelected(dataObject, dataManager);
|
||||
}
|
||||
|
||||
hotInstance.render();
|
||||
if (hotInstance.__onCheckboxChange) {
|
||||
const data = hotInstance?.getSourceData() ?? []
|
||||
const res = data.filter((f: any) => f.selected).map((item: any) => item.id)
|
||||
hotInstance.__onCheckboxChange(res)
|
||||
}
|
||||
};
|
||||
}
|
||||
32
apps/web-ele/src/components/db-hst/command.ts
Normal file
32
apps/web-ele/src/components/db-hst/command.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import Handsontable from 'handsontable'
|
||||
|
||||
// Handsontable physical/visual row helper functions for hiddenColumns support
|
||||
// Problem: Custom renderers cause dirty rendering after hiding columns due to DOM reuse
|
||||
// Solution: Use physical row index for data lookup
|
||||
|
||||
export const getPhysicalRowIndex = (instance: Handsontable.Core, visualRow: number): number => {
|
||||
const physicalRow = instance.toPhysicalRow(visualRow)
|
||||
return physicalRow >= 0 ? physicalRow : visualRow
|
||||
}
|
||||
|
||||
export const getRowData = (instance: Handsontable.Core, visualRow: number): any | null => {
|
||||
const physicalRow = getPhysicalRowIndex(instance, visualRow)
|
||||
return (instance.getSourceDataAtRow(physicalRow) as any | null) ?? null
|
||||
}
|
||||
|
||||
export const getCellValueByProp = (
|
||||
instance: Handsontable.Core,
|
||||
visualRow: number,
|
||||
visualColumn: number,
|
||||
): { cellProp: string | number; cellValue: any; rowData: any | null } => {
|
||||
const cellProp = instance.colToProp(visualColumn)
|
||||
const rowData = getRowData(instance, visualRow)
|
||||
if (typeof cellProp === 'string' && rowData) {
|
||||
return { cellProp, cellValue: rowData[cellProp], rowData }
|
||||
}
|
||||
return {
|
||||
cellProp,
|
||||
cellValue: instance.getDataAtCell(visualRow, visualColumn),
|
||||
rowData,
|
||||
}
|
||||
}
|
||||
79
apps/web-ele/src/components/db-hst/component/ColoumApply.vue
Normal file
79
apps/web-ele/src/components/db-hst/component/ColoumApply.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { getFieldConfigList } from '#/api/database/interface/config';
|
||||
import { DbHst } from '#/components/db-hst';
|
||||
import { ElButton, ElDialog } from 'element-plus';
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
success?: (data: any[]) => void
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const modelValue = ref(false)
|
||||
const hstRef = ref()
|
||||
const savedHiddenFields = ref<string[]>([])
|
||||
const hasConfirmed = ref(false)
|
||||
|
||||
const columns = [
|
||||
{ type: 'text', data: 'fieldName', title: '字段名称', readOnly: true, width: 170 },
|
||||
{ type: 'checkbox', data: 'hidden', title: '隐藏', className: 'htCenter' },
|
||||
];
|
||||
|
||||
const settings = {
|
||||
data: [],
|
||||
columns,
|
||||
// rowHeaders: true,
|
||||
}
|
||||
|
||||
const show = async (fields: string[], catalogItemId: number | string) => {
|
||||
const res = await getFieldConfigList(catalogItemId);
|
||||
// Use saved hidden fields if confirmed before, otherwise use passed fields
|
||||
const hiddenFields = hasConfirmed.value ? savedHiddenFields.value : fields;
|
||||
const data = res.map((item: any) => ({
|
||||
id: item.id,
|
||||
fieldName: item.fieldName,
|
||||
fieldCode: item.fieldCode,
|
||||
hidden: hiddenFields.includes(item.fieldCode),
|
||||
}));
|
||||
// console.log('show res', data);
|
||||
modelValue.value = true;
|
||||
setTimeout(() => {
|
||||
hstRef.value?.hotInstance?.loadData(data);
|
||||
}, 200);
|
||||
}
|
||||
const close = () => {
|
||||
modelValue.value = false
|
||||
}
|
||||
const handleConfirm = () => {
|
||||
const hotInstance = hstRef.value?.hotInstance
|
||||
const editedData = hotInstance?.getSourceData() ?? []
|
||||
// collect hidden fieldCodes as string[]
|
||||
const hiddenFieldCodes = editedData
|
||||
.filter((item: any) => item.hidden)
|
||||
.map((item: any) => item.fieldCode)
|
||||
savedHiddenFields.value = hiddenFieldCodes
|
||||
hasConfirmed.value = true
|
||||
props.success?.(hiddenFieldCodes)
|
||||
modelValue.value = false
|
||||
}
|
||||
|
||||
defineExpose({ show, close })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDialog :model-value="modelValue" @update:model-value="close" title="列头筛选"
|
||||
width="400px" :close-on-click-modal="false">
|
||||
<div class="h-[400px] w-full flex flex-col">
|
||||
<DbHst ref="hstRef" :settings="settings" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<ElButton @click="close">取消</ElButton>
|
||||
<ElButton type="primary" @click="handleConfirm">确定</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss"></style>
|
||||
@@ -0,0 +1,440 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch, nextTick } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
container?: HTMLElement | null
|
||||
holderSelector?: string,
|
||||
}>(),
|
||||
{
|
||||
container: null,
|
||||
holderSelector: '.handsontable .wtHolder',
|
||||
}
|
||||
)
|
||||
|
||||
const BAR_PADDING = 2
|
||||
const BAR_GAP = BAR_PADDING * 2
|
||||
const BAR_SIZE = 0 // hot-scrollbar-corner 显示
|
||||
const BAR_INSET = 2
|
||||
const MIN_THUMB_SIZE = 28
|
||||
|
||||
type Axis = 'vertical' | 'horizontal'
|
||||
const AXIS_MAP = {
|
||||
vertical: {
|
||||
clientKey: 'clientY',
|
||||
scrollKey: 'scrollTop',
|
||||
scrollSizeKey: 'scrollHeight',
|
||||
clientSizeKey: 'clientHeight',
|
||||
rectKey: 'top',
|
||||
},
|
||||
horizontal: {
|
||||
clientKey: 'clientX',
|
||||
scrollKey: 'scrollLeft',
|
||||
scrollSizeKey: 'scrollWidth',
|
||||
clientSizeKey: 'clientWidth',
|
||||
rectKey: 'left',
|
||||
},
|
||||
} as const
|
||||
|
||||
const holderElement = ref<HTMLElement | null>(null)
|
||||
|
||||
const isVerticalScrollable = ref(false)
|
||||
const isHorizontalScrollable = ref(false)
|
||||
|
||||
const verticalTrackRef = ref<HTMLDivElement | null>(null)
|
||||
const horizontalTrackRef = ref<HTMLDivElement | null>(null)
|
||||
|
||||
const verticalThumbSize = ref(0)
|
||||
const verticalThumbOffset = ref(0)
|
||||
const horizontalThumbSize = ref(0)
|
||||
const horizontalThumbOffset = ref(0)
|
||||
|
||||
const isDraggingVertical = ref(false)
|
||||
const isDraggingHorizontal = ref(false)
|
||||
|
||||
let rafId = 0
|
||||
const scheduleScrollbarUpdate = () => {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = requestAnimationFrame(() => {
|
||||
updateScrollbars()
|
||||
})
|
||||
}
|
||||
|
||||
let holderScrollListener: (() => void) | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let originalOnSelectStart: ((this: GlobalEventHandlers, ev: Event) => any) | null = null
|
||||
|
||||
const setContainerActive = (isActive: boolean) => {
|
||||
const container = props.container
|
||||
if (!container) return
|
||||
if (isActive) container.classList.add('is-scrollbar-active')
|
||||
else container.classList.remove('is-scrollbar-active')
|
||||
}
|
||||
|
||||
const getTrackEl = (axis: Axis) => (axis === 'vertical' ? verticalTrackRef.value : horizontalTrackRef.value)
|
||||
const getThumbSizeRef = (axis: Axis) => (axis === 'vertical' ? verticalThumbSize : horizontalThumbSize)
|
||||
const getThumbOffsetRef = (axis: Axis) => (axis === 'vertical' ? verticalThumbOffset : horizontalThumbOffset)
|
||||
const getDraggingRef = (axis: Axis) => (axis === 'vertical' ? isDraggingVertical : isDraggingHorizontal)
|
||||
const setScrollable = (axis: Axis, next: boolean) => {
|
||||
if (axis === 'vertical') isVerticalScrollable.value = next
|
||||
else isHorizontalScrollable.value = next
|
||||
}
|
||||
const getScrollable = (axis: Axis) => (axis === 'vertical' ? isVerticalScrollable.value : isHorizontalScrollable.value)
|
||||
|
||||
const bindHolderElement = (holder: HTMLElement) => {
|
||||
if (holderElement.value === holder) return
|
||||
|
||||
if (holderElement.value && holderScrollListener) holderElement.value.removeEventListener('scroll', holderScrollListener)
|
||||
resizeObserver?.disconnect()
|
||||
|
||||
holderElement.value = holder
|
||||
|
||||
holderScrollListener = () => scheduleScrollbarUpdate()
|
||||
holder.addEventListener('scroll', holderScrollListener, { passive: true })
|
||||
|
||||
resizeObserver = new ResizeObserver(() => scheduleScrollbarUpdate())
|
||||
resizeObserver.observe(holder)
|
||||
const container = props.container
|
||||
if (container) resizeObserver.observe(container)
|
||||
}
|
||||
|
||||
const ensureHolderBound = () => {
|
||||
const container = props.container
|
||||
if (!container) return
|
||||
const holder = container.querySelector<HTMLElement>(props.holderSelector)
|
||||
if (holder) bindHolderElement(holder)
|
||||
}
|
||||
|
||||
const clampNumber = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
|
||||
|
||||
const updateAxis = (axis: Axis, holder: HTMLElement) => {
|
||||
const map = AXIS_MAP[axis]
|
||||
const track = getTrackEl(axis)
|
||||
if (!track) return
|
||||
|
||||
const scrollSize = (holder as any)[map.scrollSizeKey] as number
|
||||
const clientSize = (holder as any)[map.clientSizeKey] as number
|
||||
const nextScrollable = scrollSize > clientSize + 1
|
||||
|
||||
const prevScrollable = getScrollable(axis)
|
||||
setScrollable(axis, nextScrollable)
|
||||
|
||||
const trackSize = (axis === 'vertical' ? track.clientHeight : track.clientWidth) - BAR_GAP
|
||||
const thumbSizeRef = getThumbSizeRef(axis)
|
||||
const thumbOffsetRef = getThumbOffsetRef(axis)
|
||||
|
||||
if (!nextScrollable || trackSize <= 0) {
|
||||
thumbSizeRef.value = 0
|
||||
thumbOffsetRef.value = 0
|
||||
if (prevScrollable !== nextScrollable) scheduleScrollbarUpdate()
|
||||
return
|
||||
}
|
||||
|
||||
const nextThumbSize = Math.min(trackSize, Math.max((clientSize / scrollSize) * trackSize, MIN_THUMB_SIZE))
|
||||
const maxScroll = scrollSize - clientSize
|
||||
const maxOffset = Math.max(0, trackSize - nextThumbSize)
|
||||
const scroll = (holder as any)[map.scrollKey] as number
|
||||
const nextOffset = clampNumber(maxScroll > 0 ? (scroll / maxScroll) * maxOffset : 0, 0, maxOffset)
|
||||
|
||||
thumbSizeRef.value = nextThumbSize
|
||||
thumbOffsetRef.value = nextOffset
|
||||
|
||||
if (prevScrollable !== nextScrollable) scheduleScrollbarUpdate()
|
||||
}
|
||||
|
||||
const updateScrollbars = () => {
|
||||
ensureHolderBound()
|
||||
const holder = holderElement.value
|
||||
if (!holder) return
|
||||
|
||||
updateAxis('vertical', holder)
|
||||
updateAxis('horizontal', holder)
|
||||
}
|
||||
|
||||
const scrollToOffset = (axis: Axis, nextOffset: number) => {
|
||||
const holder = holderElement.value
|
||||
const track = getTrackEl(axis)
|
||||
if (!holder || !track) return
|
||||
|
||||
const map = AXIS_MAP[axis]
|
||||
const scrollSize = (holder as any)[map.scrollSizeKey] as number
|
||||
const clientSize = (holder as any)[map.clientSizeKey] as number
|
||||
const trackSize = (axis === 'vertical' ? track.clientHeight : track.clientWidth) - BAR_GAP
|
||||
const thumbSize = getThumbSizeRef(axis).value
|
||||
|
||||
const maxScroll = Math.max(0, scrollSize - clientSize)
|
||||
const maxOffset = Math.max(0, trackSize - thumbSize)
|
||||
const clampedOffset = clampNumber(nextOffset, 0, maxOffset)
|
||||
const nextScroll = maxOffset > 0 ? (clampedOffset / maxOffset) * maxScroll : 0
|
||||
;(holder as any)[map.scrollKey] = nextScroll
|
||||
}
|
||||
|
||||
const handleTrackMouseDown = (axis: Axis, e: MouseEvent) => {
|
||||
if (getDraggingRef(axis).value) return
|
||||
if (!getScrollable(axis)) return
|
||||
|
||||
const track = getTrackEl(axis)
|
||||
if (!track) return
|
||||
|
||||
const map = AXIS_MAP[axis]
|
||||
const rect = track.getBoundingClientRect() as any
|
||||
const clickClient = (e as any)[map.clientKey] as number
|
||||
const clickOffset = clickClient - (rect[map.rectKey] as number) - BAR_PADDING
|
||||
const thumbSize = getThumbSizeRef(axis).value
|
||||
const nextOffset = clickOffset - thumbSize / 2
|
||||
scrollToOffset(axis, nextOffset)
|
||||
}
|
||||
|
||||
type DragState = {
|
||||
axis: Axis
|
||||
startClient: number
|
||||
startScroll: number
|
||||
trackSize: number
|
||||
thumbSize: number
|
||||
scrollSize: number
|
||||
clientSize: number
|
||||
}
|
||||
|
||||
const dragState = ref<DragState | null>(null)
|
||||
|
||||
const restoreSelectStart = () => {
|
||||
if (originalOnSelectStart !== null && document.onselectstart !== originalOnSelectStart) document.onselectstart = originalOnSelectStart
|
||||
originalOnSelectStart = null
|
||||
}
|
||||
|
||||
const handleDragMouseMove = (e: MouseEvent) => {
|
||||
const holder = holderElement.value
|
||||
const state = dragState.value
|
||||
if (!holder || !state) return
|
||||
|
||||
const map = AXIS_MAP[state.axis]
|
||||
const currentClient = (e as any)[map.clientKey] as number
|
||||
const delta = currentClient - state.startClient
|
||||
|
||||
const maxScroll = Math.max(0, state.scrollSize - state.clientSize)
|
||||
const maxOffset = Math.max(0, state.trackSize - state.thumbSize)
|
||||
if (maxOffset <= 0) return
|
||||
|
||||
const nextScroll = state.startScroll + (delta * maxScroll) / maxOffset
|
||||
;(holder as any)[map.scrollKey] = clampNumber(nextScroll, 0, maxScroll)
|
||||
}
|
||||
|
||||
const handleDragMouseUp = () => {
|
||||
const state = dragState.value
|
||||
if (state) {
|
||||
getDraggingRef(state.axis).value = false
|
||||
setContainerActive(false)
|
||||
}
|
||||
dragState.value = null
|
||||
|
||||
document.removeEventListener('mousemove', handleDragMouseMove)
|
||||
document.removeEventListener('mouseup', handleDragMouseUp)
|
||||
restoreSelectStart()
|
||||
}
|
||||
|
||||
const startDrag = (axis: Axis, e: MouseEvent) => {
|
||||
const holder = holderElement.value
|
||||
const track = getTrackEl(axis)
|
||||
if (!holder || !track) return
|
||||
if (!getScrollable(axis)) return
|
||||
|
||||
const map = AXIS_MAP[axis]
|
||||
const trackSize = (axis === 'vertical' ? track.clientHeight : track.clientWidth) - BAR_GAP
|
||||
const scrollSize = (holder as any)[map.scrollSizeKey] as number
|
||||
const clientSize = (holder as any)[map.clientSizeKey] as number
|
||||
const startScroll = (holder as any)[map.scrollKey] as number
|
||||
const startClient = (e as any)[map.clientKey] as number
|
||||
const thumbSize = getThumbSizeRef(axis).value
|
||||
|
||||
dragState.value = { axis, startClient, startScroll, trackSize, thumbSize, scrollSize, clientSize }
|
||||
getDraggingRef(axis).value = true
|
||||
setContainerActive(true)
|
||||
|
||||
window.getSelection()?.removeAllRanges()
|
||||
originalOnSelectStart = document.onselectstart
|
||||
document.onselectstart = () => false
|
||||
|
||||
document.addEventListener('mousemove', handleDragMouseMove)
|
||||
document.addEventListener('mouseup', handleDragMouseUp)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// ensureHolderBound()
|
||||
// scheduleScrollbarUpdate()
|
||||
})
|
||||
|
||||
// watch(
|
||||
// () => props.container,
|
||||
// () => {
|
||||
// ensureHolderBound()
|
||||
// scheduleScrollbarUpdate()
|
||||
// }
|
||||
// )
|
||||
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cancelAnimationFrame(rafId)
|
||||
handleDragMouseUp()
|
||||
|
||||
if (holderElement.value && holderScrollListener) holderElement.value.removeEventListener('scroll', holderScrollListener)
|
||||
holderScrollListener = null
|
||||
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
|
||||
restoreSelectStart()
|
||||
setContainerActive(false)
|
||||
})
|
||||
defineExpose({scheduleScrollbarUpdate})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="hot-scrollbar hot-scrollbar--vertical"
|
||||
:class="{ 'is-hidden': !isVerticalScrollable }"
|
||||
:style="{ bottom: isHorizontalScrollable ? `${BAR_INSET + BAR_SIZE + BAR_GAP}px` : `${BAR_INSET}px` }"
|
||||
>
|
||||
<div
|
||||
ref="verticalTrackRef"
|
||||
class="hot-scrollbar__track"
|
||||
@mousedown.prevent.stop="handleTrackMouseDown('vertical', $event)"
|
||||
@click.stop
|
||||
>
|
||||
<div
|
||||
class="hot-scrollbar__thumb"
|
||||
:style="{ height: `${verticalThumbSize}px`, transform: `translate3d(0, ${verticalThumbOffset}px, 0)` }"
|
||||
@mousedown.prevent.stop="startDrag('vertical', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="hot-scrollbar hot-scrollbar--horizontal"
|
||||
:class="{ 'is-hidden': !isHorizontalScrollable }"
|
||||
:style="{ right: isVerticalScrollable ? `${BAR_INSET + BAR_SIZE + BAR_GAP}px` : `${BAR_INSET}px` }"
|
||||
>
|
||||
<div
|
||||
ref="horizontalTrackRef"
|
||||
class="hot-scrollbar__track"
|
||||
@mousedown.prevent.stop="handleTrackMouseDown('horizontal', $event)"
|
||||
@click.stop
|
||||
>
|
||||
<div
|
||||
class="hot-scrollbar__thumb"
|
||||
:style="{ width: `${horizontalThumbSize}px`, transform: `translate3d(${horizontalThumbOffset}px, 0, 0)` }"
|
||||
@mousedown.prevent.stop="startDrag('horizontal', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <div v-if="isVerticalScrollable && isHorizontalScrollable" class="hot-scrollbar-corner" /> -->
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.hot-scrollbar {
|
||||
position: absolute;
|
||||
z-index: 200;
|
||||
opacity: 0;
|
||||
transition: opacity 160ms ease, transform 160ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hot-table-wrapper:hover .hot-scrollbar:not(.is-hidden),
|
||||
.hot-table-wrapper.is-scrollbar-active .hot-scrollbar:not(.is-hidden) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.hot-scrollbar.is-hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.hot-scrollbar--vertical {
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 7px;
|
||||
}
|
||||
|
||||
.hot-scrollbar--horizontal {
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
.hot-scrollbar__track {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 2px;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
linear-gradient(rgba(233, 233, 233, 0.22), rgba(233, 233, 233, 0.1)),
|
||||
rgba(0, 0, 0, 0.06);
|
||||
// box-shadow:
|
||||
// 0 0 0 1px rgba(0, 0, 0, 0.08),
|
||||
// 0 6px 14px rgba(0, 0, 0, 0.12);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.hot-scrollbar__thumb {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: #b4b7bd;
|
||||
// background-color: var(--el-scrollbar-bg-color, var(--el-text-color-secondary));
|
||||
border-radius: 999px;
|
||||
// box-shadow:
|
||||
// 0 1px 2px rgba(0, 0, 0, 0.2),
|
||||
// inset 0 0 0 1px rgba(255, 255, 255, 0.32);
|
||||
transition: transform 80ms ease, background-color 120ms ease;
|
||||
}
|
||||
|
||||
.hot-scrollbar__thumb:hover {
|
||||
background-color: rgb(168, 171, 178);
|
||||
}
|
||||
|
||||
.hot-table-wrapper.is-scrollbar-active .hot-scrollbar__thumb {
|
||||
background-color: rgb(168, 171, 178);
|
||||
}
|
||||
|
||||
.hot-scrollbar--vertical .hot-scrollbar__thumb {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hot-scrollbar--horizontal .hot-scrollbar__thumb {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.hot-scrollbar-corner {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
bottom: 2px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 5px;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08);
|
||||
z-index: 39;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.handsontable .wtHolder::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.handsontable .wtHolder {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
/** 为了解决 滚动条bottom 10px 被遮挡问题 */
|
||||
.hot-table-wrapper .ht_master.handsontable .wtHolder .htCore {
|
||||
padding: 0px 20px 20px 0px;
|
||||
}
|
||||
/** 为了解决 有些表头没有被控制到 */
|
||||
.hot-table-wrapper .ht_clone_top.handsontable .wtHolder .htCore {
|
||||
padding: 0px 20px 0 0px;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* 全局右键菜单管理器
|
||||
* 用于管理多个右键菜单实例,确保同一时间只有一个菜单处于打开状态
|
||||
*/
|
||||
|
||||
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()
|
||||
@@ -1,228 +0,0 @@
|
||||
<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>
|
||||
@@ -1,170 +1,148 @@
|
||||
import { ref } from 'vue'
|
||||
import { ref, type Ref, watch } from 'vue'
|
||||
import Handsontable from 'handsontable'
|
||||
import type { DropdownInstance } from 'element-plus'
|
||||
import { getCellValueByProp } from './command'
|
||||
|
||||
let currentDropdownEl: HTMLElement | null = null
|
||||
let currentOnDocClick: ((e: MouseEvent) => void) | null = null
|
||||
const isDisabled = ref<any>(false)
|
||||
const closeDropdown = () => {
|
||||
if (currentDropdownEl && currentDropdownEl.parentNode) currentDropdownEl.parentNode.removeChild(currentDropdownEl)
|
||||
currentDropdownEl = null
|
||||
if (currentOnDocClick) document.removeEventListener('click', currentOnDocClick, true)
|
||||
currentOnDocClick = null
|
||||
type DropdownOption = {
|
||||
value: string
|
||||
label: string
|
||||
icon?: string
|
||||
disabled?: boolean
|
||||
divided?: boolean
|
||||
}
|
||||
|
||||
const openDropdown = (
|
||||
td: HTMLTableCellElement,
|
||||
value: unknown,
|
||||
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 = 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(String(optValue));
|
||||
// 调用选择后的回调,传递旧值、新值和完整数据
|
||||
if (onAfterSelect) {
|
||||
const optData = typeof opt === 'object' && opt !== null ? opt.data : undefined
|
||||
onAfterSelect(value, String(optValue), optData)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const instanceStateMap = new WeakMap<any, any>()
|
||||
|
||||
const { EventManager } = Handsontable;
|
||||
const { addClass, hasClass } = Handsontable.dom;
|
||||
Handsontable.renderers.registerRenderer("db-dropdown", (instance: any, TD: any, row: number, column: number, prop: string | number, value: any, cellProperties: any) => {
|
||||
// console.log('value',value, row, column)
|
||||
const { rootDocument } = instance;
|
||||
const ARROW = rootDocument.createElement('DIV');
|
||||
|
||||
ARROW.className = 'htAutocompleteArrow';
|
||||
ARROW.appendChild(rootDocument.createTextNode(String.fromCharCode(9660)));
|
||||
|
||||
// 先重绘 TD,防止 DOM 复用时残留旧内容
|
||||
Handsontable.renderers.TextRenderer.apply(this, [instance, TD, row, column, prop, value, cellProperties])
|
||||
|
||||
if (!TD.firstChild) { TD.appendChild(rootDocument.createTextNode(String.fromCharCode(160))); }
|
||||
const disabledProp = (cellProperties as { disabled?: boolean | Function }).disabled
|
||||
const disabled = typeof disabledProp === 'function'
|
||||
? disabledProp(instance, row, prop, value, cellProperties.source)
|
||||
: disabledProp
|
||||
// console.log('TD.firstChild',TD.firstChild)
|
||||
//TODO 优化到{key:'',value:''}
|
||||
TD.firstChild.nodeValue = cellProperties.source.find((item: any) => item.value === value)?.label || ''
|
||||
// TD.firstChild.nodeValue = value?.label || ''
|
||||
if (disabled) return
|
||||
TD.insertBefore(ARROW, TD.firstChild);
|
||||
// addClass(TD, 'htAutocomplete');
|
||||
const target = 'tdDropdown'
|
||||
addClass(TD, [target, '!cursor-pointer']);
|
||||
addClass(ARROW, [target, '!cursor-pointer']);
|
||||
if (!instance[target + 'Listener']) {
|
||||
const eventManager = new EventManager(instance);
|
||||
|
||||
// not very elegant but easy and fast
|
||||
instance[target + 'Listener'] = function (event: MouseEvent) {
|
||||
if (hasClass(event.target as HTMLElement, target)) {
|
||||
const targetCell = (event.target as HTMLElement).closest('td');
|
||||
const state = instanceStateMap.get(instance)
|
||||
if (!state) return
|
||||
state.rendererDropdownRef.value?.handleClose()
|
||||
if (targetCell) {
|
||||
// 使用公共 API 获取坐标(visual index)
|
||||
const coords = instance.getCoords(targetCell);
|
||||
const visualRow = coords.row;
|
||||
const visualCol = coords.col;
|
||||
const cellMeta = instance.getCellMeta(visualRow, visualCol);
|
||||
|
||||
if (cellMeta.readOnly != undefined && cellMeta.readOnly) return;
|
||||
// 使用 getCellValueByProp 正确处理 hiddenColumns
|
||||
const { cellProp, cellValue, rowData } = getCellValueByProp(instance, visualRow, visualCol)
|
||||
let cellSource = (cellMeta as any).source || []
|
||||
if (typeof cellMeta.onOptions === 'function') {
|
||||
cellSource = cellMeta.onOptions(instance, coords.row, cellProp, cellValue, cellSource)
|
||||
}
|
||||
state.activeContext.value = { instance, TD, row: coords.row, column: coords.col, prop: cellProp, value: cellValue, cellProperties: cellMeta, rowData }
|
||||
state.dropdownOptions.value = cellSource
|
||||
setTimeout(() => {
|
||||
const rect = targetCell.getBoundingClientRect()
|
||||
state.popperWidth.value = Math.ceil(rect.width)
|
||||
state.position.value = DOMRect.fromRect({
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
})
|
||||
state.rendererDropdownRef.value?.handleOpen()
|
||||
}, 200)
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation(); //阻止冒泡
|
||||
}
|
||||
closeDropdown()
|
||||
}
|
||||
frag.appendChild(item)
|
||||
}
|
||||
menu.appendChild(frag)
|
||||
const rect = td.getBoundingClientRect()
|
||||
menu.style.visibility = 'hidden'
|
||||
document.body.appendChild(menu)
|
||||
const menuWidth = menu.offsetWidth
|
||||
const menuHeight = menu.offsetHeight
|
||||
const viewportRight = window.scrollX + window.innerWidth
|
||||
const viewportBottom = window.scrollY + window.innerHeight
|
||||
const left = Math.min(rect.left + window.scrollX, viewportRight - menuWidth - 8)
|
||||
let top = rect.bottom + window.scrollY
|
||||
let placement = 'bottom'
|
||||
if (top + menuHeight > viewportBottom - 8) { top = rect.top + window.scrollY - menuHeight; placement = 'top' }
|
||||
menu.style.left = `${Math.max(left, 8)}px`
|
||||
menu.style.top = `${top}px`
|
||||
menu.classList.add(placement === 'top' ? 'is-top' : 'is-bottom')
|
||||
menu.style.visibility = ''
|
||||
currentDropdownEl = menu
|
||||
currentOnDocClick = (ev: MouseEvent) => { const target = ev.target as Node; if (currentDropdownEl && !currentDropdownEl.contains(target)) closeDropdown() }
|
||||
document.addEventListener('click', currentOnDocClick, true)
|
||||
}
|
||||
};
|
||||
//true 是 阻止冒泡
|
||||
eventManager.addEventListener(instance.rootElement, 'mousedown', instance[target + 'Listener'], 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 = '' // 清除背景色
|
||||
// We need to unbind the listener after the table has been destroyed
|
||||
instance.addHookOnce('afterDestroy', () => {
|
||||
eventManager.destroy();
|
||||
instanceStateMap.delete(instance);
|
||||
});
|
||||
}
|
||||
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.className = 'ht-cell-dropdown'
|
||||
const valueEl = document.createElement('span')
|
||||
valueEl.className = 'ht-cell-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
|
||||
|
||||
});
|
||||
|
||||
export function useDropdown(instanceRef: Ref<any>) {
|
||||
const rendererDropdownRef = ref<DropdownInstance>()
|
||||
const position = ref({
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
} as DOMRect)
|
||||
|
||||
const triggerRef = ref({
|
||||
getBoundingClientRect: () => position.value,
|
||||
})
|
||||
|
||||
const popperWidth = ref(100)
|
||||
const activeContext = ref<any>()
|
||||
const dropdownOptions = ref<DropdownOption[]>([])
|
||||
|
||||
watch(instanceRef, (instance) => {
|
||||
if (instance) {
|
||||
instanceStateMap.set(instance, {
|
||||
rendererDropdownRef,
|
||||
position,
|
||||
popperWidth,
|
||||
activeContext,
|
||||
dropdownOptions,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
valueEl.textContent = displayText
|
||||
const caretEl = document.createElement('span')
|
||||
caretEl.className = 'ht-cell-caret'
|
||||
wrapper.appendChild(valueEl)
|
||||
wrapper.appendChild(caretEl)
|
||||
td.appendChild(wrapper)
|
||||
|
||||
let disabledSet = new Set<string>()
|
||||
if (isDisabled.value) {
|
||||
const colValues = instance.getSourceDataAtCol(column) as unknown[]
|
||||
const currentStr = String(value ?? '')
|
||||
disabledSet = new Set((Array.isArray(colValues) ? colValues : []).map(v => String(v)))
|
||||
disabledSet.delete(currentStr)
|
||||
}
|
||||
|
||||
//在 dropdown 的点击事件中阻止冒泡(推荐)
|
||||
wrapper.onclick = (e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
//TODO 暂时性,后面要删除
|
||||
// 如果是只读状态,不打开下拉框
|
||||
if (isReadOnly) {
|
||||
return
|
||||
}, { immediate: true })
|
||||
|
||||
const handleCommandDropdownRenderer = (key: string | number): void => {
|
||||
const ctx = activeContext.value
|
||||
if (!ctx?.instance) return
|
||||
|
||||
const selectedValue = String(key)
|
||||
if (typeof ctx.cellProperties.onSelect === 'function') {
|
||||
ctx.cellProperties.onSelect({ ...ctx, value: selectedValue })
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
ctx.instance.setDataAtRowProp(ctx.row, ctx.prop as string, selectedValue, 'category-dropdown')
|
||||
rendererDropdownRef.value?.handleClose()
|
||||
}
|
||||
|
||||
// 阻止 mousedown 事件冒泡,防止触发 beforeOnCellMouseDown
|
||||
wrapper.onmousedown = (e) => {
|
||||
e.stopPropagation()
|
||||
|
||||
return {
|
||||
rendererDropdownRef,
|
||||
triggerRef,
|
||||
popperWidth,
|
||||
activeContext,
|
||||
dropdownOptions,
|
||||
handleCommandDropdownRenderer,
|
||||
}
|
||||
|
||||
return td
|
||||
}
|
||||
@@ -1,33 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, reactive, ref, nextTick, createVNode, render, watch, computed } from 'vue'
|
||||
import { ElDropdown, ElDropdownMenu, ElDropdownItem } from 'element-plus'
|
||||
import Handsontable from "handsontable";
|
||||
import { HotTable } from '@handsontable/vue3'
|
||||
import { registerLanguageDictionary, zhCN } from 'handsontable/i18n'
|
||||
import { registerAllModules } from 'handsontable/registry'
|
||||
import 'handsontable/styles/handsontable.css'
|
||||
import { HotTable } from '@handsontable/vue3';
|
||||
// import Handsontable from "handsontable";
|
||||
import { useDebounceFn, useResizeObserver } from '@vueuse/core';
|
||||
import type { DropdownInstance } from 'element-plus';
|
||||
import { ElDropdown, ElDropdownItem, ElDropdownMenu } from 'element-plus';
|
||||
import { registerLanguageDictionary, zhCN } from 'handsontable/i18n';
|
||||
import { registerAllModules } from 'handsontable/registry';
|
||||
import 'handsontable/styles/handsontable.css';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
// import 'handsontable/styles/ht-theme-main.css'
|
||||
import 'handsontable/styles/ht-theme-classic.css';
|
||||
|
||||
import HotTableScrollbars from './component/HotTableScrollbars.vue';
|
||||
import { useDropdown } from './dropdown';
|
||||
import { initAddChildAtIndex } from './nestedRows';
|
||||
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<{
|
||||
|
||||
|
||||
const componentProps = defineProps<{
|
||||
settings?: any
|
||||
contextMenuItems?: Array<{
|
||||
key: string
|
||||
name: string
|
||||
callback?: (hotInstance: any) => void
|
||||
icon?: any
|
||||
disabled?: boolean | (() => boolean)
|
||||
divided?: boolean
|
||||
callback?: (hotInstance: any) => void | Promise<void>
|
||||
separator?: boolean
|
||||
}>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'root-menu-command',
|
||||
command: string,
|
||||
): void
|
||||
}>()
|
||||
// 导入和注册插件和单元格类型
|
||||
// import { registerCellType, NumericCellType } from 'handsontable/cellTypes';
|
||||
// import { registerPlugin, UndoRedo } from 'handsontable/plugins';
|
||||
@@ -35,373 +47,382 @@ const componentProps = defineProps<{
|
||||
// registerPlugin(UndoRedo);
|
||||
// const tableHeight = computed(() => componentProps.height ?? 0)
|
||||
// const defaultData = ref<any[][]>(Array.from({ length: 30 }, () => Array(componentProps.columns?.length ?? 0).fill('')))
|
||||
const scrollbarContainer = ref<HTMLElement | null>(null)
|
||||
const hotTableComponent = ref<any>(null)
|
||||
const selectedRow = ref<number | null>(null) // 记录当前选中的行
|
||||
const codeColWidth = ref<number>(120)
|
||||
const hotInstance = ref<any>(null)
|
||||
|
||||
const {
|
||||
rendererDropdownRef,
|
||||
triggerRef: rendererTriggerRef,
|
||||
popperWidth: popperWidthDropdownRenderer,
|
||||
activeContext: activeContextDropdownRenderer,
|
||||
dropdownOptions: dropdownOptionsDropdownRenderer,
|
||||
handleCommandDropdownRenderer,
|
||||
} = useDropdown(hotInstance)
|
||||
|
||||
// const colHeaders = ref<string[]>([])
|
||||
let defaultSettings = {
|
||||
const tableContainer = ref<any>(null)
|
||||
const tableHeight = ref<number>(600)
|
||||
const tableData = ref<any[]>([])
|
||||
// ElDropdown 相关变量
|
||||
const dropdownRef = ref<DropdownInstance>()
|
||||
const position = ref({
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
} as DOMRect)
|
||||
|
||||
const triggerRef = ref({
|
||||
getBoundingClientRect: () => position.value,
|
||||
})
|
||||
|
||||
const defaultSettings = computed(() => ({
|
||||
// themeName: 'ht-theme-main',
|
||||
themeName: 'ht-theme-classic',
|
||||
|
||||
language: 'zh-CN',
|
||||
// data: sourceDataObject,
|
||||
// colWidths: [100, 120, 100, 100, 100, 100],
|
||||
// rowHeights: [30, 30, 30, 30, 30, 30],
|
||||
colWidths: 120, // 固定列宽
|
||||
// colWidths(index) {
|
||||
// return (index + 1) * 40;
|
||||
// },
|
||||
// colWidths: undefined,
|
||||
rowHeights: 23, // 固定行高
|
||||
wordWrap: false,// 禁止单元格内容自动换行
|
||||
|
||||
//manualColumnMove: true,
|
||||
height: 600,//默认高度,为防止滚动条加载前后顺序变化导致的布局抖动
|
||||
columns: [],
|
||||
manualColumnMove: true,
|
||||
manualColumnResize: true,
|
||||
autoRowSize: false,
|
||||
autoColumnSize: false,
|
||||
|
||||
fillHandle: false,//禁用 右下角拖拽
|
||||
selectionMode: 'single', // 'single', 'range' or 'multiple',
|
||||
renderAllRows: false,
|
||||
viewportColumnRenderingOffset: 12,//渲染列数
|
||||
viewportRowRenderingOffset: 12,//渲染行数
|
||||
// colHeaders: componentProps.colHeaders ?? [],
|
||||
rowHeaders: false,
|
||||
// columns: componentProps.columns ?? [],
|
||||
autoWrapRow: true,
|
||||
autoWrapCol: true,
|
||||
width: '100%',
|
||||
// height: 'auto',
|
||||
// height: tableHeight.value,
|
||||
// height: 200,
|
||||
// stretchH: 'all',
|
||||
// loading: true,
|
||||
//contextMenu: true,
|
||||
// dialog: true,
|
||||
// dialog: {
|
||||
// content: 'This dialog can be controlled programmatically.',
|
||||
// closable: true,
|
||||
// contentBackground: true,
|
||||
// background: 'semi-transparent',
|
||||
// },
|
||||
licenseKey: '424fc-f3b67-5905b-a191b-9b809',
|
||||
// 如果使用第一行作为列头(colHeaders: false),添加以下配置
|
||||
// cells: function(row: number, col: number) {
|
||||
// const cellProperties: any = {};
|
||||
|
||||
// // 如果 colHeaders 为 false,将第一行设置为列头样式
|
||||
// if (row === 0) {
|
||||
// cellProperties.readOnly = true; // 不可编辑
|
||||
// cellProperties.className = 'custom-header-row'; // 自定义样式类
|
||||
// cellProperties.renderer = function(instance: any, td: HTMLTableCellElement, row: number, col: number, prop: any, value: any, cellProperties: any) {
|
||||
// Handsontable.renderers.TextRenderer.apply(this, arguments as any);
|
||||
// td.style.fontWeight = 'bold';
|
||||
// td.style.backgroundColor = '#f5f5f5';
|
||||
// td.style.textAlign = 'center';
|
||||
// td.style.borderBottom = '2px solid #ddd';
|
||||
// };
|
||||
// }
|
||||
|
||||
// return cellProperties;
|
||||
// },
|
||||
// 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?.()
|
||||
dropdownRef.value?.handleClose()
|
||||
},
|
||||
}
|
||||
// beforeKeyDown(event: KeyboardEvent) {
|
||||
// const hot = this as any
|
||||
// // 当按下 Backspace 或 Delete 键时,如果单元格处于选中状态(非编辑状态),
|
||||
// // 则进入编辑模式而不是清空单元格内容
|
||||
// if (event.key === 'Backspace' || event.key === 'Delete') {
|
||||
// const activeEditor = hot.getActiveEditor()
|
||||
// // 如果编辑器未打开(单元格处于选中状态而非编辑状态)
|
||||
// if (activeEditor && !activeEditor.isOpened()) {
|
||||
// const selected = hot.getSelected()
|
||||
// if (selected && selected.length > 0) {
|
||||
// const [row, col] = selected[0]
|
||||
// const cellMeta = hot.getCellMeta(row, col)
|
||||
// // 只对非只读单元格生效
|
||||
// if (!cellMeta.readOnly) {
|
||||
// // 阻止默认的清空行为
|
||||
// event.stopImmediatePropagation()
|
||||
// event.preventDefault()
|
||||
// // 打开编辑器进入编辑模式,并将光标放在末尾
|
||||
// activeEditor.beginEditing()
|
||||
// // 延迟执行,确保编辑器已打开
|
||||
// setTimeout(() => {
|
||||
// const textareaElement = activeEditor.TEXTAREA
|
||||
// if (textareaElement) {
|
||||
// // 将光标移到末尾,不全选
|
||||
// const len = textareaElement.value.length
|
||||
// textareaElement.setSelectionRange(len, len)
|
||||
// }
|
||||
// }, 0)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}))
|
||||
// 合并外部 settings 和默认配置
|
||||
let hotSettings = {}
|
||||
// 保留必要的回调函数
|
||||
// let hotSettings = {}
|
||||
// 保留必要的回调函数
|
||||
|
||||
const hotInstance = ref<any>(null)
|
||||
const contextMenuRef = ref<any>(null)
|
||||
const isContextMenu = ref(true)
|
||||
|
||||
// 处理右键菜单事件
|
||||
const handleContextMenu = (event: MouseEvent) => {
|
||||
contextMenuRef.value?.handleContextMenu(event)
|
||||
const startContextMenu = () => {
|
||||
isContextMenu.value = true
|
||||
}
|
||||
const stopContextMenu = () => {
|
||||
isContextMenu.value = false
|
||||
}
|
||||
// ElDropdown 相关函数
|
||||
const handleClick = () => {
|
||||
dropdownRef.value?.handleClose()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const handleRootContextmenu = (event: MouseEvent) => {
|
||||
if (!isContextMenu.value) return
|
||||
// 如果没有自定义菜单项,不拦截右键事件,让 Handsontable 原生 contextMenu 生效
|
||||
if (!componentProps.contextMenuItems || componentProps.contextMenuItems.length === 0) return
|
||||
|
||||
const { clientX, clientY } = event
|
||||
position.value = DOMRect.fromRect({
|
||||
x: clientX,
|
||||
y: clientY,
|
||||
})
|
||||
event.preventDefault()
|
||||
dropdownRef.value?.handleOpen()
|
||||
}
|
||||
|
||||
const handleRootMenuCommand = (command: string | number) => {
|
||||
|
||||
componentProps.contextMenuItems
|
||||
?.find((item) => item.key === command)
|
||||
?.callback?.(hotInstance.value)
|
||||
|
||||
emit('root-menu-command', String(command))
|
||||
dropdownRef.value?.handleClose()
|
||||
}
|
||||
|
||||
// 处理鼠标按下事件,确保点击空白区域时触发输入框的 blur
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
// 如果点击的不是 input 元素,手动触发当前聚焦元素的 blur
|
||||
if (target.tagName !== 'INPUT' && target.tagName !== 'TEXTAREA') {
|
||||
const activeElement = document.activeElement as HTMLElement
|
||||
if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) {
|
||||
activeElement.blur()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseEnter = useDebounceFn(() => {
|
||||
if (scrollbarContainer?.value) (scrollbarContainer.value as any).scheduleScrollbarUpdate?.()
|
||||
}, 100)
|
||||
|
||||
const updateTableHeight = useDebounceFn(() => {
|
||||
if (!tableContainer.value) return
|
||||
const newHeight = tableContainer.value.clientHeight
|
||||
// console.log('updateTableHeight',newHeight, tableHeight.value)
|
||||
if (newHeight > 0 && newHeight !== tableHeight.value) {
|
||||
const enabled = hotInstance.value?.getPlugin('nestedRows').enabled;
|
||||
// console.log('nestedRowsLoadData', enabled)
|
||||
let trimmedIndexes = []
|
||||
if(enabled){
|
||||
const map = hotInstance.value?.getPlugin('nestedRows')?.collapsedRowsMap
|
||||
if(map){
|
||||
trimmedIndexes = map.getTrimmedIndexes()
|
||||
}
|
||||
hotInstance.value?.getPlugin('nestedRows').disablePlugin();//先关闭
|
||||
hotInstance.value.loadData([]);
|
||||
}
|
||||
tableHeight.value = newHeight
|
||||
hotInstance.value?.updateSettings({ height: newHeight })
|
||||
// 通知 Handsontable 更新尺寸
|
||||
hotInstance.value?.refreshDimensions()
|
||||
if(enabled){
|
||||
hotInstance.value?.getPlugin('nestedRows').enablePlugin();//再打开
|
||||
if(tableData.value.length > 0) hotInstance.value.loadData(tableData.value);
|
||||
|
||||
if(trimmedIndexes.length > 0){
|
||||
// console.log('nestedRowsLoadData', trimmedIndexes)
|
||||
hotInstance.value?.getPlugin('nestedRows')?.collapsingUI.collapseRows(trimmedIndexes, true, true)
|
||||
}
|
||||
hotInstance.value?.render()
|
||||
}
|
||||
}
|
||||
}, 100)
|
||||
useResizeObserver(tableContainer, updateTableHeight);
|
||||
|
||||
onMounted(async () => {
|
||||
hotInstance.value = hotTableComponent.value?.hotInstance
|
||||
})
|
||||
onUnmounted(() => {
|
||||
})
|
||||
|
||||
watch(
|
||||
() => componentProps.settings,
|
||||
(newSettings) => {
|
||||
if (!newSettings) return
|
||||
|
||||
const merged = {
|
||||
...defaultSettings,
|
||||
...newSettings,
|
||||
}
|
||||
Object.assign(hotSettings, merged)
|
||||
hotSettings = merged
|
||||
|
||||
// console.log(merged)
|
||||
Object.assign(defaultSettings.value, newSettings)
|
||||
// console.log(defaultSettings.value)
|
||||
},
|
||||
{ immediate: true,deep:true }
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
const loadData = (rows: any[][]) => {
|
||||
|
||||
const nestedRowsLoadData = (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());
|
||||
}
|
||||
|
||||
const updateCodeColWidth = () => {
|
||||
if (!hotInstance.value) return
|
||||
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('nestedRowsLoadData',rows)
|
||||
// nestedRows 解决数据 空数组
|
||||
if(rows.length == 0){
|
||||
if(hotInstance.value?.getPlugin('nestedRows').enabled){
|
||||
hotInstance.value?.getPlugin('nestedRows').disablePlugin();
|
||||
}
|
||||
}else{
|
||||
if(!hotInstance.value?.getPlugin('nestedRows').enabled){
|
||||
if (hotInstance.value?.getSettings().columns.find((item: any) => item.type === 'db.nestedRows')){
|
||||
hotInstance.value?.getPlugin('nestedRows').enablePlugin();
|
||||
initAddChildAtIndex(hotInstance.value?.getPlugin('nestedRows')?.dataManager);
|
||||
}
|
||||
}
|
||||
console.log(newColWidths)
|
||||
// 更新列宽
|
||||
hotInstance.value.updateSettings({
|
||||
colWidths: newColWidths
|
||||
})
|
||||
}
|
||||
|
||||
hotInstance.value.render()
|
||||
tableData.value = rows
|
||||
// console.log('nestedRowsLoadData', hotInstance.value?.getPlugin('nestedRows').enabled, rows)
|
||||
hotInstance.value.loadData(rows);
|
||||
}
|
||||
defineExpose({ loadData, hotTableComponent, hotInstance, updateCodeColWidth, codeColWidth })
|
||||
const collapseAll = () => {
|
||||
if (hotInstance.value?.getPlugin('nestedRows').enabled) {
|
||||
hotInstance.value?.getPlugin('nestedRows').collapsingUI.collapseAll();
|
||||
}
|
||||
}
|
||||
const expandAll = () => {
|
||||
if (hotInstance.value?.getPlugin('nestedRows').enabled) {
|
||||
hotInstance.value?.getPlugin('nestedRows').collapsingUI.expandAll();
|
||||
}
|
||||
}
|
||||
const addChild = () => {
|
||||
return new Promise((resolve) => {
|
||||
if (!hotInstance.value?.getPlugin('nestedRows').enabled) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const plugin = hotInstance.value.getPlugin('nestedRows');
|
||||
const parent = plugin.dataManager.getDataObject(hotInstance.value.getSelectedActive()[0]);
|
||||
// console.log('parent',plugin, parent)
|
||||
if (!parent) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// addHookOnce 确保回调只执行一次
|
||||
hotInstance.value.addHookOnce('afterAddChild', (_parent: any, childElement: any) => {
|
||||
const newRowIndex = plugin.dataManager.getRowIndex(childElement);
|
||||
resolve(newRowIndex);
|
||||
});
|
||||
// batch() 合并 addChild 内部的多次 render 为一次
|
||||
hotInstance.value.batch(() => {
|
||||
plugin.dataManager.addChild(parent);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
nestedRowsLoadData, hotTableComponent, hotInstance,
|
||||
stopContextMenu, startContextMenu, collapseAll, expandAll, addChild,
|
||||
})
|
||||
|
||||
Handsontable.renderers.registerRenderer("db-table", handlerTableRenderer);
|
||||
Handsontable.renderers.registerRenderer("db-dropdown", handlerDropdownRenderer);
|
||||
Handsontable.renderers.registerRenderer("db-duplicate", handlerDuplicateCodeRenderer);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<el-table :data="gridData">
|
||||
<el-table-column property="date" label="Date" width="150" />
|
||||
<el-table-column property="name" label="Name" width="200" />
|
||||
<el-table-column property="address" label="Address" />
|
||||
</el-table>
|
||||
<div style="margin-top:12px;display:flex;gap:8px;justify-content:flex-end">
|
||||
<button class="el-button el-button--default el-button--small btn-cancel">取消</button>
|
||||
<button class="el-button el-button--primary el-button--small btn-ok">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
<!-- <el-popover
|
||||
ref="popoverRef"
|
||||
:virtual-ref="popoverButtonRef"
|
||||
v-model:visible="isPopoverOpen"
|
||||
trigger="manual"
|
||||
virtual-triggering
|
||||
width="auto"
|
||||
>
|
||||
<el-table :data="gridData" size="small" @row-click="onClickOutside" border>
|
||||
<el-table-column width="150" property="date" label="date" />
|
||||
<el-table-column width="100" property="name" label="name" />
|
||||
<el-table-column width="300" property="address" label="address" />
|
||||
</el-table>
|
||||
</el-popover> -->
|
||||
</template>
|
||||
<div ref="tableContainer" class="hot-table-wrapper" @mouseenter="handleMouseEnter"
|
||||
@contextmenu="handleRootContextmenu" @mousedown="handleMouseDown" @click="handleClick">
|
||||
<hot-table ref="hotTableComponent" :settings="defaultSettings"></hot-table>
|
||||
<HotTableScrollbars ref="scrollbarContainer" :container="tableContainer" />
|
||||
<ElDropdown ref="dropdownRef" :virtual-ref="triggerRef" :show-arrow="false" :popper-options="{
|
||||
modifiers: [{ name: 'offset', options: { offset: [0, 0] } }],
|
||||
}" virtual-triggering trigger="contextmenu" placement="bottom-start" @command="handleRootMenuCommand">
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu v-if="componentProps.contextMenuItems?.length">
|
||||
<ElDropdownItem v-for="item in componentProps.contextMenuItems" :key="item.key" :icon="item.icon"
|
||||
:disabled="typeof item.disabled === 'function' ? item.disabled() : item.disabled" :divided="item.divided"
|
||||
:command="item.key">
|
||||
{{ item.name }}
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
<slot v-else name="dropdown-menu" />
|
||||
</template>
|
||||
</ElDropdown>
|
||||
|
||||
<ElDropdown
|
||||
ref="rendererDropdownRef"
|
||||
:virtual-ref="rendererTriggerRef"
|
||||
:show-arrow="false"
|
||||
:popper-options="{
|
||||
modifiers: [{ name: 'offset', options: { offset: [0, 0] } }],
|
||||
}"
|
||||
:popper-style="{ width: `${popperWidthDropdownRenderer}px` }"
|
||||
virtual-triggering
|
||||
trigger="contextmenu"
|
||||
placement="bottom-end"
|
||||
size="small"
|
||||
@command="handleCommandDropdownRenderer"
|
||||
:max-height="200"
|
||||
>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem class="hst-dropdown-menu"
|
||||
v-for="item in dropdownOptionsDropdownRenderer"
|
||||
:key="item.value"
|
||||
:class="{
|
||||
'hot-category-dropdown__item--active': item.value === activeContextDropdownRenderer?.value
|
||||
}"
|
||||
:icon="item.icon"
|
||||
:disabled="item.disabled"
|
||||
:divided="item.divided"
|
||||
:command="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="css" scoped>
|
||||
/** 类型 行颜色 */
|
||||
.hot-table-wrapper :deep(td.node-type-root) {
|
||||
background-color: #F2F2F2;
|
||||
}
|
||||
.hot-table-wrapper :deep(td.node-type-division) {
|
||||
background-color: #FAF5E2;
|
||||
}
|
||||
.hot-table-wrapper :deep(td.node-type-boq) {
|
||||
background-color: #D3E2F3;
|
||||
}
|
||||
.hot-table-wrapper :deep(td.node-type-quota) {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
.hot-table-wrapper :deep(td.node-type-work_content) {
|
||||
background-color: #E7EAEE;
|
||||
}
|
||||
.hot-table-wrapper :deep(td.node-type-sync_source) {
|
||||
background-color: #F0FFF4;
|
||||
}
|
||||
|
||||
</style>
|
||||
<style lang="css">
|
||||
/* 禁止单元格内容换行 */
|
||||
.hot-table-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 禁止单元格内容换行 */
|
||||
.handsontable td {
|
||||
white-space: nowrap !important;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* 自定义滚动条样式 */
|
||||
/* .ht_master .wtHolder::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
background-color: #f1f1f1;
|
||||
}
|
||||
|
||||
.ht_master .wtHolder::-webkit-scrollbar-thumb {
|
||||
background-color: #888;
|
||||
border-radius: 6px;
|
||||
border: 3px solid #ffffff;
|
||||
}
|
||||
|
||||
.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: 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; }
|
||||
.ht-dropdown-menu { position: absolute; background: #fff; border: 1px solid var(--el-border-color-light); border-radius: 8px; box-shadow: 0 10px 20px rgba(0,0,0,0.1); min-width: 160px; max-height:300px; overflow: auto; z-index: 10000; }
|
||||
.ht-dropdown-menu.is-bottom { margin-top: 4px; }
|
||||
.ht-dropdown-menu.is-top { margin-bottom: 4px; }
|
||||
.ht-dropdown-item { padding: 8px 12px; cursor: pointer; font-size: 13px; }
|
||||
.ht-dropdown-item:hover { background-color: #f5f7fa; }
|
||||
.ht-dropdown-item.is-selected { background-color: #eef3ff; }
|
||||
.ht-dropdown-item.is-disabled { color: #c0c4cc; cursor: not-allowed; }
|
||||
/* tree */
|
||||
.handsontable .text-relative span.ht_nestingLevel_empty{
|
||||
.handsontable .wtHolder {
|
||||
/**为了解决 滚动条表头 */
|
||||
/* padding-right: 10px !important; */
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 5px;
|
||||
height: 1px;
|
||||
order: -2;
|
||||
}
|
||||
|
||||
/* tree */
|
||||
.handsontable .text-relative span.ht_nestingLevel_empty {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 5px;
|
||||
height: 1px;
|
||||
order: -2;
|
||||
}
|
||||
|
||||
.handsontable .text-relative span:last-child {
|
||||
padding-left: calc(var(--ht-icon-size) + 5px);
|
||||
padding-left: calc(var(--ht-icon-size) + 5px);
|
||||
}
|
||||
|
||||
/* table */
|
||||
|
||||
/* 自定义下拉渲染样式 */
|
||||
.hot-cell-dropdown { display: flex; align-items: center; gap: 6px; padding: 0 6px; }
|
||||
.hot-dropdown-display { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.hot-dropdown-text { font-size: 12px; color: #1f2328; }
|
||||
.hot-dropdown-placeholder { font-size: 12px; color: #a0a0a0; }
|
||||
|
||||
.hot-dropdown-trigger {
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 0;
|
||||
}
|
||||
.hot-dropdown-trigger::after {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cpath d='m21 21-4.35-4.35'%3E%3C/path%3E%3C/svg%3E");
|
||||
background-color: currentColor;
|
||||
}
|
||||
.hot-dropdown-trigger::after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.hot-dropdown { position: fixed; z-index: 10000; background: #fff; border: 1px solid #e5e7eb; box-shadow: 0 8px 24px rgba(0,0,0,0.12); border-radius: 6px; max-height: 260px; overflow: hidden; will-change: top, left; display: flex; flex-direction: column; }
|
||||
.hot-dropdown--up { border-top-left-radius: 6px; border-top-right-radius: 6px; }
|
||||
.hot-dropdown--down { border-bottom-left-radius: 6px; border-bottom-right-radius: 6px; }
|
||||
.hot-dropdown-search { padding: 0px; border-bottom: 1px solid #e5e7eb; background: #fff; position: sticky; top: 0; z-index: 1; }
|
||||
.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; 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; }
|
||||
|
||||
|
||||
/* 整行高亮样式 */
|
||||
.row-highlight {
|
||||
background-color: #e9ecfc !important; /* 浅蓝色背景 */
|
||||
background-color: #e9ecfc !important;
|
||||
/* 浅蓝色背景 */
|
||||
}
|
||||
|
||||
/* 确保 Handsontable 右键菜单在 ElDialog 之上 - 必须是全局样式 */
|
||||
@@ -410,10 +431,12 @@ Handsontable.renderers.registerRenderer("db-duplicate", handlerDuplicateCodeRend
|
||||
.handsontable.htFiltersConditionsMenu:not(.htGhostTable) {
|
||||
z-index: 9999 !important;
|
||||
}
|
||||
|
||||
.hot-category-dropdown__item--active {
|
||||
background-color: #ecf5ff;
|
||||
}
|
||||
.ht-id-cell {
|
||||
position: relative !important;
|
||||
z-index: 3 !important;
|
||||
/* z-index: 3 !important; */
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
@@ -425,7 +448,7 @@ Handsontable.renderers.registerRenderer("db-duplicate", handlerDuplicateCodeRend
|
||||
height: 14px;
|
||||
display: none;
|
||||
cursor: pointer;
|
||||
z-index: 4;
|
||||
/* z-index: 4; */
|
||||
}
|
||||
|
||||
.ht-id-cell.current .ht-id-icon,
|
||||
@@ -439,6 +462,7 @@ Handsontable.renderers.registerRenderer("db-duplicate", handlerDuplicateCodeRend
|
||||
--ht-tree-line-width: 1px;
|
||||
--ht-tree-indent: 14px;
|
||||
}
|
||||
|
||||
/** 新树形连接线 */
|
||||
.handsontable .ht_treeCell {
|
||||
display: flex;
|
||||
@@ -473,5 +497,164 @@ Handsontable.renderers.registerRenderer("db-duplicate", handlerDuplicateCodeRend
|
||||
.handsontable td.ht_rowHighlight:not(.current):not(.area) {
|
||||
background-color: #e9ecfc;
|
||||
}
|
||||
/* nestedRows 样式 */
|
||||
.handsontable td.ht_nestingLevels {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.handsontable td.ht_nestingLevels > .relative {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: var(--ht-cell-vertical-padding) var(--ht-cell-horizontal-padding);
|
||||
}
|
||||
|
||||
/* 树形连线基础样式 */
|
||||
.handsontable td.ht_nestingLevels span.ht_nestingLevel_empty {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 垂直延续线 - 祖先节点还有后续兄弟节点 */
|
||||
.handsontable td.ht_nestingLevels span.ht_nestingLevel_empty.ht_nestingLevel_line::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background-color: var(--ht-border-color);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
/* 水平连接线 - 当前节点的层级 */
|
||||
.handsontable td.ht_nestingLevels span.ht_nestingLevel_empty.ht_nestingLevel_connector::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 10px;
|
||||
height: 1px;
|
||||
background-color: var(--ht-border-color);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
/* 拐角线 - 最后一个子节点,同时有垂直和水平线 */
|
||||
.handsontable td.ht_nestingLevels span.ht_nestingLevel_empty.ht_nestingLevel_corner::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
width: 1px;
|
||||
height: 50%;
|
||||
background-color: var(--ht-border-color);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.handsontable td.ht_nestingLevels span.ht_nestingLevel_empty.ht_nestingLevel_corner::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 10px;
|
||||
height: 1px;
|
||||
background-color: var(--ht-border-color);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
/* 中间子节点 - 有完整垂直线和水平连接 */
|
||||
.handsontable td.ht_nestingLevels span.ht_nestingLevel_empty.ht_nestingLevel_middle::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background-color: var(--ht-border-color);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.handsontable td.ht_nestingLevels span.ht_nestingLevel_empty.ht_nestingLevel_middle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 10px;
|
||||
height: 1px;
|
||||
background-color: var(--ht-border-color);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.handsontable td.ht_nestingLevels .hot-nesting-button--spacer {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* codeRenderer 高亮样式:只在文字区域显示背景色 */
|
||||
.handsontable td.ht_codeHighlight:not(.row-highlight):not(.current):not(.area) {
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
transparent var(--ht-highlight-offset, 0px),
|
||||
var(--ht-highlight-color, #ffeb3b) var(--ht-highlight-offset, 0px)
|
||||
);
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
/** 计算基数图标 */
|
||||
.handsontable .htCalculate {
|
||||
position: relative;
|
||||
cursor: default;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 0;
|
||||
float: right;
|
||||
top: calc((var(--ht-line-height) - var(--ht-icon-size)) / 3);
|
||||
margin-left: calc(var(--ht-gap-size)* 2);
|
||||
margin-right: 1px;
|
||||
}
|
||||
.handsontable .htCalculate::after {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url('data:image/svg+xml;base64,PHN2ZyB0PSIxNzY2MjAwNTkzNTAwIiBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHAtaWQ9IjgxMzAiIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0iTTI1MS4yIDM4N0gzMjB2NjguOGMwIDEuOCAxLjggMy4yIDQgMy4yaDQ4YzIuMiAwIDQtMS40IDQtMy4zVjM4N2g2OC44YzEuOCAwIDMuMi0xLjggMy4yLTR2LTQ4YzAtMi4yLTEuNC00LTMuMy00SDM3NnYtNjguOGMwLTEuOC0xLjgtMy4yLTQtMy4yaC00OGMtMi4yIDAtNCAxLjQtNCAzLjJWMzMxaC02OC44Yy0xLjggMC0zLjIgMS44LTMuMiA0djQ4YzAgMi4yIDEuNCA0IDMuMiA0eiBtMzI4IDBoMTkzLjZjMS44IDAgMy4yLTEuOCAzLjItNHYtNDhjMC0yLjItMS40LTQtMy4zLTRINTc5LjJjLTEuOCAwLTMuMiAxLjgtMy4yIDR2NDhjMCAyLjIgMS40IDQgMy4yIDR6IG0wIDI2NWgxOTMuNmMxLjggMCAzLjItMS44IDMuMi00di00OGMwLTIuMi0xLjQtNC0zLjMtNEg1NzkuMmMtMS44IDAtMy4yIDEuOC0zLjIgNHY0OGMwIDIuMiAxLjQgNCAzLjIgNHogbTAgMTA0aDE5My42YzEuOCAwIDMuMi0xLjggMy4yLTR2LTQ4YzAtMi4yLTEuNC00LTMuMy00SDU3OS4yYy0xLjggMC0zLjIgMS44LTMuMiA0djQ4YzAgMi4yIDEuNCA0IDMuMiA0eiBtLTE5NS43LTgxbDYxLjItNzQuOWM0LjMtNS4yIDAuNy0xMy4xLTUuOS0xMy4xSDM4OGMtMi4zIDAtNC41IDEtNS45IDIuOWwtMzQgNDEuNi0zNC00MS42Yy0xLjUtMS44LTMuNy0yLjktNS45LTIuOWgtNTAuOWMtNi42IDAtMTAuMiA3LjktNS45IDEzLjFsNjEuMiA3NC45LTYyLjcgNzYuOGMtNC40IDUuMi0wLjggMTMuMSA1LjggMTMuMWg1MC44YzIuMyAwIDQuNS0xIDUuOS0yLjlsMzUuNS00My41IDM1LjUgNDMuNWMxLjUgMS44IDMuNyAyLjkgNS45IDIuOWg1MC44YzYuNiAwIDEwLjItNy45IDUuOS0xMy4xTDM4My41IDY3NXoiIHAtaWQ9IjgxMzEiPjwvcGF0aD48cGF0aCBkPSJNODgwIDExMkgxNDRjLTE3LjcgMC0zMiAxNC4zLTMyIDMydjczNmMwIDE3LjcgMTQuMyAzMiAzMiAzMmg3MzZjMTcuNyAwIDMyLTE0LjMgMzItMzJWMTQ0YzAtMTcuNy0xNC4zLTMyLTMyLTMyeiBtLTM2IDczMkgxODBWMTgwaDY2NHY2NjR6IiBwLWlkPSI4MTMyIj48L3BhdGg+PC9zdmc+');
|
||||
background-color: black;
|
||||
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/** 三条线图标 */
|
||||
.handsontable .htMenuLine {
|
||||
position: relative;
|
||||
cursor: default;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 0;
|
||||
float: right;
|
||||
top: calc((var(--ht-line-height) - var(--ht-icon-size)) / 3);
|
||||
margin-left: calc(var(--ht-gap-size)* 2);
|
||||
margin-right: 1px;
|
||||
}
|
||||
.handsontable .htMenuLine::after {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNCIgaGVpZ2h0PSIxNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxsaW5lIHgxPSI0IiB5MT0iNiIgeDI9IjIwIiB5Mj0iNiI+PC9saW5lPjxsaW5lIHgxPSI0IiB5MT0iMTIiIHgyPSIyMCIgeTI9IjEyIj48L2xpbmU+PGxpbmUgeDE9IjQiIHkxPSIxOCIgeDI9IjIwIiB5Mj0iMTgiPjwvbGluZT48L3N2Zz4=');
|
||||
background-color: black;
|
||||
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
interface Album {
|
||||
code?: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
label: string;
|
||||
level?: string;
|
||||
__children?: Album[];
|
||||
}
|
||||
|
||||
interface MusicAward {
|
||||
code?: string;
|
||||
category: string;
|
||||
artist?: string | null;
|
||||
title?: string | null;
|
||||
label?: string | null;
|
||||
level?: string;
|
||||
__children: Album[];
|
||||
}
|
||||
|
||||
// 音乐类型
|
||||
const categories = [
|
||||
'Best Rock Performance',
|
||||
'Best Metal Performance',
|
||||
'Best Pop Performance',
|
||||
'Best Jazz Performance',
|
||||
'Best Classical Performance',
|
||||
'Best Electronic Performance',
|
||||
'Best Hip Hop Performance',
|
||||
'Best Country Performance',
|
||||
'Best R&B Performance',
|
||||
'Best Alternative Performance'
|
||||
]
|
||||
|
||||
// 艺术家名字
|
||||
const artists = [
|
||||
'Alabama Shakes', 'Florence & The Machine', 'Foo Fighters', 'Elle King', 'Wolf Alice',
|
||||
'Ghost', 'August Burns Red', 'Lamb Of God', 'Sevendust', 'Slipknot',
|
||||
'Muse', 'James Bay', 'Death Cab For Cutie', 'Highly Suspect', 'Arctic Monkeys',
|
||||
'The Killers', 'Imagine Dragons', 'Twenty One Pilots', 'Panic! At The Disco', 'Fall Out Boy',
|
||||
'Paramore', 'Green Day', 'Blink-182', 'My Chemical Romance', 'Linkin Park',
|
||||
'Coldplay', 'Radiohead', 'The Strokes', 'Kings of Leon', 'The Black Keys'
|
||||
]
|
||||
|
||||
// 歌曲标题
|
||||
const titles = [
|
||||
"Don't Wanna Fight", 'What Kind Of Man', 'Something From Nothing', "Ex's & Oh's", 'Moaning Lisa Smile',
|
||||
'Cirice', 'Identity', '512', 'Thank You', 'Custer',
|
||||
'Drones', 'Chaos And The Calm', 'Kintsugi', 'Mister Asylum', 'The Gray Chapter',
|
||||
'Believer', 'Thunder', 'Radioactive', 'Demons', 'Warriors',
|
||||
'High Hopes', 'Hey Look Ma I Made It', 'Victorious', 'King of the Clouds', 'Roaring 20s',
|
||||
'Misery Business', 'Still Into You', 'Ain\'t It Fun', 'Hard Times', 'Rose-Colored Boy'
|
||||
]
|
||||
|
||||
// 唱片公司
|
||||
const labels = [
|
||||
'ATO Records', 'Republic', 'RCA Records', 'Warner Bros. Records', 'Atlantic',
|
||||
'Loma Vista Recordings', 'Fearless Records', 'Epic Records', '7Bros Records', 'Roadrunner Records',
|
||||
'300 Entertainment', 'Columbia Records', 'Interscope Records', 'Capitol Records', 'Universal Music',
|
||||
'Sony Music', 'EMI Records', 'Def Jam', 'Island Records', 'Elektra Records'
|
||||
]
|
||||
|
||||
// 生成随机数据
|
||||
const generateMockData = (count: number): MusicAward[] => {
|
||||
const data: MusicAward[] = []
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const categoryIndex = i % categories.length
|
||||
const childrenCount = Math.floor(Math.random() * 8) + 3 // 3-10个子项
|
||||
|
||||
const children: Album[] = []
|
||||
for (let j = 0; j < childrenCount; j++) {
|
||||
children.push({
|
||||
code: `${String.fromCharCode(65 + categoryIndex)}${String(i + 1).padStart(3, '0')}-${j + 1}`,
|
||||
title: titles[Math.floor(Math.random() * titles.length)],
|
||||
artist: artists[Math.floor(Math.random() * artists.length)],
|
||||
label: labels[Math.floor(Math.random() * labels.length)],
|
||||
level: `${i}-${j + 1}`,
|
||||
})
|
||||
}
|
||||
|
||||
data.push({
|
||||
code: `${String.fromCharCode(65 + categoryIndex)}${String(i + 1).padStart(3, '0')}`,
|
||||
category: categories[categoryIndex],
|
||||
artist: null,
|
||||
title: null,
|
||||
label: null,
|
||||
level: String(i),
|
||||
__children: children,
|
||||
})
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
export function sourceData(row: number){return generateMockData(row ?? 10)}
|
||||
export const sourceDataObject = generateMockData(10)
|
||||
@@ -1,490 +1,269 @@
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
export type TreeNode = Record<string, unknown> & {
|
||||
__id?: string
|
||||
__children?: TreeNode[]
|
||||
level?: string | null
|
||||
// console.log('NestedRows')
|
||||
import Handsontable from 'handsontable'
|
||||
import { NestedRows } from 'handsontable/plugins'
|
||||
// 编辑后刷新列宽
|
||||
const refreshColumnSize = (instance: Handsontable.Core, col: number) => {
|
||||
const manualColumnResize = instance.getPlugin?.('manualColumnResize')
|
||||
if (!manualColumnResize?.setManualSize) return
|
||||
const baseWidth = instance.getColWidth?.(col) ?? 0
|
||||
const hookedWidth = instance.runHooks?.('beforeColumnResize', baseWidth, col, true)
|
||||
const newWidth = typeof hookedWidth === 'number' ? hookedWidth : baseWidth
|
||||
|
||||
manualColumnResize.setManualSize(col, newWidth)
|
||||
manualColumnResize.saveManualColumnWidths?.()
|
||||
instance.runHooks?.('afterColumnResize', newWidth, col, true)
|
||||
instance.view?.adjustElementsSize?.()
|
||||
instance.render?.()
|
||||
}
|
||||
|
||||
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
|
||||
class CodeEditor extends Handsontable.editors.TextEditor {
|
||||
override close(): void {
|
||||
super.close()
|
||||
// console.log(this.state)
|
||||
if (this.state === 'STATE_FINISHED') {
|
||||
// refreshColumnSize(this.hot, this.col)
|
||||
}
|
||||
const id = String(nextNodeId++)
|
||||
node.__id = id
|
||||
return id
|
||||
}
|
||||
}
|
||||
const codeRenderer: Handsontable.renderers.TextRenderer = (
|
||||
instance: Handsontable.Core,
|
||||
td: HTMLTableCellElement,
|
||||
row: number,
|
||||
col: number,
|
||||
prop: string,
|
||||
value: string,
|
||||
cellProperties: Handsontable.CellProperties
|
||||
) => {
|
||||
Handsontable.renderers.TextRenderer(instance, td, row, col, prop, value, cellProperties)
|
||||
|
||||
const nestedRowsPlugin: NestedRows = instance.getPlugin('nestedRows')
|
||||
const physicalRow = instance.toPhysicalRow(row)
|
||||
const dataManager = nestedRowsPlugin?.dataManager
|
||||
|
||||
if (!dataManager || physicalRow === null) {
|
||||
return td
|
||||
}
|
||||
|
||||
const normalizeNode = (node: TreeNode): TreeNode => {
|
||||
getNodeId(node)
|
||||
if (!Array.isArray(node.__children)) node.__children = []
|
||||
return node
|
||||
const rowLevel = dataManager.getRowLevel(physicalRow) ?? 0
|
||||
const isParentRow = dataManager.isParent(physicalRow)
|
||||
const collapsingUI = nestedRowsPlugin?.collapsingUI
|
||||
const isCollapsed = isParentRow && collapsingUI ? collapsingUI.areChildrenCollapsed(physicalRow) : false
|
||||
const dataObject = dataManager.getDataObject(physicalRow)
|
||||
|
||||
td.innerHTML = ''
|
||||
td.classList.add('ht_nestingLevels')
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.className = 'relative'
|
||||
|
||||
// 一次性构建层级信息并创建元素
|
||||
const levelIsLast: boolean[] = new Array(rowLevel)
|
||||
let node = dataObject
|
||||
|
||||
// 从内向外遍历,填充 isLast 信息
|
||||
for (let i = rowLevel - 1; i >= 0; i--) {
|
||||
const parent = dataManager.getRowParent(node)
|
||||
levelIsLast[i] = !parent?.__children ||
|
||||
dataManager.getRowIndexWithinParent(node) === parent.__children.length - 1
|
||||
node = parent
|
||||
}
|
||||
|
||||
for (let i = 0; i < rowLevel; i++) {
|
||||
const isLast = levelIsLast[i]
|
||||
const isCurrent = i === rowLevel - 1
|
||||
|
||||
// 直接构建完整类名,避免多次 DOM 操作
|
||||
const cssClass = isCurrent
|
||||
? isLast ? 'ht_nestingLevel_empty ht_nestingLevel_corner' // └
|
||||
: 'ht_nestingLevel_empty ht_nestingLevel_middle' // ├
|
||||
: !isLast ? 'ht_nestingLevel_empty ht_nestingLevel_line' // │
|
||||
: 'ht_nestingLevel_empty' // 空白
|
||||
|
||||
const levelIndicator = document.createElement('span')
|
||||
levelIndicator.className = cssClass
|
||||
container.appendChild(levelIndicator)
|
||||
}
|
||||
// console.log(value,rowLevel, isParentRow, collapsingUI)
|
||||
|
||||
const buildFlatTreeIndex = (root: TreeNode[], collapsedIds: Set<string>): FlatTreeIndex => {
|
||||
const rows: TreeNode[] = []
|
||||
const metaByRow: FlatRowMeta[] = []
|
||||
if (isParentRow && collapsingUI) {
|
||||
const toggleButton = document.createElement('div')
|
||||
toggleButton.className = `ht_nestingButton ${isCollapsed ? 'ht_nestingExpand' : 'ht_nestingCollapse'}`
|
||||
toggleButton.tabIndex = -1
|
||||
toggleButton.setAttribute('role', 'button')
|
||||
toggleButton.setAttribute('aria-label', isCollapsed ? 'Expand row' : 'Collapse row')
|
||||
|
||||
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)
|
||||
toggleButton.addEventListener(
|
||||
'mousedown',
|
||||
(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
},
|
||||
{ capture: true }
|
||||
)
|
||||
|
||||
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}`
|
||||
toggleButton.addEventListener(
|
||||
'click',
|
||||
(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
;(event as MouseEvent).stopImmediatePropagation?.()
|
||||
|
||||
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)
|
||||
if (collapsingUI.areChildrenCollapsed(physicalRow)) {
|
||||
collapsingUI.expandChildren(physicalRow)
|
||||
} else {
|
||||
collapsingUI.collapseChildren(physicalRow)
|
||||
}
|
||||
}
|
||||
}
|
||||
// refreshColumnSize(instance, col)
|
||||
},
|
||||
{ capture: true }
|
||||
)
|
||||
|
||||
visit(root, 0, '', '')
|
||||
return { root, rows, metaByRow }
|
||||
container.appendChild(toggleButton)
|
||||
} else {
|
||||
const spacer = document.createElement('div')
|
||||
spacer.className = 'ht_nestingButton hot-nesting-button--spacer'
|
||||
spacer.setAttribute('aria-hidden', 'true')
|
||||
container.appendChild(spacer)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
const text = document.createElement('span')
|
||||
text.className = 'htTextEllipsis'
|
||||
text.textContent = value ?? ''
|
||||
|
||||
container.appendChild(text)
|
||||
// 被冒泡了要单独设置contextmenu
|
||||
container.addEventListener('contextmenu', (ev) => {
|
||||
const newEvent = new MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: ev.clientX,
|
||||
clientY: ev.clientY,
|
||||
})
|
||||
return rowSchema
|
||||
}
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
td.dispatchEvent(newEvent)
|
||||
})
|
||||
td.appendChild(container)
|
||||
|
||||
//把最后一次选中的行重新选回来,保持高亮
|
||||
const highlightDeselect = (_this: any, coords: any) => {
|
||||
if (coords !== null) queueMicrotask(() => _this.selectCell(coords?.row, coords?.col))
|
||||
}
|
||||
const showColor = typeof cellProperties.colorClass === 'function'
|
||||
if (showColor) {
|
||||
// 基于 CSS 结构计算偏移量,避免 getBoundingClientRect() 触发强制布局回流
|
||||
// CSS 常量: levelWidth=12px, gap=5px, buttonSize=16px, cellPadding≈4px
|
||||
// 公式: padding + rowLevel * (levelWidth + gap) + buttonSize + gap - 5
|
||||
const relativeLeft = 20 + rowLevel * 17
|
||||
|
||||
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
|
||||
const highlightColor = cellProperties.colorClass(dataObject)
|
||||
const hasHighlight = Boolean(highlightColor)
|
||||
td.classList.toggle('ht_codeHighlight', hasHighlight)
|
||||
if (hasHighlight) {
|
||||
td.style.setProperty(
|
||||
'--ht-highlight-offset',
|
||||
`${relativeLeft}px`
|
||||
)
|
||||
td.style.setProperty('--ht-highlight-color', highlightColor)
|
||||
} else {
|
||||
td.style.removeProperty('--ht-highlight-offset')
|
||||
td.style.removeProperty('--ht-highlight-color')
|
||||
}
|
||||
}
|
||||
|
||||
return td
|
||||
}
|
||||
|
||||
Handsontable.cellTypes.registerCellType('db.nestedRows', {
|
||||
editor: CodeEditor,
|
||||
// editor: Handsontable.editors.TextEditor,
|
||||
renderer: codeRenderer,
|
||||
});
|
||||
|
||||
/**
|
||||
* 修复 nestedRows 插件的 bug
|
||||
* @param parent 父行数据对象
|
||||
* @param index 子行插入的索引位置
|
||||
* @param element 子行数据对象(可选)
|
||||
*/
|
||||
export function initAddChildAtIndex(_dataManager : any){
|
||||
_dataManager.addChildAtIndex = function(parent: any, index: number, element: any) {
|
||||
let childElement = element;
|
||||
let flattenedIndex;
|
||||
|
||||
if (!childElement) {
|
||||
childElement = this.mockNode();
|
||||
}
|
||||
|
||||
this.hot.runHooks('beforeAddChild', parent, childElement, index);
|
||||
|
||||
if (parent) {
|
||||
if (!parent.__children) {
|
||||
parent.__children = [];
|
||||
}
|
||||
|
||||
const safeIndex = Math.max(0, Math.min(index, parent.__children.length));
|
||||
const parentIndex = this.getRowIndex(parent);
|
||||
let insertRowIndex;
|
||||
|
||||
if (safeIndex === parent.__children.length) {
|
||||
if (parent.__children.length === 0) {
|
||||
insertRowIndex = parentIndex + 1;
|
||||
} else {
|
||||
const prevSibling = parent.__children[parent.__children.length - 1];
|
||||
insertRowIndex = this.getRowIndex(prevSibling) + this.countChildren(prevSibling) + 1;
|
||||
}
|
||||
} else {
|
||||
const nextSibling = parent.__children[safeIndex];
|
||||
insertRowIndex = this.getRowIndex(nextSibling);
|
||||
}
|
||||
|
||||
this.hot.runHooks('beforeCreateRow', insertRowIndex, 1);
|
||||
|
||||
parent.__children.splice(safeIndex, 0, childElement);
|
||||
|
||||
this.rewriteCache();
|
||||
|
||||
let upmostParent = parent;
|
||||
let tempParent = upmostParent;
|
||||
|
||||
do {
|
||||
tempParent = this.getRowParent(tempParent);
|
||||
|
||||
if (tempParent !== null) {
|
||||
upmostParent = tempParent;
|
||||
}
|
||||
|
||||
} while (tempParent !== null);
|
||||
|
||||
// 挂起渲染,合并 setSourceDataAtCell + insertIndexes + afterCreateRow 为一次渲染
|
||||
this.hot.suspendRender();
|
||||
|
||||
this.plugin.disableCoreAPIModifiers();
|
||||
|
||||
this.hot.setSourceDataAtCell(
|
||||
this.getRowIndexWithinParent(upmostParent),
|
||||
'__children',
|
||||
upmostParent.__children,
|
||||
'NestedRows.addChildAtIndex'
|
||||
);
|
||||
|
||||
this.hot.rowIndexMapper.insertIndexes(insertRowIndex, 1);
|
||||
|
||||
this.plugin.enableCoreAPIModifiers();
|
||||
|
||||
this.hot.runHooks('afterCreateRow', insertRowIndex, 1);
|
||||
|
||||
this.hot.resumeRender();
|
||||
|
||||
flattenedIndex = insertRowIndex;
|
||||
|
||||
} else {
|
||||
this.plugin.disableCoreAPIModifiers();
|
||||
this.hot.alter('insert_row_above', index, 1, 'NestedRows.addChildAtIndex');
|
||||
this.plugin.enableCoreAPIModifiers();
|
||||
|
||||
flattenedIndex = this.getRowIndex(this.data[index]);
|
||||
}
|
||||
|
||||
// Workaround for refreshing cache losing the reference to the mocked row.
|
||||
childElement = this.getDataObject(flattenedIndex);
|
||||
|
||||
this.hot.runHooks('afterAddChild', parent, childElement, index);
|
||||
};
|
||||
}
|
||||
@@ -37,25 +37,13 @@ const debounce = <T extends (...args: any[]) => void>(func: T, wait: number) =>
|
||||
}, wait)
|
||||
}
|
||||
}
|
||||
|
||||
export const createPopoverCellRenderer = ({ visible, buttonRef }: SelectRenderDeps) => {
|
||||
const openPopover = (container: HTMLElement, virtualEl: HTMLElement) => {
|
||||
buttonRef.value = virtualEl
|
||||
visible.value = true
|
||||
}
|
||||
return (
|
||||
instance: Handsontable,
|
||||
function createPopover(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')){
|
||||
|
||||
cellProperties: any, openPopover: (container: HTMLElement, virtualEl: HTMLElement) => void){
|
||||
td.innerHTML = ''
|
||||
|
||||
const container = document.createElement('div')
|
||||
@@ -158,7 +146,73 @@ export const createPopoverCellRenderer = ({ visible, buttonRef }: SelectRenderDe
|
||||
container.appendChild(input)
|
||||
container.appendChild(searchIcon)
|
||||
td.appendChild(container)
|
||||
}
|
||||
//** for /database/materials/machine */
|
||||
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
|
||||
) => {
|
||||
createPopover(instance,
|
||||
td,
|
||||
row,
|
||||
col,
|
||||
prop,
|
||||
value,
|
||||
cellProperties, openPopover)
|
||||
return td
|
||||
}
|
||||
}
|
||||
/** for database/quota/price */
|
||||
export const createPopoverCellPriceRenderer = ({ 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')){
|
||||
//直接改某个单元格的 meta(可动态切换) :
|
||||
//例如 key='readOnly' 或 key='editor' (配合 hot.render() /生命周期自动重绘)
|
||||
// instance.setCellMeta(row, col, 'editor', true)
|
||||
// instance.render()
|
||||
createPopover(instance,
|
||||
td,
|
||||
row,
|
||||
col,
|
||||
prop,
|
||||
value,
|
||||
cellProperties, openPopover)
|
||||
|
||||
}else{
|
||||
// 子行:设置 quotaQuantity 和 adjustQuantity 为只读
|
||||
// 使用 propToCol 方法将 data 属性转换为列索引
|
||||
const quotaQuantityCol = instance.propToCol('quotaQuantity')
|
||||
const adjustQuantityCol = instance.propToCol('adjustQuantity')
|
||||
if (quotaQuantityCol !== null && quotaQuantityCol !== undefined) {
|
||||
instance.setCellMeta(row, Number(quotaQuantityCol), 'readOnly', true)
|
||||
}
|
||||
if (adjustQuantityCol !== null && adjustQuantityCol !== undefined) {
|
||||
instance.setCellMeta(row, Number(adjustQuantityCol), 'readOnly', true)
|
||||
}
|
||||
td.innerHTML = value || ''
|
||||
}
|
||||
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
let currentDropdownEl: HTMLElement | null = null
|
||||
let currentOnDocClick: ((e: MouseEvent) => void) | null = null
|
||||
let currentAnchor: { instance: any; row: number; col: number; td: HTMLTableCellElement | null } | null = null
|
||||
|
||||
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 getDisplayText 获取显示文本的函数
|
||||
* @returns 格式化后的表格行HTML和数据属性
|
||||
*/
|
||||
export function createTableDataStructure(
|
||||
dataSource: any[],
|
||||
fieldKeys: string[],
|
||||
getDisplayText?: (item: any) => string
|
||||
) {
|
||||
const getLabel = getDisplayText ?? ((x: any) => x?.name ?? '')
|
||||
|
||||
return dataSource.map(item => {
|
||||
// 动态生成单元格 - 支持嵌套属性
|
||||
const cells = fieldKeys.map(key => {
|
||||
const value = getNestedValue(item, key)
|
||||
return `<td>${String(value)}</td>`
|
||||
}).join('')
|
||||
|
||||
// 动态生成 data 属性 - 支持嵌套属性
|
||||
const dataAttrs = fieldKeys
|
||||
.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) ?? '')}" data-item="${itemDataJson}" ${dataAttrs}>${cells}</tr>`,
|
||||
data: item
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getSelectedCoords = (inst: any): { row: number; col: number } | null => {
|
||||
const r = inst?.getSelectedRangeLast?.()
|
||||
if (!r) return null
|
||||
return { row: r.to.row, col: r.to.col }
|
||||
}
|
||||
|
||||
const getTargetCellRect = (inst: any, fallbackTD: HTMLTableCellElement | null): DOMRect | null => {
|
||||
const sel = getSelectedCoords(inst)
|
||||
if (sel) {
|
||||
const td = inst.getCell(sel.row, sel.col, true) as HTMLTableCellElement | null
|
||||
if (td) return td.getBoundingClientRect()
|
||||
}
|
||||
return fallbackTD ? fallbackTD.getBoundingClientRect() : null
|
||||
}
|
||||
|
||||
const positionDropdown = (inst: any) => {
|
||||
if (!currentDropdownEl || !inst) return
|
||||
const containerRect = inst.rootElement?.getBoundingClientRect?.()
|
||||
const cellRect = getTargetCellRect(inst, currentAnchor?.td ?? null)
|
||||
if (!containerRect || !cellRect) return
|
||||
const minWidth = Math.max(cellRect.width, 220)
|
||||
currentDropdownEl.style.minWidth = `${minWidth}px`
|
||||
const ddRect = currentDropdownEl.getBoundingClientRect()
|
||||
const spaceBelow = containerRect.bottom - cellRect.bottom
|
||||
const spaceAbove = cellRect.top - containerRect.top
|
||||
const openUp = spaceBelow < ddRect.height && spaceAbove > spaceBelow
|
||||
currentDropdownEl.classList.remove('hot-dropdown--up', 'hot-dropdown--down')
|
||||
currentDropdownEl.classList.add(openUp ? 'hot-dropdown--up' : 'hot-dropdown--down')
|
||||
const maxH = Math.max(0, Math.min(260, (openUp ? spaceAbove : spaceBelow)))
|
||||
currentDropdownEl.style.maxHeight = `${maxH}px`
|
||||
const dropdownW = Math.max(ddRect.width || minWidth, minWidth)
|
||||
const left = clamp(
|
||||
Math.round(cellRect.left),
|
||||
Math.round(containerRect.left ),
|
||||
Math.round(containerRect.right - dropdownW )
|
||||
)
|
||||
const ddh = Math.min(ddRect.height, maxH)
|
||||
const topCandidate = openUp ? Math.round(cellRect.top - ddh) : Math.round(cellRect.bottom)
|
||||
const top = clamp(
|
||||
topCandidate,
|
||||
Math.round(containerRect.top),
|
||||
Math.round(containerRect.bottom - ddh)
|
||||
)
|
||||
currentDropdownEl.style.left = `${left}px`
|
||||
currentDropdownEl.style.top = `${top}px`
|
||||
}
|
||||
|
||||
|
||||
export function handlerTableRenderer(instance: any, td: HTMLTableCellElement, row: number, column: number, prop: string | number, value: any, cellProperties: any) {
|
||||
td.innerHTML = ''
|
||||
td.classList.add('hot-cell-dropdown')
|
||||
|
||||
const createEl = (tag: string, className?: string, text?: string) => {
|
||||
const el = document.createElement(tag)
|
||||
if (className) el.className = className
|
||||
if (text !== undefined) el.textContent = text
|
||||
return el
|
||||
}
|
||||
|
||||
const display = createEl('div', 'hot-dropdown-display')
|
||||
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 = async () => {
|
||||
const headers: string[] | undefined = cellProperties.customTableHeaders
|
||||
const dropdown = createEl('div', 'hot-dropdown')
|
||||
const getDisplayText = (x: any) => x?.name ?? ''
|
||||
|
||||
// 创建搜索栏
|
||||
const searchContainer = createEl('div', 'hot-dropdown-search')
|
||||
const searchInput = createEl('input', 'hot-dropdown-search-input') as HTMLInputElement
|
||||
searchInput.type = 'text'
|
||||
searchInput.placeholder = '搜索...'
|
||||
searchContainer.appendChild(searchInput)
|
||||
|
||||
const theadHtml = headers && headers.length ? `<thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead>` : ''
|
||||
|
||||
// 使用自定义字段键或默认字段键
|
||||
const fieldKeys = cellProperties.customFieldKeys || []
|
||||
|
||||
// 创建加载提示
|
||||
const tableEl = createEl('div', 'hot-dropdown-table-wrapper')
|
||||
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)
|
||||
|
||||
// 异步加载数据
|
||||
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) => {
|
||||
// 将嵌套路径转换为 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'
|
||||
})
|
||||
})
|
||||
|
||||
// 阻止搜索框点击事件冒泡
|
||||
searchInput.addEventListener('click', (ev) => ev.stopPropagation())
|
||||
|
||||
tbody.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation()
|
||||
const tr = (ev.target as HTMLElement).closest('tr') as HTMLTableRowElement | null
|
||||
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) {
|
||||
document.removeEventListener('click', currentOnDocClick)
|
||||
currentOnDocClick = null
|
||||
}
|
||||
})
|
||||
|
||||
// 自动聚焦搜索框
|
||||
setTimeout(() => searchInput.focus(), 50)
|
||||
|
||||
return dropdown
|
||||
}
|
||||
|
||||
const openDropdown = async () => {
|
||||
if (currentDropdownEl && currentDropdownEl.parentNode) currentDropdownEl.parentNode.removeChild(currentDropdownEl)
|
||||
if (currentOnDocClick) document.removeEventListener('click', currentOnDocClick)
|
||||
|
||||
const dropdown = await buildDropdown()
|
||||
document.body.appendChild(dropdown)
|
||||
currentDropdownEl = dropdown
|
||||
currentAnchor = { instance, row, col: column, td }
|
||||
positionDropdown(instance)
|
||||
currentOnDocClick = (e: MouseEvent) => {
|
||||
if (!dropdown.contains(e.target as Node)) {
|
||||
if (dropdown.parentNode) dropdown.parentNode.removeChild(dropdown)
|
||||
currentDropdownEl = null
|
||||
currentAnchor = null
|
||||
document.removeEventListener('click', currentOnDocClick!)
|
||||
currentOnDocClick = null
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', currentOnDocClick)
|
||||
}
|
||||
|
||||
trigger.addEventListener('click', (e) => { e.stopPropagation(); openDropdown() })
|
||||
display.addEventListener('click', (e) => { e.stopPropagation(); openDropdown() })
|
||||
|
||||
td.appendChild(display)
|
||||
td.appendChild(trigger)
|
||||
return td
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
|
||||
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,250 +0,0 @@
|
||||
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,
|
||||
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 = '#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 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 (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.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)
|
||||
//解决右键行头触发上下文菜单事件
|
||||
textEl.addEventListener('contextmenu', (ev) => {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
const e = new MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
button: 2,
|
||||
clientX: (ev as MouseEvent).clientX,
|
||||
clientY: (ev as MouseEvent).clientY
|
||||
})
|
||||
TD.dispatchEvent(e)
|
||||
})
|
||||
}
|
||||
|
||||
// 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 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[codeDataKey] != null ? String(r[codeDataKey]) : ''
|
||||
return Math.max(m, v.length)
|
||||
}, 0)
|
||||
|
||||
const charWidth = 9
|
||||
const basePadding = 24
|
||||
const nested = hot.getPlugin('nestedRows')
|
||||
const levelCount = nested?.dataManager?.cache?.levelCount ?? 0
|
||||
const rootEl = hot.rootElement as HTMLElement
|
||||
const styles = rootEl ? getComputedStyle(rootEl) : null
|
||||
const iconSizeStr = styles ? styles.getPropertyValue('--ht-icon-size') : ''
|
||||
const iconSize = Number.parseFloat(iconSizeStr) || 16
|
||||
const indicatorWidth = 5
|
||||
const gap = 6
|
||||
const nestedPadding = levelCount * indicatorWidth + iconSize + gap
|
||||
const width = Math.ceil(maxLen * charWidth) + basePadding + nestedPadding
|
||||
return Math.min(Math.max(80, width), 480)
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
if (type === 'delete') {
|
||||
const range = getSelectedVisualRowRange(hot)
|
||||
if (!range) return
|
||||
hot.alter('remove_row', range.startRow, range.amount, 'remove_row_alter')
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,89 +1,53 @@
|
||||
// 验证行
|
||||
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);
|
||||
export const validatorRow = (_this: any, changes: any, columns: any[] = []) => {
|
||||
// const hasRealChange = changes.some((change: any) => {
|
||||
// const [, , oldValue, newValue] = change
|
||||
// return oldValue !== newValue
|
||||
// })
|
||||
const hasRealChange = changes.some((change: any) => {
|
||||
const [, , oldValue, newValue] = change;
|
||||
const isEmpty = (v: any) => v === null || v === undefined || v === '';
|
||||
return oldValue !== newValue && !(isEmpty(oldValue) && isEmpty(newValue));
|
||||
})
|
||||
if (!hasRealChange) {
|
||||
console.log('值未改变,跳过更新', hasRealChange)
|
||||
return false
|
||||
}
|
||||
|
||||
let hasEmptyRequired = false
|
||||
|
||||
if (columns.length > 0) {
|
||||
const row = changes[0][0]
|
||||
const rowData = _this.getSourceDataAtRow(row)
|
||||
|
||||
for (const col of columns) {
|
||||
const colIndex = columns.findIndex((c: any) => c.data === col.data)
|
||||
if (colIndex === -1) continue
|
||||
|
||||
const cellMeta = _this.getCellMeta?.(row, colIndex)
|
||||
|
||||
const metaRequired = cellMeta?.required
|
||||
const isRequired =
|
||||
metaRequired === true || (metaRequired !== false && col.required === true)
|
||||
|
||||
if (!isRequired) continue
|
||||
|
||||
const value = rowData[col.data]
|
||||
const isEmpty = value === null || value === undefined || value === ''
|
||||
if (isEmpty) {
|
||||
hasEmptyRequired = true
|
||||
_this.setCellMeta(row, colIndex, 'valid', false)
|
||||
} else {
|
||||
_this.setCellMeta(row, colIndex, 'valid', true)
|
||||
}
|
||||
// console.log('cellMeta', _this.getCellMeta?.(row, colIndex))
|
||||
}
|
||||
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('存在空单元格,验证失败,不执行后续操作')
|
||||
_this.render()
|
||||
if (hasEmptyRequired) {
|
||||
console.error('必填字段不能为空', hasEmptyRequired)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
9
apps/web-ele/src/components/db-tree/base.ts
Normal file
9
apps/web-ele/src/components/db-tree/base.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// 生成随机6位数字+英文字符串
|
||||
export const generateRandomCode = (): string => {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
let result = ''
|
||||
for (let i = 0; i < 6; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1,418 +1,418 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import type { DropdownInstance, TreeV2Instance } from 'element-plus'
|
||||
import { contextMenuManager } from '../db-hst/contextMenuManager'
|
||||
// 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>
|
||||
type LocateResult<T> = { node: NodeType<T>; parent: NodeType<T> | null; container: NodeType<T>[]; index: number } | null
|
||||
// interface NodeBase<T> { id: string; label: string; children?: T[] }
|
||||
// type NodeType<T> = T & NodeBase<T>
|
||||
// type LocateResult<T> = { node: NodeType<T>; parent: NodeType<T> | null; container: NodeType<T>[]; index: number } | null
|
||||
|
||||
interface MenuItem { key: string; text: string; disabled?: boolean }
|
||||
// interface MenuItem { key: string; text: string; disabled?: boolean }
|
||||
|
||||
interface ContextMenuConfig<T> {
|
||||
dataRef: Ref<NodeType<T>[]>;
|
||||
treeRef: Ref<TreeV2Instance | undefined>;
|
||||
expandedKeysRef: Ref<string[]>;
|
||||
locate: (id: string) => LocateResult<T>;
|
||||
startEdit: (node: NodeType<T>) => void;
|
||||
}
|
||||
// interface ContextMenuConfig<T> {
|
||||
// dataRef: Ref<NodeType<T>[]>;
|
||||
// treeRef: Ref<TreeV2Instance | undefined>;
|
||||
// expandedKeysRef: Ref<string[]>;
|
||||
// locate: (id: string) => LocateResult<T>;
|
||||
// startEdit: (node: NodeType<T>) => void;
|
||||
// }
|
||||
|
||||
interface ContextMenuContext<T> {
|
||||
createNode: (prefix: string) => NodeType<T>;
|
||||
setData: (next: NodeType<T>[]) => void;
|
||||
expandNode: (id: string) => void;
|
||||
setCurrentKey: (id: string) => void;
|
||||
locate: (id: string) => LocateResult<T>;
|
||||
startEdit: (node: NodeType<T>) => void;
|
||||
dataRef: Ref<NodeType<T>[]>;
|
||||
}
|
||||
// interface ContextMenuContext<T> {
|
||||
// createNode: (prefix: string) => NodeType<T>;
|
||||
// setData: (next: NodeType<T>[]) => void;
|
||||
// expandNode: (id: string) => void;
|
||||
// setCurrentKey: (id: string) => void;
|
||||
// locate: (id: string) => LocateResult<T>;
|
||||
// startEdit: (node: NodeType<T>) => void;
|
||||
// dataRef: Ref<NodeType<T>[]>;
|
||||
// }
|
||||
|
||||
interface LevelConfig {
|
||||
depth: number
|
||||
addKey?: string
|
||||
addText?: string
|
||||
allowDelete?: boolean
|
||||
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 LevelConfig {
|
||||
// depth: number
|
||||
// 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[]
|
||||
}
|
||||
// interface HierarchyConfig {
|
||||
// rootKey: string
|
||||
// rootText: string
|
||||
// onRootAdd?: (newNode: any, allRootNodes: any[]) => void | Promise<void>
|
||||
// onRootDelete?: (node: any) => void | Promise<void>
|
||||
// levels: LevelConfig[]
|
||||
// }
|
||||
|
||||
interface ContextMenuHandler<T> {
|
||||
getMenuItems: (node: NodeType<T> | null, ctx: ContextMenuContext<T>) => MenuItem[];
|
||||
execute: (cmd: string, node: NodeType<T> | null, ctx: ContextMenuContext<T>) => Promise<void> | void;
|
||||
}
|
||||
// interface ContextMenuHandler<T> {
|
||||
// getMenuItems: (node: NodeType<T> | null, ctx: ContextMenuContext<T>) => MenuItem[];
|
||||
// execute: (cmd: string, node: NodeType<T> | null, ctx: ContextMenuContext<T>) => Promise<void> | void;
|
||||
// }
|
||||
|
||||
/**
|
||||
* 获取节点深度
|
||||
*/
|
||||
const getDepth = <T>(node: NodeType<T>, ctx: ContextMenuContext<T>): number => {
|
||||
const target = ctx.locate(node.id)
|
||||
if (!target) return -1
|
||||
let depth = 0
|
||||
let p = target.parent
|
||||
while (p) {
|
||||
depth += 1
|
||||
const pt = ctx.locate(p.id)
|
||||
p = pt?.parent ?? null
|
||||
}
|
||||
return depth
|
||||
}
|
||||
// /**
|
||||
// * 获取节点深度
|
||||
// */
|
||||
// const getDepth = <T>(node: NodeType<T>, ctx: ContextMenuContext<T>): number => {
|
||||
// const target = ctx.locate(node.id)
|
||||
// if (!target) return -1
|
||||
// let depth = 0
|
||||
// let p = target.parent
|
||||
// while (p) {
|
||||
// depth += 1
|
||||
// const pt = ctx.locate(p.id)
|
||||
// p = pt?.parent ?? null
|
||||
// }
|
||||
// return depth
|
||||
// }
|
||||
|
||||
/**
|
||||
* 层级化上下文菜单处理器
|
||||
*/
|
||||
class HierarchyContextMenuHandler<T> implements ContextMenuHandler<T> {
|
||||
constructor(private config: HierarchyConfig) {}
|
||||
// /**
|
||||
// * 层级化上下文菜单处理器
|
||||
// */
|
||||
// class HierarchyContextMenuHandler<T> implements ContextMenuHandler<T> {
|
||||
// constructor(private config: HierarchyConfig) {}
|
||||
|
||||
getMenuItems = (node: NodeType<T> | null, ctx: ContextMenuContext<T>): MenuItem[] => {
|
||||
// 空白区域右键 - 添加根节点
|
||||
if (!node) {
|
||||
return [{ key: this.config.rootKey, text: this.config.rootText }]
|
||||
}
|
||||
// getMenuItems = (node: NodeType<T> | null, ctx: ContextMenuContext<T>): MenuItem[] => {
|
||||
// // 空白区域右键 - 添加根节点
|
||||
// if (!node) {
|
||||
// return [{ key: this.config.rootKey, text: this.config.rootText }]
|
||||
// }
|
||||
|
||||
const depth = getDepth(node, ctx)
|
||||
console.log('getMenuItems - 节点:', node, '深度:', depth)
|
||||
const levelConfig = this.config.levels.find(l => l.depth === depth)
|
||||
console.log('找到的 levelConfig:', levelConfig)
|
||||
// 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) {
|
||||
// 未配置的层级,只显示删除
|
||||
return [{ key: 'delete', text: '删除' }]
|
||||
}
|
||||
// if (!levelConfig) {
|
||||
// // 未配置的层级,只显示删除
|
||||
// return [{ key: 'delete', text: '删除' }]
|
||||
// }
|
||||
|
||||
const items: MenuItem[] = []
|
||||
// const items: MenuItem[] = []
|
||||
|
||||
// 添加子级菜单项(检查条件)
|
||||
if (levelConfig.addKey && 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.addKey && 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
// // 添加自定义菜单项
|
||||
// if (levelConfig.customMenuItems) {
|
||||
// for (const customItem of levelConfig.customMenuItems) {
|
||||
// // 如果有条件函数,检查是否满足条件
|
||||
// if (!customItem.condition || customItem.condition(node)) {
|
||||
// items.push({ key: customItem.key, text: customItem.text })
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// 删除菜单项
|
||||
if (levelConfig.allowDelete !== false) {
|
||||
items.push({ key: 'delete', text: '删除' })
|
||||
}
|
||||
// // 删除菜单项
|
||||
// if (levelConfig.allowDelete !== false) {
|
||||
// items.push({ key: 'delete', text: '删除' })
|
||||
// }
|
||||
|
||||
return items
|
||||
}
|
||||
// return items
|
||||
// }
|
||||
|
||||
execute = async (cmd: string, node: NodeType<T> | null, ctx: ContextMenuContext<T>) => {
|
||||
// 添加根节点
|
||||
if (!node && cmd === this.config.rootKey) {
|
||||
const next = ctx.createNode('root')
|
||||
next.label = this.config.rootText.replace('添加', '')
|
||||
// execute = async (cmd: string, node: NodeType<T> | null, ctx: ContextMenuContext<T>) => {
|
||||
// // 添加根节点
|
||||
// if (!node && cmd === this.config.rootKey) {
|
||||
// const next = ctx.createNode('root')
|
||||
// next.label = this.config.rootText.replace('添加', '')
|
||||
|
||||
// 先添加到数据中
|
||||
ctx.setData([...ctx.dataRef.value, next])
|
||||
// // 先添加到数据中
|
||||
// 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
|
||||
}
|
||||
// // 调用根节点添加回调,传入所有根节点数据
|
||||
// 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
|
||||
// }
|
||||
|
||||
if (!node) return
|
||||
// if (!node) return
|
||||
|
||||
// 删除节点
|
||||
if (cmd === 'delete') {
|
||||
const target = ctx.locate(node.id)
|
||||
if (!target) return
|
||||
// // 删除节点
|
||||
// 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)
|
||||
// // 查找当前节点的层级配置
|
||||
// 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)
|
||||
}
|
||||
// // 如果是根节点(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
|
||||
}
|
||||
// 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)
|
||||
// // 查找匹配的层级配置
|
||||
// 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
|
||||
// if (levelConfig) {
|
||||
// const target = ctx.locate(node.id)
|
||||
// if (!target) return
|
||||
|
||||
target.node.children = target.node.children || []
|
||||
// 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
|
||||
})
|
||||
// // 如果配置了排序字段,先对现有子节点排序
|
||||
// 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)
|
||||
// // 计算新节点的排序号(取最大值 + 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
|
||||
// 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)
|
||||
// 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('添加', '') || '新目录'
|
||||
// // 调用添加回调,传递父节点的所有子节点
|
||||
// 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)
|
||||
// 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
|
||||
}
|
||||
// // 调用添加回调,传递父节点的所有子节点
|
||||
// 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
|
||||
// }
|
||||
|
||||
// 处理自定义命令
|
||||
const customLevelConfig = this.config.levels.find(l => l.depth === getDepth(node, ctx))
|
||||
if (customLevelConfig?.onCustomCommand) {
|
||||
await customLevelConfig.onCustomCommand(cmd, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
// // 处理自定义命令
|
||||
// const customLevelConfig = this.config.levels.find(l => l.depth === getDepth(node, ctx))
|
||||
// if (customLevelConfig?.onCustomCommand) {
|
||||
// await customLevelConfig.onCustomCommand(cmd, node)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
/**
|
||||
* 默认上下文菜单处理器
|
||||
*/
|
||||
class DefaultContextMenuHandler<T> implements ContextMenuHandler<T> {
|
||||
getMenuItems = (node: NodeType<T> | null, _ctx: ContextMenuContext<T>): MenuItem[] => {
|
||||
if (!node) return [{ key: 'add-root', text: '添加根目录' }]
|
||||
return [
|
||||
{ key: 'add-sibling', text: '添加目录' },
|
||||
{ key: 'add-child', text: '添加子目录' },
|
||||
{ key: 'rename', text: '重命名' },
|
||||
{ key: 'delete', text: '删除' },
|
||||
]
|
||||
}
|
||||
// /**
|
||||
// * 默认上下文菜单处理器
|
||||
// */
|
||||
// class DefaultContextMenuHandler<T> implements ContextMenuHandler<T> {
|
||||
// getMenuItems = (node: NodeType<T> | null, _ctx: ContextMenuContext<T>): MenuItem[] => {
|
||||
// if (!node) return [{ key: 'add-root', text: '添加根目录' }]
|
||||
// return [
|
||||
// { key: 'add-sibling', text: '添加目录' },
|
||||
// { key: 'add-child', text: '添加子目录' },
|
||||
// { key: 'rename', text: '重命名' },
|
||||
// { key: 'delete', text: '删除' },
|
||||
// ]
|
||||
// }
|
||||
|
||||
execute = async (cmd: string, node: NodeType<T> | null, ctx: ContextMenuContext<T>) => {
|
||||
if (!node && cmd === 'add-root') {
|
||||
ctx.setData([...ctx.dataRef.value, ctx.createNode('node')])
|
||||
return
|
||||
}
|
||||
if (!node) return
|
||||
if (cmd === 'add-sibling') {
|
||||
const target = ctx.locate(node.id)
|
||||
if (!target) return
|
||||
const prefix = target.parent ? target.parent.id : 'node'
|
||||
target.container.push(ctx.createNode(prefix))
|
||||
ctx.setData([...ctx.dataRef.value])
|
||||
return
|
||||
}
|
||||
if (cmd === 'add-child') {
|
||||
const target = ctx.locate(node.id)
|
||||
if (!target) return
|
||||
target.node.children = target.node.children || []
|
||||
target.node.children.push(ctx.createNode(target.node.id))
|
||||
ctx.setData([...ctx.dataRef.value])
|
||||
ctx.expandNode(target.node.id)
|
||||
// ctx.setCurrentKey(target.node.id)
|
||||
return
|
||||
}
|
||||
if (cmd === 'rename') { ctx.startEdit(node); return }
|
||||
if (cmd === 'delete') {
|
||||
const target = ctx.locate(node.id)
|
||||
if (!target) return
|
||||
target.container.splice(target.index, 1)
|
||||
ctx.setData([...ctx.dataRef.value])
|
||||
}
|
||||
}
|
||||
}
|
||||
// execute = async (cmd: string, node: NodeType<T> | null, ctx: ContextMenuContext<T>) => {
|
||||
// if (!node && cmd === 'add-root') {
|
||||
// ctx.setData([...ctx.dataRef.value, ctx.createNode('node')])
|
||||
// return
|
||||
// }
|
||||
// if (!node) return
|
||||
// if (cmd === 'add-sibling') {
|
||||
// const target = ctx.locate(node.id)
|
||||
// if (!target) return
|
||||
// const prefix = target.parent ? target.parent.id : 'node'
|
||||
// target.container.push(ctx.createNode(prefix))
|
||||
// ctx.setData([...ctx.dataRef.value])
|
||||
// return
|
||||
// }
|
||||
// if (cmd === 'add-child') {
|
||||
// const target = ctx.locate(node.id)
|
||||
// if (!target) return
|
||||
// target.node.children = target.node.children || []
|
||||
// target.node.children.push(ctx.createNode(target.node.id))
|
||||
// ctx.setData([...ctx.dataRef.value])
|
||||
// ctx.expandNode(target.node.id)
|
||||
// // ctx.setCurrentKey(target.node.id)
|
||||
// return
|
||||
// }
|
||||
// if (cmd === 'rename') { ctx.startEdit(node); return }
|
||||
// if (cmd === 'delete') {
|
||||
// const target = ctx.locate(node.id)
|
||||
// if (!target) return
|
||||
// target.container.splice(target.index, 1)
|
||||
// ctx.setData([...ctx.dataRef.value])
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
class DbTreeContextMenu<T> {
|
||||
dropdownRef = ref<DropdownInstance>()
|
||||
position = ref({ top: 0, left: 0, bottom: 0, right: 0 } as DOMRect)
|
||||
triggerRef = ref({ getBoundingClientRect: () => this.position.value })
|
||||
currentNode = ref<NodeType<T> | null>(null)
|
||||
// class DbTreeContextMenu<T> {
|
||||
// dropdownRef = ref<DropdownInstance>()
|
||||
// position = ref({ top: 0, left: 0, bottom: 0, right: 0 } as DOMRect)
|
||||
// triggerRef = ref({ getBoundingClientRect: () => this.position.value })
|
||||
// currentNode = ref<NodeType<T> | null>(null)
|
||||
|
||||
private config: ContextMenuConfig<T>
|
||||
private handler: ContextMenuHandler<T>
|
||||
private unregister: (() => void) | null = null
|
||||
// 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>()
|
||||
// constructor(config: ContextMenuConfig<T>, handler?: ContextMenuHandler<T>) {
|
||||
// this.config = config
|
||||
// this.handler = handler ?? new DefaultContextMenuHandler<T>()
|
||||
|
||||
// 注册到全局菜单管理器
|
||||
this.unregister = contextMenuManager.register(this.closeContextMenu)
|
||||
}
|
||||
// // 注册到全局菜单管理器
|
||||
// this.unregister = contextMenuManager.register(this.closeContextMenu)
|
||||
// }
|
||||
|
||||
destroy() {
|
||||
// 取消注册
|
||||
if (this.unregister) {
|
||||
this.unregister()
|
||||
this.unregister = null
|
||||
}
|
||||
}
|
||||
// destroy() {
|
||||
// // 取消注册
|
||||
// if (this.unregister) {
|
||||
// this.unregister()
|
||||
// this.unregister = null
|
||||
// }
|
||||
// }
|
||||
|
||||
private createNode = (prefix: string): NodeType<T> => {
|
||||
const suffix = Math.random().toString(36).slice(2, 8)
|
||||
const id = `${prefix}-${suffix}`
|
||||
return { id, label: '新目录' } as NodeType<T>
|
||||
}
|
||||
// private createNode = (prefix: string): NodeType<T> => {
|
||||
// const suffix = Math.random().toString(36).slice(2, 8)
|
||||
// const id = `${prefix}-${suffix}`
|
||||
// return { id, label: '新目录' } as NodeType<T>
|
||||
// }
|
||||
|
||||
private setData = (next: NodeType<T>[]) => { this.config.dataRef.value = next }
|
||||
private expandNode = (id: string) => {
|
||||
const keys = Array.from(new Set([...this.config.expandedKeysRef.value, id]))
|
||||
this.config.expandedKeysRef.value = keys
|
||||
this.config.treeRef.value?.setExpandedKeys(keys)
|
||||
}
|
||||
private setCurrentKey = (id: string) => { this.config.treeRef.value?.setCurrentKey?.(id) }
|
||||
private ctx = (): ContextMenuContext<T> => ({
|
||||
createNode: this.createNode,
|
||||
setData: this.setData,
|
||||
expandNode: this.expandNode,
|
||||
setCurrentKey: this.setCurrentKey,
|
||||
locate: this.config.locate,
|
||||
startEdit: this.config.startEdit,
|
||||
dataRef: this.config.dataRef,
|
||||
})
|
||||
// private setData = (next: NodeType<T>[]) => { this.config.dataRef.value = next }
|
||||
// private expandNode = (id: string) => {
|
||||
// const keys = Array.from(new Set([...this.config.expandedKeysRef.value, id]))
|
||||
// this.config.expandedKeysRef.value = keys
|
||||
// this.config.treeRef.value?.setExpandedKeys(keys)
|
||||
// }
|
||||
// private setCurrentKey = (id: string) => { this.config.treeRef.value?.setCurrentKey?.(id) }
|
||||
// private ctx = (): ContextMenuContext<T> => ({
|
||||
// createNode: this.createNode,
|
||||
// setData: this.setData,
|
||||
// expandNode: this.expandNode,
|
||||
// setCurrentKey: this.setCurrentKey,
|
||||
// locate: this.config.locate,
|
||||
// startEdit: this.config.startEdit,
|
||||
// dataRef: this.config.dataRef,
|
||||
// })
|
||||
|
||||
getMenuItems = (): MenuItem[] => this.handler.getMenuItems(this.currentNode.value, this.ctx())
|
||||
// getMenuItems = (): MenuItem[] => this.handler.getMenuItems(this.currentNode.value, this.ctx())
|
||||
|
||||
openContextMenu = (event: MouseEvent, nodeData: NodeType<T>) => {
|
||||
// console.log('openContextMenu',nodeData)
|
||||
// 通知管理器即将打开新菜单,关闭其他菜单
|
||||
contextMenuManager.notifyOpening(this.closeContextMenu)
|
||||
// openContextMenu = (event: MouseEvent, nodeData: NodeType<T>) => {
|
||||
// // 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()
|
||||
}
|
||||
// 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) => {
|
||||
// console.log('openBlankContextMenu')
|
||||
// 通知管理器即将打开新菜单,关闭其他菜单
|
||||
contextMenuManager.notifyOpening(this.closeContextMenu)
|
||||
// openBlankContextMenu = (event: MouseEvent) => {
|
||||
// // 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()
|
||||
}
|
||||
// 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()
|
||||
// }
|
||||
|
||||
closeContextMenu = () => { this.dropdownRef.value?.handleClose() }
|
||||
// closeContextMenu = () => { this.dropdownRef.value?.handleClose() }
|
||||
|
||||
onCommand = async (cmd: string) => { await this.handler.execute(cmd, this.currentNode.value, this.ctx()) }
|
||||
// onCommand = async (cmd: string) => { await this.handler.execute(cmd, this.currentNode.value, this.ctx()) }
|
||||
|
||||
onGlobalCommand = (cmd: string) => { void this.handler.execute(cmd, this.currentNode.value, this.ctx()) }
|
||||
}
|
||||
// onGlobalCommand = (cmd: string) => { void this.handler.execute(cmd, this.currentNode.value, this.ctx()) }
|
||||
// }
|
||||
|
||||
/**
|
||||
* 创建层级化的上下文菜单处理器
|
||||
* @param config 层级配置
|
||||
* @returns ContextMenuHandler
|
||||
*/
|
||||
const createHierarchyContextMenuHandler = <T>(config: HierarchyConfig): ContextMenuHandler<T> => {
|
||||
return new HierarchyContextMenuHandler<T>(config)
|
||||
}
|
||||
// /**
|
||||
// * 创建层级化的上下文菜单处理器
|
||||
// * @param config 层级配置
|
||||
// * @returns ContextMenuHandler
|
||||
// */
|
||||
// const createHierarchyContextMenuHandler = <T>(config: HierarchyConfig): ContextMenuHandler<T> => {
|
||||
// return new HierarchyContextMenuHandler<T>(config)
|
||||
// }
|
||||
|
||||
const createContextMenu = <T>(config: ContextMenuConfig<T>, handler?: ContextMenuHandler<T> | HierarchyConfig) => {
|
||||
// 如果传入的是 HierarchyConfig,自动创建 HierarchyContextMenuHandler
|
||||
if (handler && 'rootKey' in handler && 'rootText' in handler && 'levels' in handler) {
|
||||
return new DbTreeContextMenu<T>(config, createHierarchyContextMenuHandler<T>(handler))
|
||||
}
|
||||
return new DbTreeContextMenu<T>(config, handler as ContextMenuHandler<T> | undefined)
|
||||
}
|
||||
// const createContextMenu = <T>(config: ContextMenuConfig<T>, handler?: ContextMenuHandler<T> | HierarchyConfig) => {
|
||||
// // 如果传入的是 HierarchyConfig,自动创建 HierarchyContextMenuHandler
|
||||
// if (handler && 'rootKey' in handler && 'rootText' in handler && 'levels' in handler) {
|
||||
// return new DbTreeContextMenu<T>(config, createHierarchyContextMenuHandler<T>(handler))
|
||||
// }
|
||||
// return new DbTreeContextMenu<T>(config, handler as ContextMenuHandler<T> | undefined)
|
||||
// }
|
||||
|
||||
export { DbTreeContextMenu, HierarchyContextMenuHandler, createHierarchyContextMenuHandler }
|
||||
export type { ContextMenuConfig, ContextMenuHandler, MenuItem, NodeType, LocateResult, HierarchyConfig, LevelConfig }
|
||||
// export { DbTreeContextMenu, HierarchyContextMenuHandler, createHierarchyContextMenuHandler }
|
||||
// export type { ContextMenuConfig, ContextMenuHandler, MenuItem, NodeType, LocateResult, HierarchyConfig, LevelConfig }
|
||||
|
||||
export const useContextMenu = <T>(config: ContextMenuConfig<T>, handler?: ContextMenuHandler<T> | HierarchyConfig) => createContextMenu<T>(config, handler)
|
||||
// export const useContextMenu = <T>(config: ContextMenuConfig<T>, handler?: ContextMenuHandler<T> | HierarchyConfig) => createContextMenu<T>(config, handler)
|
||||
|
||||
@@ -1,102 +1,292 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import type { TreeV2Instance } from 'element-plus'
|
||||
import { ref, shallowRef } from 'vue'
|
||||
|
||||
type NodeType<T> = T & { id: string; label: string; children?: NodeType<T>[] }
|
||||
type LocateResult<T> = { node: NodeType<T>; parent: NodeType<T> | null; container: NodeType<T>[]; index: number } | null
|
||||
export type TreeKey = string | number
|
||||
export type DropType = 'before' | 'after' | 'inner'
|
||||
|
||||
type DropType = 'before' | 'after' | 'inside'
|
||||
type ParentMap<Key extends TreeKey> = Map<Key, Key | null>
|
||||
|
||||
const getDropType = (e: DragEvent): DropType => {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
const rect = el.getBoundingClientRect()
|
||||
const offset = e.clientY - rect.top
|
||||
if (offset < rect.height * 0.25) return 'before'
|
||||
if (offset > rect.height * 0.75) return 'after'
|
||||
return 'inside'
|
||||
const isKey = (value: unknown): value is TreeKey => typeof value === 'string' || typeof value === 'number'
|
||||
|
||||
const getDropType = (event: DragEvent): DropType => {
|
||||
const target = event.currentTarget as HTMLElement | null
|
||||
if (!target) return 'inner'
|
||||
const rect = target.getBoundingClientRect()
|
||||
const y = event.clientY - rect.top
|
||||
const threshold = Math.max(6, rect.height * 0.25)
|
||||
if (y <= threshold) return 'before'
|
||||
if (y >= rect.height - threshold) return 'after'
|
||||
return 'inner'
|
||||
}
|
||||
|
||||
export const useDragAndDrop = <T>(params: {
|
||||
dataRef: Ref<NodeType<T>[]>;
|
||||
treeRef: Ref<TreeV2Instance | undefined>;
|
||||
expandedKeysRef: Ref<string[]>;
|
||||
locate: (id: string) => LocateResult<T>;
|
||||
const getSiblingDropType = (event: DragEvent): Exclude<DropType, 'inner'> => {
|
||||
const target = event.currentTarget as HTMLElement | null
|
||||
if (!target) return 'after'
|
||||
const rect = target.getBoundingClientRect()
|
||||
const y = event.clientY - rect.top
|
||||
return y <= rect.height / 2 ? 'before' : 'after'
|
||||
}
|
||||
|
||||
const buildParentMap = <DataT extends Record<string, any>, Key extends TreeKey>(params: {
|
||||
treeData: DataT[]
|
||||
keyField: string
|
||||
childrenField: string
|
||||
}): ParentMap<Key> => {
|
||||
const parentByKey: ParentMap<Key> = new Map()
|
||||
const { treeData, keyField, childrenField } = params
|
||||
|
||||
const walk = (nodes: DataT[], parentKey: Key | null) => {
|
||||
for (const item of nodes) {
|
||||
if (!item || typeof item !== 'object') continue
|
||||
const key = item[keyField] as unknown
|
||||
if (!isKey(key)) continue
|
||||
parentByKey.set(key as Key, parentKey)
|
||||
const children = item[childrenField] as unknown
|
||||
if (Array.isArray(children) && children.length) walk(children as DataT[], key as Key)
|
||||
}
|
||||
}
|
||||
|
||||
walk(treeData, null)
|
||||
return parentByKey
|
||||
}
|
||||
|
||||
const locate = <DataT extends Record<string, any>, Key extends TreeKey>(params: {
|
||||
treeData: DataT[]
|
||||
keyField: string
|
||||
childrenField: string
|
||||
targetKey: Key
|
||||
}): { container: DataT[]; index: number; item: DataT } | null => {
|
||||
const { treeData, keyField, childrenField, targetKey } = params
|
||||
|
||||
const walk = (nodes: DataT[]): { container: DataT[]; index: number; item: DataT } | null => {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const item = nodes[i]
|
||||
if (!item || typeof item !== 'object') continue
|
||||
const key = item[keyField] as unknown
|
||||
if (key === targetKey) return { container: nodes, index: i, item }
|
||||
const children = item[childrenField] as unknown
|
||||
if (Array.isArray(children) && children.length) {
|
||||
const found = walk(children as DataT[])
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return walk(treeData)
|
||||
}
|
||||
|
||||
const isAncestorOf = <Key extends TreeKey>(parentByKey: ParentMap<Key>, ancestorKey: Key, possibleDescendantKey: Key): boolean => {
|
||||
let current: Key | null | undefined = possibleDescendantKey
|
||||
while (current !== null && current !== undefined) {
|
||||
if (current === ancestorKey) return true
|
||||
current = parentByKey.get(current)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export const useDbTreeDraggable = <
|
||||
NodeT extends Record<string, any>,
|
||||
DataT extends Record<string, any>,
|
||||
Key extends TreeKey = TreeKey,
|
||||
>(params: {
|
||||
getTreeData: () => DataT[]
|
||||
getKeyField: () => string
|
||||
getChildrenField: () => string
|
||||
getKeyFromNode?: (node: NodeT, data: DataT) => Key | null
|
||||
isDraggable: () => boolean
|
||||
allowCrossNodeDrag: () => boolean
|
||||
confirmMove: () => Promise<boolean>
|
||||
onMoved: (payload: {
|
||||
dragData: DataT
|
||||
dropData: DataT
|
||||
dragNode: NodeT
|
||||
dropNode: NodeT
|
||||
dropType: DropType
|
||||
event: DragEvent
|
||||
}) => void
|
||||
}) => {
|
||||
const { dataRef, treeRef, expandedKeysRef, locate } = params
|
||||
const draggingId = ref<string | null>(null)
|
||||
const dropState = ref<{ id: string | null; type: DropType | null }>({ id: null, type: null })
|
||||
const draggingKey = ref<Key | null>(null)
|
||||
const draggingNode = shallowRef<NodeT | null>(null)
|
||||
const draggingData = shallowRef<DataT | null>(null)
|
||||
const dropHint = ref<{ key: Key; type: DropType } | null>(null)
|
||||
const parentByKey = shallowRef<ParentMap<Key>>(new Map())
|
||||
|
||||
const contains = (sourceId: string, targetId: string) => {
|
||||
const src = locate(sourceId)
|
||||
if (!src) return false
|
||||
const stack: NodeType<T>[] = src.node.children ? [...src.node.children] : []
|
||||
while (stack.length) {
|
||||
const n = stack.pop()!
|
||||
if (n.id === targetId) return true
|
||||
if (n.children && n.children.length) stack.push(...n.children)
|
||||
const getKeyFromNode =
|
||||
params.getKeyFromNode ??
|
||||
((node: NodeT, data?: DataT) => {
|
||||
const nodeKey = node?.['key'] as unknown
|
||||
if (isKey(nodeKey)) return nodeKey as Key
|
||||
const key = data?.[params.getKeyField()] as unknown
|
||||
return isKey(key) ? (key as Key) : null
|
||||
})
|
||||
|
||||
const clearDragState = () => {
|
||||
draggingKey.value = null
|
||||
draggingNode.value = null
|
||||
draggingData.value = null
|
||||
dropHint.value = null
|
||||
parentByKey.value = new Map()
|
||||
}
|
||||
|
||||
const isDropHint = (node: NodeT, data: DataT, type: DropType) => {
|
||||
const key = getKeyFromNode(node, data)
|
||||
return Boolean(dropHint.value && dropHint.value.key === key && dropHint.value.type === type)
|
||||
}
|
||||
|
||||
const handleDragStart = (event: DragEvent, node: NodeT, data: DataT) => {
|
||||
if (!params.isDraggable()) return
|
||||
const dragKey = getKeyFromNode(node, data)
|
||||
if (dragKey === null) return
|
||||
|
||||
draggingKey.value = dragKey
|
||||
draggingNode.value = node
|
||||
draggingData.value = data
|
||||
parentByKey.value = buildParentMap<DataT, Key>({
|
||||
treeData: params.getTreeData(),
|
||||
keyField: params.getKeyField(),
|
||||
childrenField: params.getChildrenField(),
|
||||
})
|
||||
|
||||
const transfer = event.dataTransfer
|
||||
if (transfer) {
|
||||
transfer.effectAllowed = 'move'
|
||||
transfer.setData('text/plain', String(dragKey))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const getNodeStyles = (node: NodeType<T>) => {
|
||||
const base: Record<string, string> = { display: 'block', width: '100%'}
|
||||
if (dropState.value.id === node.id && dropState.value.type) {
|
||||
if (dropState.value.type === 'before') base.borderTop = '2px solid #409eff'
|
||||
else if (dropState.value.type === 'after') base.borderBottom = '2px solid #409eff'
|
||||
else base.background = 'rgba(64,158,255,0.15)'
|
||||
const handleDragOver = (event: DragEvent, node: NodeT, data: DataT) => {
|
||||
if (!params.isDraggable()) return
|
||||
const currentDraggingKey = draggingKey.value
|
||||
if (currentDraggingKey === null) return
|
||||
|
||||
const dropKey = getKeyFromNode(node, data)
|
||||
if (dropKey === null) {
|
||||
dropHint.value = null
|
||||
return
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
const onDragStart = (node: NodeType<T>, e: DragEvent) => {
|
||||
draggingId.value = node.id
|
||||
e.dataTransfer?.setData('text/plain', node.id)
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
const onDragOver = (node: NodeType<T>, e: DragEvent) => {
|
||||
if (!draggingId.value) return
|
||||
if (draggingId.value === node.id) { dropState.value = { id: null, type: null }; return }
|
||||
const type = getDropType(e)
|
||||
dropState.value = { id: node.id, type }
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
|
||||
const onDrop = (node: NodeType<T>, e: DragEvent) => {
|
||||
if (!draggingId.value) return
|
||||
const sourceId = draggingId.value
|
||||
const type = getDropType(e)
|
||||
if (sourceId === node.id) { onDragEnd(); return }
|
||||
if (type === 'inside' && contains(sourceId, node.id)) { onDragEnd(); return }
|
||||
const src = locate(sourceId)
|
||||
const tgt = locate(node.id)
|
||||
if (!src || !tgt) { onDragEnd(); return }
|
||||
src.container.splice(src.index, 1)
|
||||
if (type === 'inside') {
|
||||
const nextChildren = tgt.node.children ? [...tgt.node.children] : []
|
||||
nextChildren.push(src.node as NodeType<T>)
|
||||
tgt.node.children = nextChildren
|
||||
expandedKeysRef.value = Array.from(new Set([...expandedKeysRef.value, tgt.node.id]))
|
||||
treeRef.value?.setExpandedKeys(expandedKeysRef.value)
|
||||
treeRef.value?.setCurrentKey?.(src.node.id)
|
||||
} else {
|
||||
let insertIndex = tgt.index
|
||||
if (type === 'after') insertIndex = tgt.index + 1
|
||||
const same = src.container === tgt.container
|
||||
if (same && src.index < tgt.index && type === 'before') insertIndex -= 1
|
||||
if (same && src.index < tgt.index && type === 'after') insertIndex -= 1
|
||||
tgt.container.splice(insertIndex, 0, src.node)
|
||||
treeRef.value?.setCurrentKey?.(src.node.id)
|
||||
if (dropKey === currentDraggingKey) {
|
||||
dropHint.value = null
|
||||
return
|
||||
}
|
||||
dataRef.value = [...dataRef.value]
|
||||
onDragEnd()
|
||||
if (params.allowCrossNodeDrag() && isAncestorOf(parentByKey.value, currentDraggingKey, dropKey)) {
|
||||
dropHint.value = null
|
||||
return
|
||||
}
|
||||
|
||||
let dropType = getDropType(event)
|
||||
if (!params.allowCrossNodeDrag()) {
|
||||
dropType = getSiblingDropType(event)
|
||||
const dragParentKey = parentByKey.value.get(currentDraggingKey)
|
||||
const dropParentKey = parentByKey.value.get(dropKey)
|
||||
if (dragParentKey !== dropParentKey) {
|
||||
dropHint.value = null
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
dropHint.value = { key: dropKey, type: dropType }
|
||||
const transfer = event.dataTransfer
|
||||
if (transfer) transfer.dropEffect = 'move'
|
||||
}
|
||||
|
||||
const onDragEnd = () => {
|
||||
draggingId.value = null
|
||||
dropState.value = { id: null, type: null }
|
||||
const moveNodeByKey = (dragKey: Key, dropKey: Key, dropType: DropType) => {
|
||||
const treeData = params.getTreeData()
|
||||
const keyField = params.getKeyField()
|
||||
const childrenField = params.getChildrenField()
|
||||
|
||||
const dragLoc = locate<DataT, Key>({ treeData, keyField, childrenField, targetKey: dragKey })
|
||||
if (!dragLoc) return false
|
||||
|
||||
const [dragItem] = dragLoc.container.splice(dragLoc.index, 1)
|
||||
if (!dragItem) return false
|
||||
|
||||
if (dropType === 'inner') {
|
||||
const dropLoc = locate<DataT, Key>({ treeData, keyField, childrenField, targetKey: dropKey })
|
||||
if (!dropLoc) {
|
||||
treeData.push(dragItem)
|
||||
return true
|
||||
}
|
||||
const children = dropLoc.item[childrenField] as any
|
||||
if (Array.isArray(children)) {
|
||||
children.push(dragItem)
|
||||
return true
|
||||
}
|
||||
;(dropLoc.item as any)[childrenField] = [dragItem]
|
||||
return true
|
||||
}
|
||||
|
||||
const dropLoc = locate<DataT, Key>({ treeData, keyField, childrenField, targetKey: dropKey })
|
||||
if (!dropLoc) {
|
||||
treeData.push(dragItem)
|
||||
return true
|
||||
}
|
||||
|
||||
const insertIndex = dropType === 'before' ? dropLoc.index : dropLoc.index + 1
|
||||
dropLoc.container.splice(insertIndex, 0, dragItem)
|
||||
return true
|
||||
}
|
||||
|
||||
return { draggingId, dropState, getNodeStyles, onDragStart, onDragOver, onDrop, onDragEnd }
|
||||
const handleDrop = async (event: DragEvent, node: NodeT, data: DataT) => {
|
||||
if (!params.isDraggable()) return
|
||||
|
||||
const currentDraggingKey = draggingKey.value
|
||||
const currentDraggingNode = draggingNode.value
|
||||
const currentDraggingData = draggingData.value
|
||||
if (currentDraggingKey === null || !currentDraggingNode || !currentDraggingData) return
|
||||
|
||||
const dropKey = getKeyFromNode(node, data)
|
||||
if (dropKey === null) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (dropKey === currentDraggingKey) {
|
||||
clearDragState()
|
||||
return
|
||||
}
|
||||
if (params.allowCrossNodeDrag() && isAncestorOf(parentByKey.value, currentDraggingKey, dropKey)) {
|
||||
clearDragState()
|
||||
return
|
||||
}
|
||||
|
||||
let dropType = dropHint.value?.key === dropKey ? dropHint.value.type : getDropType(event)
|
||||
if (!params.allowCrossNodeDrag()) {
|
||||
dropType = getSiblingDropType(event)
|
||||
const dragParentKey = parentByKey.value.get(currentDraggingKey)
|
||||
const dropParentKey = parentByKey.value.get(dropKey)
|
||||
if (dragParentKey !== dropParentKey) {
|
||||
clearDragState()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
clearDragState()
|
||||
const ok = await params.confirmMove().catch(() => false)
|
||||
if (!ok) return
|
||||
|
||||
const moved = moveNodeByKey(currentDraggingKey, dropKey, dropType)
|
||||
if (!moved) return
|
||||
|
||||
params.onMoved({
|
||||
dragData: currentDraggingData,
|
||||
dropData: data,
|
||||
dragNode: currentDraggingNode,
|
||||
dropNode: node,
|
||||
dropType,
|
||||
event,
|
||||
})
|
||||
}
|
||||
|
||||
const handleDragEnd = () => clearDragState()
|
||||
|
||||
return {
|
||||
clearDragState,
|
||||
isDropHint,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDrop,
|
||||
handleDragEnd,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { default as DbTree } from './index.vue';
|
||||
export { default as DbTree } from './index.vue';
|
||||
export type { TreeProps } from './index.vue';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,52 +1,56 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { ref, nextTick } from 'vue'
|
||||
// import type { Ref } from 'vue';
|
||||
// import { nextTick, ref } from 'vue';
|
||||
|
||||
type NodeType<T> = T & { id: string; label: string; children?: T[] }
|
||||
type LocateResult<T> = { node: NodeType<T>; parent: NodeType<T> | null; container: NodeType<T>[]; index: number } | null
|
||||
// type NodeType<T> = T & { id: string; label: string; children?: T[] }
|
||||
// type LocateResult<T> = { node: NodeType<T>; parent: NodeType<T> | null; container: NodeType<T>[]; index: number } | null
|
||||
|
||||
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, onSave } = params
|
||||
const editingId = ref<string | null>(null)
|
||||
const editingLabel = ref('')
|
||||
const editingOriginalLabel = ref('')
|
||||
// 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, onSave } = params
|
||||
// const editingId = ref<string | null>(null)
|
||||
// const editingLabel = ref('')
|
||||
// const editingOriginalLabel = ref('')
|
||||
|
||||
const startEdit = (nodeData: NodeType<T>) => {
|
||||
editingId.value = nodeData.id
|
||||
editingLabel.value = nodeData.label
|
||||
editingOriginalLabel.value = nodeData.label
|
||||
nextTick(() => {
|
||||
const el = document.getElementById(`edit-${nodeData.id}`) as HTMLInputElement | null
|
||||
el?.focus()
|
||||
el?.select()
|
||||
})
|
||||
}
|
||||
// const startEdit = (nodeData: NodeType<T>) => {
|
||||
// editingId.value = nodeData.id
|
||||
// editingLabel.value = nodeData.label
|
||||
// editingOriginalLabel.value = nodeData.label
|
||||
// nextTick(() => {
|
||||
// const el = document.getElementById(`edit-${nodeData.id}`) as HTMLInputElement | null
|
||||
// el?.focus()
|
||||
// el?.select()
|
||||
// })
|
||||
// }
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editingId.value) return
|
||||
const target = locate(editingId.value)
|
||||
if (!target) { editingId.value = null; return }
|
||||
const next = editingLabel.value.trim()
|
||||
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
|
||||
}
|
||||
// const saveEdit = async () => {
|
||||
// if (!editingId.value) return
|
||||
// const target = locate(editingId.value)
|
||||
// if (!target) { editingId.value = null; return }
|
||||
// const next = editingLabel.value.trim()
|
||||
// const oldLabel = editingOriginalLabel.value
|
||||
// if (next && next !== oldLabel) {
|
||||
// target.node.label = next
|
||||
// // 同步更新 name 属性(如果存在),保持数据一致性
|
||||
// if ('name' in target.node) {
|
||||
// (target.node as any).name = next
|
||||
// }
|
||||
// if (onSave) {
|
||||
// await onSave(target.node, oldLabel, next)
|
||||
// }
|
||||
// }
|
||||
// dataRef.value = [...dataRef.value]
|
||||
// editingId.value = null
|
||||
// }
|
||||
|
||||
const cancelEdit = () => {
|
||||
if (!editingId.value) return
|
||||
editingLabel.value = editingOriginalLabel.value
|
||||
editingId.value = null
|
||||
}
|
||||
// const cancelEdit = () => {
|
||||
// if (!editingId.value) return
|
||||
// editingLabel.value = editingOriginalLabel.value
|
||||
// editingId.value = null
|
||||
// }
|
||||
|
||||
return { editingId, editingLabel, editingOriginalLabel, startEdit, saveEdit, cancelEdit }
|
||||
}
|
||||
// return { editingId, editingLabel, editingOriginalLabel, startEdit, saveEdit, cancelEdit }
|
||||
// }
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<span class="element-tree-node-label-wrapper">
|
||||
<slot v-if="$slots.default" :node="node" :data="data" />
|
||||
|
||||
<template v-else>
|
||||
<slot name="node-label" :node="node" :data="data">
|
||||
<span class="element-tree-node-label">{{ String(node.label ?? '') }}</span>
|
||||
</slot>
|
||||
|
||||
<span v-if="showLabelLine" class="element-tree-node-label-line" />
|
||||
|
||||
<slot name="after-node-label" :node="node" :data="data" />
|
||||
</template>
|
||||
|
||||
<template v-if="shouldRenderLines">
|
||||
<span
|
||||
v-for="line in verticalLines"
|
||||
:key="line.index"
|
||||
class="element-tree-node-line-ver"
|
||||
:class="{ 'last-node-isLeaf-line': line.isLastLeafLine }"
|
||||
:style="{ left: line.left }"
|
||||
/>
|
||||
<span class="element-tree-node-line-hor" :style="horizontalStyle" />
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
type TreeNodeLike = {
|
||||
id?: unknown
|
||||
key?: unknown
|
||||
label?: unknown
|
||||
level: number
|
||||
parent?: {
|
||||
children?: TreeNodeLike[]
|
||||
childNodes?: TreeNodeLike[]
|
||||
level?: number
|
||||
key?: unknown
|
||||
parent?: unknown
|
||||
} | null
|
||||
isLeaf?: boolean
|
||||
children?: TreeNodeLike[]
|
||||
childNodes?: TreeNodeLike[]
|
||||
}
|
||||
|
||||
type Props = {
|
||||
node: TreeNodeLike
|
||||
data?: unknown
|
||||
treeData?: unknown[]
|
||||
rootKeyIndexMap?: Map<string | number, number>
|
||||
rootLastIndex?: number
|
||||
indent?: number
|
||||
showLabelLine?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
data: undefined,
|
||||
treeData: undefined,
|
||||
rootKeyIndexMap: undefined,
|
||||
rootLastIndex: undefined,
|
||||
indent: 16,
|
||||
showLabelLine: true,
|
||||
})
|
||||
|
||||
const shouldRenderLines = computed(() => props.node.level > 1)
|
||||
|
||||
const lastNodeFlags = computed(() => {
|
||||
if (!shouldRenderLines.value) return []
|
||||
|
||||
const flags: boolean[] = []
|
||||
let currentNode: TreeNodeLike | null | undefined = props.node
|
||||
|
||||
while (currentNode) {
|
||||
let parentNode = currentNode.parent ?? null
|
||||
|
||||
if (currentNode.level === 1 && !currentNode.parent) {
|
||||
const currentKey = currentNode.key ?? currentNode.id
|
||||
if (
|
||||
(typeof currentKey === 'string' || typeof currentKey === 'number') &&
|
||||
props.rootKeyIndexMap &&
|
||||
typeof props.rootLastIndex === 'number'
|
||||
) {
|
||||
flags.unshift(props.rootKeyIndexMap.get(currentKey) === props.rootLastIndex)
|
||||
break
|
||||
}
|
||||
|
||||
if (!Array.isArray(props.treeData)) throw new Error('TreeNodeLine requires treeData when used with el-tree-v2')
|
||||
const index = props.treeData.findIndex((item) => {
|
||||
if (!item || typeof item !== 'object') return false
|
||||
const record = item as Record<string, unknown>
|
||||
return record.id === currentKey
|
||||
})
|
||||
flags.unshift(index === props.treeData.length - 1)
|
||||
break
|
||||
}
|
||||
|
||||
if (parentNode) {
|
||||
const siblings = (parentNode.children || (parentNode as any).childNodes || []) as any[]
|
||||
const currentKey = (currentNode.key ?? currentNode.id) as unknown
|
||||
const index = siblings.findIndex((item) => (item?.key ?? item?.id) === currentKey)
|
||||
flags.unshift(index === siblings.length - 1)
|
||||
}
|
||||
|
||||
currentNode = parentNode as any
|
||||
}
|
||||
|
||||
return flags
|
||||
})
|
||||
|
||||
const verticalLines = computed(() => {
|
||||
if (!shouldRenderLines.value) return []
|
||||
|
||||
const level = props.node.level
|
||||
const lines: Array<{ index: number; left: string; isLastLeafLine: boolean }> = []
|
||||
// 根节点不渲染垂直线
|
||||
for (let i = 1; i < level; i++) {
|
||||
if (lastNodeFlags.value[i] && level - 1 !== i) continue
|
||||
|
||||
lines.push({
|
||||
index: i,
|
||||
left: `${props.indent * i}px`,
|
||||
isLastLeafLine: Boolean(lastNodeFlags.value[i] && level - 1 === i),
|
||||
})
|
||||
}
|
||||
|
||||
return lines
|
||||
})
|
||||
|
||||
const horizontalStyle = computed(() => ({
|
||||
width: `${props.node.isLeaf ? 24 : 8}px`,
|
||||
left: `${(props.node.level - 1) * props.indent}px`,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.element-tree-node-label-wrapper {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.element-tree-node-label {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.element-tree-node-line-ver {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
border-left: 1px solid #ababab;
|
||||
}
|
||||
|
||||
.element-tree-node-line-ver.last-node-isLeaf-line {
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
.element-tree-node-line-hor {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
height: 0;
|
||||
border-bottom: 1px solid #ababab;
|
||||
}
|
||||
|
||||
.element-tree-node-label-line {
|
||||
flex: 1;
|
||||
border-top: 1px solid #ababab;
|
||||
align-self: center;
|
||||
margin: 0 10px;
|
||||
}
|
||||
</style>
|
||||
1
apps/web-ele/src/components/lazy-load/index.ts
Normal file
1
apps/web-ele/src/components/lazy-load/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as LazyLoad } from './lazy-load.vue';
|
||||
40
apps/web-ele/src/components/lazy-load/lazy-load.vue
Normal file
40
apps/web-ele/src/components/lazy-load/lazy-load.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
|
||||
interface Props {
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
delay: 0,
|
||||
});
|
||||
|
||||
const isVisible = ref(false);
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
//为了解决 Uncaught ResizeObserver loop completed with undelivered notifications.做了延时显示
|
||||
if (props.delay > 0) {
|
||||
timeoutId = setTimeout(() => {
|
||||
isVisible.value = true;
|
||||
}, props.delay);
|
||||
} else {
|
||||
isVisible.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="lazy-load-wrapper h-full w-full">
|
||||
<slot v-if="isVisible" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
</style>
|
||||
Reference in New Issue
Block a user