第二阶段代码

This commit is contained in:
2026-04-23 11:37:37 +08:00
parent 618bb6699e
commit ef64c3b7fb
937 changed files with 118372 additions and 10248 deletions

View File

@@ -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=工料机返回类别IDfee=定额取费(返回对象)
}
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>

View File

@@ -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` 配置与自定义菜单会自动互斥

View File

@@ -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)

View 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)
}
};
}

View 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,
}
}

View 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>

View File

@@ -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>

View File

@@ -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()

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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)

View File

@@ -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);
};
}

View File

@@ -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 || ''
}

View File

@@ -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, '&quot;')
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(/&quot;/g, '"'))
// 获取当前行的完整数据
const currentRowData = instance.getSourceDataAtRow(row)
// 调用自定义回调函数
const callbackFn = cellProperties.customCallbackRow
if (typeof callbackFn === 'function') {
callbackFn(currentRowData, selectedItem, row, column, instance)
} else {
// 默认行为:设置显示文本
const displayValue = tr.dataset.label ?? ''
instance.setDataAtCell(row, column, displayValue)
}
if (dropdown.parentNode) dropdown.parentNode.removeChild(dropdown)
currentDropdownEl = null
if (currentOnDocClick) {
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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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,
}
}

View File

@@ -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
}

View 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
}

View File

@@ -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)

View File

@@ -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,
}
}

View File

@@ -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

View File

@@ -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 }
// }

View File

@@ -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>

View File

@@ -0,0 +1 @@
export { default as LazyLoad } from './lazy-load.vue';

View 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>