front/src/components/HandsontablePro/index.vue
2026-03-23 18:03:05 +08:00

1603 lines
57 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick, h, render } from 'vue';
import { IconSettings } from '@arco-design/web-vue/es/icon';
import { registerAllModules } from 'handsontable/registry';
import { registerLanguageDictionary, zhCN } from 'handsontable/i18n';
import Handsontable from 'handsontable';
// Handsontable styles are imported globally in main.ts
// 注册所有 Handsontable 模块
registerAllModules();
// 注册中文语言包
registerLanguageDictionary(zhCN);
interface ColumnConfig {
data: number;
type?: string;
readOnly?: boolean;
width: number;
className?: string;
headerClassName?: string;
renderer?: any;
}
interface ColumnDef {
key: string;
label: string;
width: number;
searchable?: boolean;
searchField?: string;
multiValue?: boolean;
renderer?: (row: any, value: any) => string;
className?: string;
}
interface Props {
// 列定义
columns: ColumnDef[];
// 数据
data: any[];
// 总数(接受 number 或 string,内部转换为 number)
total?: number | string | null;
// 当前页
currentPage?: number;
// 每页数量
pageSize?: number;
// 搜索参数对象(响应式)
searchParams?: Record<string, any>;
// 列索引到搜索字段的映射
columnToSearchFieldMap?: Record<number, string>;
// 是否显示操作列
showActionColumn?: boolean;
// 操作列位置:'start' | 'end' | 'beforeLast'(在最后一列数据前),默认为 'start'
actionColumnPosition?: 'start' | 'end' | 'beforeLast';
// 操作列宽度
actionColumnWidth?: number;
// 是否显示复选框列
showCheckboxColumn?: boolean;
// 复选框列表头文案(空则表头为空)
checkboxColumnHeader?: string;
// 序号列表头文案
serialNoHeader?: string;
// 操作列表头文案
actionColumnHeader?: string;
// 复选框变化回调
onCheckboxChange?: (row: any, checked: boolean, rowIndex: number) => void;
// 操作列渲染器
actionRenderer?: (row: any, rowIndex: number) => string;
// 操作列点击回调
onActionClick?: (row: any, rowIndex: number, action?: string) => void;
// 搜索回调
onSearch?: () => void;
// 行选择回调
onSelectionChange?: (selectedRows: any[]) => void;
// 自定义单元格渲染器
customCellRenderer?: (row: number, col: number, value: any, rowData: any) => string | null;
// 高度
height?: number;
// 是否只读
readOnly?: boolean;
// 多值字段列表
multiValueFields?: string[];
// 打开多值输入弹窗回调
onOpenMultiValueModal?: (field: string, label: string) => void;
// 列是否拉伸铺满容器宽度
stretchColumns?: boolean;
// 固定行高(像素),设置后禁用 autoRowSize
rowHeight?: number;
// 是否启用“序号表头-设置排序”入口
enableColumnOrderConfig?: boolean;
// 是否启用列拖拽排序
enableColumnReorder?: boolean;
// 外部控制的列顺序(按列 key)
columnOrder?: string[];
// 本地持久化 key(可选)
persistColumnOrderKey?: string;
// 禁止拖拽的列 key
lockedColumnKeys?: string[];
}
const props = withDefaults(defineProps<Props>(), {
total: 0,
currentPage: 1,
pageSize: 300,
searchParams: () => ({}),
columnToSearchFieldMap: () => ({}),
showActionColumn: false,
actionColumnPosition: 'start',
actionColumnWidth: 60,
showCheckboxColumn: false,
checkboxColumnHeader: '',
serialNoHeader: '序号',
actionColumnHeader: '操作',
readOnly: true,
multiValueFields: () => [],
height: undefined,
stretchColumns: false,
rowHeight: undefined,
enableColumnOrderConfig: false,
enableColumnReorder: false,
columnOrder: undefined,
persistColumnOrderKey: '',
lockedColumnKeys: () => [],
});
// 确保 total 始终是数字类型(用于内部使用)
const totalNumber = computed(() => {
const value = props.total;
if (value === null || value === undefined) return 0;
const num = typeof value === 'string' ? Number(value) : value;
return isNaN(num) ? 0 : num;
});
const emit = defineEmits<{
'update:selectedRows': [rows: any[]];
'action-click': [row: any, rowIndex: number, action?: string];
'search': [];
'selection-change': [rows: any[]];
'checkbox-change': [row: any, checked: boolean, rowIndex: number];
'column-order-change': [order: string[]];
'open-column-order-config': [columns: Array<{ key: string; label: string }>];
}>();
const hotContainer = ref<HTMLElement | null>(null);
const containerRef = ref<HTMLElement | null>(null);
let hotInstance: Handsontable | null = null;
const selectedRows = ref<any[]>([]);
const selectedRowIndicesSet = ref<Set<number>>(new Set());
const internalColumnOrder = ref<string[]>([]);
function normalizeColumnOrder(
allColumns: ColumnDef[],
order: string[] | undefined
): string[] {
const validKeys = new Set(allColumns.map((c) => c.key));
const kept = (order || []).filter((k) => validKeys.has(k));
const missing = allColumns.map((c) => c.key).filter((k) => !kept.includes(k));
return [...kept, ...missing];
}
const resolvedColumnOrder = computed(() => {
const base = props.columnOrder && props.columnOrder.length > 0
? props.columnOrder
: internalColumnOrder.value;
return normalizeColumnOrder(props.columns, base);
});
const effectiveColumns = computed<ColumnDef[]>(() => {
const order = resolvedColumnOrder.value;
const map = new Map(props.columns.map((c) => [c.key, c]));
return order.map((k) => map.get(k)).filter(Boolean) as ColumnDef[];
});
const updateColumnOrder = (nextOrder: string[]) => {
internalColumnOrder.value = normalizeColumnOrder(props.columns, nextOrder);
if (props.persistColumnOrderKey) {
localStorage.setItem(props.persistColumnOrderKey, JSON.stringify(internalColumnOrder.value));
}
emit('column-order-change', [...internalColumnOrder.value]);
};
// 计算表格高度
const calculatedHeight = computed(() => {
if (props.height != null && props.height > 0) {
return props.height;
}
const headerHeight = 40;
const rowHeight = 50;
const maxRows = 20;
const minHeight = 400;
return Math.max(headerHeight + maxRows * rowHeight, minHeight);
});
/** 外部传入 height 为 0 时不能用 ?? 回退(0 非 nullish),需显式判断 */
const resolveTableHeight = (containerH: number): number => {
if (props.height != null && props.height > 0) return props.height;
if (containerH > 0) return containerH;
return calculatedHeight.value;
};
// 创建省略号渲染器
const createEllipsisRenderer = () => {
return function(instance: any, td: any, row: number, col: number, prop: any, value: any) {
const text = value || '--';
const textStr = String(text);
td.style.textAlign = 'left';
td.style.verticalAlign = 'middle';
td.style.overflow = 'hidden';
td.style.textOverflow = 'ellipsis';
td.style.whiteSpace = 'nowrap';
td.style.padding = '7px 16px';
td.textContent = textStr;
if (textStr && textStr !== '--') {
td.title = textStr;
td.style.cursor = 'help';
} else {
td.removeAttribute('title');
td.style.cursor = 'default';
}
return td;
};
};
// 初始化 Handsontable
const initHandsontable = () => {
if (!hotContainer.value) {
return;
}
if (hotInstance) {
hotInstance.destroy();
hotInstance = null;
}
const ellipsisRenderer = createEllipsisRenderer();
// 构建列配置
const columns: ColumnConfig[] = [];
const headerTexts: string[] = [];
let currentDataIndex = 0;
// 复选框列
if (props.showCheckboxColumn) {
columns.push({ data: currentDataIndex, type: 'checkbox', readOnly: false, width: 60, className: 'htLeft' });
headerTexts.push(props.checkboxColumnHeader ?? '');
currentDataIndex++;
}
// 操作列(在前面)
if (props.showActionColumn && props.actionColumnPosition === 'start') {
columns.push({ data: currentDataIndex, type: 'text', readOnly: true, width: props.actionColumnWidth, className: 'htCenter', headerClassName: 'htLeft' });
headerTexts.push(props.actionColumnHeader);
currentDataIndex++;
}
// 序号列
columns.push({
data: currentDataIndex,
type: 'text',
readOnly: true,
width: props.enableColumnOrderConfig ? 76 : 60,
className: 'htCenter',
headerClassName: 'htLeft',
renderer: ellipsisRenderer
});
headerTexts.push(props.serialNoHeader);
currentDataIndex++;
// 数据列(beforeLast 时最后一列在操作列之后)
const dataColCount = effectiveColumns.value.length;
effectiveColumns.value.forEach((col, index) => {
// beforeLast:在倒数第二列后插入操作列
if (props.showActionColumn && props.actionColumnPosition === 'beforeLast' && index === dataColCount - 1) {
columns.push({ data: currentDataIndex, type: 'text', readOnly: true, width: props.actionColumnWidth, className: 'htCenter', headerClassName: 'htLeft' });
headerTexts.push(props.actionColumnHeader);
currentDataIndex++;
}
columns.push({
data: currentDataIndex,
type: (col as any).type || 'text',
readOnly: props.readOnly !== false,
width: col.width,
className: col.className || 'htLeft',
renderer: ellipsisRenderer,
});
headerTexts.push(col.label);
currentDataIndex++;
});
// 操作列(在后面,非 beforeLast 时)
if (props.showActionColumn && props.actionColumnPosition === 'end') {
columns.push({ data: currentDataIndex, type: 'text', readOnly: true, width: props.actionColumnWidth, className: 'htCenter', headerClassName: 'htLeft' });
headerTexts.push(props.actionColumnHeader);
currentDataIndex++;
}
// 添加 afterChange 处理复选框变化
const afterChangeHandler = function(changes: any, source: string) {
if (source === 'edit' && changes && props.showCheckboxColumn) {
changes.forEach(([row, col, oldValue, newValue]: any[]) => {
let colOffset = 0;
const checkboxColIndex = props.showCheckboxColumn ? colOffset++ : -1;
if (col === checkboxColIndex) {
const rowData = props.data[row];
if (rowData) {
(rowData as any).checked = newValue || false;
emit('checkbox-change', rowData, newValue || false, row);
}
}
});
}
};
// 准备数据(顺序必须与 columns 的 data 索引一致)
const data = props.data.map((row, rowIndex) => {
const rowData: any[] = [];
// 复选框列
if (props.showCheckboxColumn) {
rowData.push((row as any).checked || false);
}
// 操作列(在前面)
if (props.showActionColumn && props.actionColumnPosition === 'start') {
rowData.push('');
}
// 序号
const serialNo = (props.currentPage - 1) * props.pageSize + rowIndex + 1;
rowData.push(serialNo);
// 数据列(beforeLast 时在倒数第二列后插入操作列)
const dataColCount = effectiveColumns.value.length;
effectiveColumns.value.forEach((col, index) => {
if (props.showActionColumn && props.actionColumnPosition === 'beforeLast' && index === dataColCount - 1) {
rowData.push('');
}
const value = row[col.key] || '--';
rowData.push(value);
});
// 操作列(在后面)
if (props.showActionColumn && props.actionColumnPosition === 'end') {
rowData.push('');
}
return rowData;
});
try {
// 未传 height 时使用容器实际高度,使列表高度与容器一致
const containerH = containerRef.value?.clientHeight ?? 0;
const tableHeight = resolveTableHeight(containerH);
const hotConfig: any = {
data: data,
columns: columns,
colHeaders: headerTexts,
rowHeaders: false,
selectionMode: 'multiple',
width: '100%',
height: tableHeight,
licenseKey: 'non-commercial-and-evaluation',
className: 'ht-theme-classic',
language: 'zh-CN',
readOnly: props.readOnly,
contextMenu: false,
manualColumnResize: true,
manualColumnMove: props.enableColumnReorder,
stretchH: props.stretchColumns ? 'all' : 'none',
...(props.rowHeight != null && props.rowHeight > 0
? { rowHeights: props.rowHeight, autoRowSize: false }
: {}),
automaticLayout: true,
observeChanges: true,
observeDOMVisibility: true,
renderAllRows: false,
viewportColumnRenderingOffset: 'auto',
viewportRowRenderingOffset: 'auto',
// 强制启用滚动条
scrollable: true,
// 确保滚动条可见
scrollbar: {
horizontal: true,
vertical: true
},
filters: { enabled: true },
dropdownMenu: {
items: {
sort_asc: { name: '升序排序' },
sort_desc: { name: '降序排序' },
'---------': {},
filter_by_condition: { name: '按条件过滤' },
filter_by_value: { name: '按值过滤' },
filter_action_bar: {
name: function() {
return '<div class="filter-action-bar">' +
'<button class="filter-ok-btn">确定</button>' +
'<button class="filter-reset-btn">重置</button>' +
'</div>';
},
callback: function(key: string, selection: any[], clickEvent: MouseEvent) {
const target = clickEvent?.target as HTMLElement;
if (target && target.classList.contains('filter-reset-btn')) {
// 重置过滤条件
clickEvent.preventDefault();
clickEvent.stopPropagation();
if (hotInstance) {
const filtersPlugin = (hotInstance as any).getPlugin('filters');
if (filtersPlugin) {
// 尝试从多个来源获取当前列索引
let columnIndex: number | undefined = undefined;
// 方法1: 从 selection 获取
if (selection && selection.length > 0 && selection[0]?.start?.col !== undefined) {
columnIndex = selection[0].start.col;
}
// 方法2: 从下拉菜单的上下文获取
if (columnIndex === undefined) {
const dropdownMenuPlugin = (hotInstance as any).getPlugin('dropdownMenu');
if (dropdownMenuPlugin && dropdownMenuPlugin.menu) {
const menuElement = dropdownMenuPlugin.menu.container;
if (menuElement) {
const thElement = menuElement.closest('th');
if (thElement) {
const headerRow = thElement.parentElement;
if (headerRow) {
const thIndex = Array.from(headerRow.children).indexOf(thElement);
columnIndex = thIndex;
}
}
}
}
}
if (columnIndex !== undefined && columnIndex !== null && columnIndex >= 0) {
// 清除当前列的过滤条件
filtersPlugin.clearConditions(columnIndex);
filtersPlugin.filter();
} else {
// 清除所有过滤条件
filtersPlugin.clearConditions();
filtersPlugin.filter();
}
// 关闭下拉菜单
setTimeout(() => {
const dropdownMenuPlugin = (hotInstance as any).getPlugin('dropdownMenu');
if (dropdownMenuPlugin) {
dropdownMenuPlugin.close();
}
}, 100);
}
}
return false; // 阻止默认行为
}
// 确定按钮使用默认行为
return true;
}
},
},
},
columnSorting: true,
beforeColumnMove: (movedColumns: number[]) => {
if (!props.enableColumnReorder) return false;
// 仅允许移动数据列(复选框/操作/序号列禁止)
let colOffset = 0;
if (props.showCheckboxColumn) colOffset++;
if (props.showActionColumn && props.actionColumnPosition === 'start') colOffset++;
colOffset++; // 序号列
const minDataCol = colOffset;
const maxDataCol = colOffset + effectiveColumns.value.length - 1;
const inRange = movedColumns.every((c) => c >= minDataCol && c <= maxDataCol);
if (!inRange) return false;
if (!props.lockedColumnKeys?.length) return true;
const hasLocked = movedColumns.some((c) => {
const dataIndex = c - colOffset;
const col = effectiveColumns.value[dataIndex];
return col && props.lockedColumnKeys!.includes(col.key);
});
return !hasLocked;
},
afterColumnMove: () => {
if (!props.enableColumnReorder || !hotInstance) return;
let colOffset = 0;
if (props.showCheckboxColumn) colOffset++;
if (props.showActionColumn && props.actionColumnPosition === 'start') colOffset++;
colOffset++; // 序号列
const nextOrder = Array.from({ length: effectiveColumns.value.length }, (_, i) => {
const visual = colOffset + i;
const physical = hotInstance!.toPhysicalColumn(visual);
const dataPhysicalIndex = physical - colOffset;
return effectiveColumns.value[dataPhysicalIndex]?.key;
}).filter(Boolean) as string[];
if (nextOrder.length === effectiveColumns.value.length) {
updateColumnOrder(nextOrder);
}
},
afterChange: afterChangeHandler,
afterGetColHeader: function(col: number, TH: HTMLElement) {
// 计算列偏移
let colOffset = 0;
const checkboxColIndex = props.showCheckboxColumn ? colOffset++ : -1;
const actionColStartIndex = props.showActionColumn && props.actionColumnPosition === 'start' ? colOffset++ : -1;
const serialColIndex = colOffset++;
const dataColCount = effectiveColumns.value.length;
const actionColEndIndex = props.showActionColumn
? (props.actionColumnPosition === 'end' ? colOffset + dataColCount : props.actionColumnPosition === 'beforeLast' ? colOffset + dataColCount - 1 : -1)
: -1;
// 复选框列:保持默认
if (col === checkboxColIndex) {
return;
}
// 操作列、序号列:用极简表头替换,去除 div.relative/span.colHeader 等多余容器,确保 60px 下完整显示
if (col === actionColStartIndex || col === serialColIndex || col === actionColEndIndex) {
const label = col === serialColIndex ? props.serialNoHeader : props.actionColumnHeader;
TH.innerHTML = '';
TH.classList.add('ht-header-simple');
const span = document.createElement('span');
span.className = 'ht-header-simple-text';
span.textContent = label;
if (col === serialColIndex && props.enableColumnOrderConfig) {
TH.classList.add('ht-header-order-config');
const wrap = document.createElement('div');
wrap.className = 'ht-header-order-config-wrap';
const btn = document.createElement('span');
btn.className = 'column-order-config-btn';
btn.title = '列排序设置';
btn.setAttribute('role', 'button');
btn.setAttribute('tabindex', '0');
btn.setAttribute('aria-label', '列排序设置');
const iconContainer = document.createElement('span');
iconContainer.className = 'column-order-config-icon';
render(h(IconSettings, { size: 18 }), iconContainer);
btn.appendChild(iconContainer);
btn.addEventListener('click', (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
emit('open-column-order-config', effectiveColumns.value.map((c) => ({ key: c.key, label: c.label })));
});
btn.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
emit('open-column-order-config', effectiveColumns.value.map((c) => ({ key: c.key, label: c.label })));
}
});
wrap.appendChild(btn);
TH.appendChild(wrap);
} else {
TH.appendChild(span);
}
return;
}
// 数据列
const dataColIndex = col - colOffset;
if (dataColIndex < 0 || dataColIndex >= effectiveColumns.value.length) {
return;
}
const colDef = effectiveColumns.value[dataColIndex];
const searchField = colDef.searchField || props.columnToSearchFieldMap[col];
const headerText = colDef.label;
// 检查是否已经创建了搜索框
if (TH.querySelector('.header-search-input')) {
return;
}
// 如果支持搜索,获取当前值;否则为空
const currentValue = searchField ? ((props.searchParams as any)[searchField] || '') : '';
const isMultiValue = searchField && (colDef.multiValue || props.multiValueFields.includes(searchField));
// 如果列不支持搜索,仍然显示搜索框,但禁用输入
const isSearchable = colDef.searchable || !!searchField;
// 设置表头样式
TH.style.padding = '4px 6px';
TH.style.position = 'relative';
TH.style.verticalAlign = 'middle';
// 检查是否已经创建了搜索框,避免重复创建
if (TH.querySelector('.header-search-wrapper')) {
return;
}
// 创建搜索框容器
const searchWrapper = document.createElement('div');
searchWrapper.className = 'header-search-wrapper';
searchWrapper.style.cssText = `
display: flex;
align-items: center;
width: 99%;
position: relative;
min-width: 0;
`;
// 创建搜索输入框
const input = document.createElement('input');
input.type = 'text';
input.className = 'header-search-input';
input.placeholder = headerText;
input.value = currentValue as string;
input.disabled = !isSearchable;
input.style.cssText = `
flex: 1;
min-width: 0;
height: 26px;
padding: 4px 8px;
border: 1px solid #d0d0d0;
border-radius: 4px;
font-size: 12px;
background-color: ${isSearchable ? '#fff' : '#f5f5f5'};
outline: none;
cursor: ${isSearchable ? 'text' : 'not-allowed'};
box-sizing: border-box;
line-height: 1.5;
`;
if (isSearchable && isMultiValue) {
input.title = '双击打开批量输入';
}
// 搜索框事件处理(仅当支持搜索时)
if (isSearchable) {
input.addEventListener('keydown', (e: KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'Enter' || e.keyCode === 13) {
e.preventDefault();
const value = (input.value || '').trim();
if (searchField) {
(props.searchParams as any)[searchField] = value;
emit('search');
}
}
});
input.addEventListener('blur', (e: FocusEvent) => {
e.stopPropagation();
const value = (input.value || '').trim();
if (searchField) {
(props.searchParams as any)[searchField] = value;
if (value) {
emit('search');
}
}
});
// 粘贴事件处理,支持多数据粘贴(换行符、逗号、分号分隔)
input.addEventListener('paste', (e: ClipboardEvent) => {
e.stopPropagation();
const clipboardData = e.clipboardData;
if (clipboardData) {
const pastedText = clipboardData.getData('text');
if (pastedText) {
// 处理多行数据:换行符、逗号、分号分隔
let processedValue = pastedText.trim();
// 如果是多行数据,转换为逗号分隔
if (pastedText.includes('\n') || pastedText.includes('\r')) {
processedValue = pastedText
.split(/[\n\r]+/)
.map(line => line.trim())
.filter(line => line.length > 0)
.join(',');
} else if (pastedText.includes(';')) {
// 分号分隔转换为逗号分隔
processedValue = pastedText
.split(';')
.map(item => item.trim())
.filter(item => item.length > 0)
.join(',');
}
// 更新输入框值
setTimeout(() => {
input.value = processedValue;
if (searchField) {
(props.searchParams as any)[searchField] = processedValue;
}
}, 0);
}
}
});
// 输入事件处理,确保数据实时保存并一直显示
input.addEventListener('input', (e: Event) => {
const value = (input.value || '').trim();
if (searchField) {
(props.searchParams as any)[searchField] = value;
}
});
}
input.addEventListener('mousedown', (e: MouseEvent) => {
e.stopPropagation();
});
// 多值字段支持:双击打开批量输入(仅当支持搜索时)
if (isSearchable && isMultiValue) {
input.addEventListener('dblclick', (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (props.onOpenMultiValueModal) {
props.onOpenMultiValueModal(searchField, headerText);
}
});
}
searchWrapper.appendChild(input);
// 清空表头,添加搜索框
// Handsontable 会在后续渲染中自动添加筛选图标,我们在 afterRender 中处理
TH.innerHTML = '';
TH.appendChild(searchWrapper);
// 确保表头可以显示筛选图标
TH.style.overflow = 'visible';
// 标记这个表头已经添加了搜索框
TH.setAttribute('data-has-search', 'true');
},
cells: function(row: number, col: number) {
const cellProperties: any = {};
cellProperties.renderer = function(instance: any, td: any, row: number, col: number, prop: any, value: any) {
td.style.textAlign = 'left';
td.style.verticalAlign = 'middle';
let colOffset = 0;
const checkboxColIndex = props.showCheckboxColumn ? colOffset++ : -1;
const actionColStartIndex = props.showActionColumn && props.actionColumnPosition === 'start' ? colOffset++ : -1;
const serialColIndex = colOffset++;
const dataColCount = effectiveColumns.value.length;
const actionColEndIndex = props.showActionColumn
? (props.actionColumnPosition === 'end' ? colOffset + dataColCount : props.actionColumnPosition === 'beforeLast' ? colOffset + dataColCount - 1 : -1)
: -1;
// 复选框列
if (col === checkboxColIndex && row >= 0 && row < props.data.length) {
const rowData = props.data[row];
const checked = (rowData as any)?.checked || false;
td.innerHTML = `<input type="checkbox" ${checked ? 'checked' : ''} data-row-index="${row}" style="cursor: pointer;">`;
return;
}
// 操作列(在前面)
if (col === actionColStartIndex && row >= 0 && row < props.data.length) {
td.style.textAlign = 'center';
const rowData = props.data[row];
if (props.actionRenderer) {
td.innerHTML = props.actionRenderer(rowData, row);
} else {
td.innerHTML = `<div class="view-icon-wrapper" data-row-index="${row}" style="display: inline-flex; align-items: center; justify-content: center; cursor: pointer; padding: 4px; border-radius: 4px; transition: all 0.2s;">
<svg data-row-index="${row}" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="color: #2D8C8C;">
<path data-row-index="${row}" d="M8 2C4.5 2 1.73 4.11 0 7c1.73 2.89 4.5 5 8 5s6.27-2.11 8-5c-1.73-2.89-4.5-5-8-5zM8 11.5c-2.48 0-4.5-2.02-4.5-4.5S5.52 2.5 8 2.5s4.5 2.02 4.5 4.5S10.48 11.5 8 11.5zM8 4.5c-1.38 0-2.5 1.12-2.5 2.5S6.62 9.5 8 9.5s2.5-1.12 2.5-2.5S9.38 4.5 8 4.5z" fill="currentColor"/>
</svg>
</div>`;
}
return;
}
// 序号列
if (col === serialColIndex) {
td.style.textAlign = 'center';
td.innerHTML = value || '';
td.style.color = '#8c8c8c';
td.style.fontWeight = '500';
return;
}
// 操作列(在后面)
if (col === actionColEndIndex && row >= 0 && row < props.data.length) {
td.style.textAlign = 'center';
const rowData = props.data[row];
if (props.actionRenderer) {
td.innerHTML = props.actionRenderer(rowData, row);
} else {
td.innerHTML = `<div class="view-icon-wrapper" data-row-index="${row}" style="display: inline-flex; align-items: center; justify-content: center; cursor: pointer; padding: 4px; border-radius: 4px; transition: all 0.2s;">
<span data-row-index="${row}" style="color: #2D8C8C; font-size: 12px;">操作</span>
</div>`;
}
return;
}
// 数据列
const dataColIndex = col - colOffset;
if (dataColIndex >= 0 && dataColIndex < effectiveColumns.value.length) {
const colDef = effectiveColumns.value[dataColIndex];
const rowData = props.data[row];
// 自定义渲染器
if (props.customCellRenderer) {
const customResult = props.customCellRenderer(row, col, value, rowData);
if (customResult !== null) {
td.innerHTML = customResult;
return;
}
}
// 列定义中的渲染器
if (colDef.renderer) {
td.innerHTML = colDef.renderer(rowData, value);
return;
}
}
td.innerHTML = value || '--';
};
return cellProperties;
},
afterSelectionEnd: function(r: number, c: number, r2: number, c2: number) {
if (!hotInstance) return;
const selected = hotInstance.getSelected() || [];
const selectedRowIndices = new Set<number>();
selected.forEach(([rowStart, colStart, rowEnd, colEnd]: number[]) => {
const startRow = Math.min(rowStart, rowEnd);
const endRow = Math.max(rowStart, rowEnd);
for (let i = startRow; i <= endRow; i++) {
if (i >= 0 && i < props.data.length) {
selectedRowIndices.add(i);
}
}
});
selectedRowIndicesSet.value = selectedRowIndices;
selectedRows.value = Array.from(selectedRowIndices)
.map(index => props.data[index])
.filter(Boolean);
emit('update:selectedRows', selectedRows.value);
emit('selection-change', selectedRows.value);
// 应用选中行样式
applySelectedRowStyles();
},
// 在鼠标按下前拦截:如果点击的是“是否完结”滑块,不触发行选择/高亮
beforeOnCellMouseDown: function(event: any, coords: any, td: any) {
const target = event?.target as HTMLElement | null;
if (target && (target.closest('.is-finish-switch-btn') || target.closest('.customer-status-switch'))) {
event.preventDefault();
event.stopPropagation();
return false;
}
},
afterOnCellMouseDown: function(event: any, coords: any, td: any) {
let colOffset = 0;
const checkboxColIndex = props.showCheckboxColumn ? colOffset++ : -1;
const actionColStartIndex = props.showActionColumn && props.actionColumnPosition === 'start' ? colOffset++ : -1;
const serialColIndex = colOffset++;
const dataColCount = effectiveColumns.value.length;
const actionColEndIndex = props.showActionColumn
? (props.actionColumnPosition === 'end' ? colOffset + dataColCount : props.actionColumnPosition === 'beforeLast' ? colOffset + dataColCount - 1 : -1)
: -1;
// 复选框列
if (coords.col === checkboxColIndex) {
const target = event.target;
if (target.type === 'checkbox') {
const rowIndex = parseInt(target.getAttribute('data-row-index'));
if (rowIndex >= 0 && rowIndex < props.data.length) {
const row = props.data[rowIndex];
const checked = target.checked;
(row as any).checked = checked;
emit('checkbox-change', row, checked, rowIndex);
}
}
}
// 操作列(在前面)
if (coords.col === actionColStartIndex) {
const target = event.target;
// 查找最近的带有 data-action 的元素
const actionElement = target.closest('[data-action]');
const action = actionElement?.getAttribute('data-action');
const rowIndexAttr = actionElement?.getAttribute('data-row-index') || target.getAttribute('data-row-index');
if (rowIndexAttr !== null) {
const rowIndex = parseInt(rowIndexAttr);
if (rowIndex >= 0 && rowIndex < props.data.length) {
const row = props.data[rowIndex];
emit('action-click', row, rowIndex, action);
}
}
}
// 操作列(在后面)
if (coords.col === actionColEndIndex) {
const target = event.target;
// 查找最近的带有 data-action 的元素
const actionElement = target.closest('[data-action]');
const action = actionElement?.getAttribute('data-action');
const rowIndexAttr = actionElement?.getAttribute('data-row-index') || target.getAttribute('data-row-index');
if (rowIndexAttr !== null) {
const rowIndex = parseInt(rowIndexAttr);
if (rowIndex >= 0 && rowIndex < props.data.length) {
const row = props.data[rowIndex];
emit('action-click', row, rowIndex, action);
}
}
}
// 双击任意内容单元格时,触发行级操作(例如打开详情)
const isDoubleClick = event && event.detail === 2;
const rowIndex = coords?.row;
const colIndex = coords?.col;
if (
isDoubleClick &&
rowIndex != null &&
rowIndex >= 0 &&
rowIndex < props.data.length &&
colIndex !== checkboxColIndex &&
colIndex !== actionColStartIndex &&
colIndex !== actionColEndIndex
) {
const row = props.data[rowIndex];
if (row) {
emit('action-click', row, rowIndex);
}
}
},
afterRender: function(isForced: boolean) {
if (!hotInstance) return;
// 移除操作列和序号列的筛选图标(这些列不需要筛选功能)
const removeFilterIconFromColumns = () => {
const headerRows = hotContainer.value?.querySelectorAll('.ht_clone_top thead tr, .ht_master thead tr');
if (!headerRows?.length) return;
let colOffset = 0;
const checkboxColIndex = props.showCheckboxColumn ? colOffset++ : -1;
const actionColStartIndex = props.showActionColumn && props.actionColumnPosition === 'start' ? colOffset++ : -1;
const serialColIndex = colOffset++;
const dataColCount = effectiveColumns.value.length;
const actionColEndIndex = props.showActionColumn
? (props.actionColumnPosition === 'end' ? colOffset + dataColCount : props.actionColumnPosition === 'beforeLast' ? colOffset + dataColCount - 1 : -1)
: -1;
const noFilterCols = new Set([checkboxColIndex, actionColStartIndex, serialColIndex, actionColEndIndex].filter((i) => i >= 0));
headerRows.forEach((row: Element) => {
const ths = row.querySelectorAll('th');
ths.forEach((th: Element, index: number) => {
if (noFilterCols.has(index)) {
const btn = th.querySelector('.changeType');
if (btn?.parentElement) btn.parentElement.removeChild(btn);
}
});
});
};
removeFilterIconFromColumns();
// 更新已存在的搜索框的值
const headerRow = hotContainer.value?.querySelector('.ht_clone_top thead tr') as HTMLElement;
if (!headerRow) return;
const headerCells = headerRow.querySelectorAll('th');
headerCells.forEach((th: HTMLElement, index: number) => {
let colOffset = 0;
const checkboxColIndex = props.showCheckboxColumn ? colOffset++ : -1;
const actionColStartIndex = props.showActionColumn && props.actionColumnPosition === 'start' ? colOffset++ : -1;
const serialColIndex = colOffset++;
const dataColCount = effectiveColumns.value.length;
const actionColEndIndex = props.showActionColumn
? (props.actionColumnPosition === 'end' ? colOffset + dataColCount : props.actionColumnPosition === 'beforeLast' ? colOffset + dataColCount - 1 : -1)
: -1;
// 跳过复选框列、操作列和序号列
if (index === checkboxColIndex || index === actionColStartIndex || index === serialColIndex || index === actionColEndIndex) {
return;
}
// 数据列
const dataColIndex = index - colOffset;
if (dataColIndex < 0 || dataColIndex >= effectiveColumns.value.length) {
return;
}
const colDef = effectiveColumns.value[dataColIndex];
const searchField = colDef.searchField || props.columnToSearchFieldMap[index];
const isSearchable = colDef.searchable || !!searchField;
const currentValue = searchField ? ((props.searchParams as any)[searchField] || '') : '';
const input = th.querySelector('.header-search-input') as HTMLInputElement;
if (input) {
if (isSearchable && searchField) {
const isMultiValue = colDef.multiValue || props.multiValueFields.includes(searchField);
// 更新搜索框的值
if (isMultiValue && currentValue) {
const values = currentValue.split(',').map((v: string) => v.trim()).filter((v: string) => v.length > 0);
if (values.length > 1) {
const displayValue = values[0].length > 15 ? values[0].substring(0, 15) + '...' : values[0];
input.value = `${displayValue} (+${values.length - 1})`;
input.title = currentValue;
} else if (values.length === 1) {
input.value = values[0];
input.title = '';
} else {
input.value = '';
input.title = '';
}
} else {
input.value = currentValue as string;
input.title = '';
}
} else {
// 不支持搜索的列,清空搜索框
input.value = '';
input.title = '';
}
}
});
}
};
hotInstance = new Handsontable(hotContainer.value, hotConfig as any);
} catch (error) {
console.error('初始化 Handsontable 失败:', error);
}
};
// 更新数据
const updateData = () => {
if (!hotInstance || !props.data.length) {
return;
}
const data = props.data.map((row, rowIndex) => {
const rowData: any[] = [];
// 复选框列
if (props.showCheckboxColumn) {
rowData.push((row as any).checked || false);
}
// 操作列(在前面)
if (props.showActionColumn && props.actionColumnPosition === 'start') {
rowData.push('');
}
// 序号
const serialNo = (props.currentPage - 1) * props.pageSize + rowIndex + 1;
rowData.push(serialNo);
// 数据列
effectiveColumns.value.forEach((col) => {
const value = row[col.key] || '--';
rowData.push(value);
});
// 操作列(在后面)
if (props.showActionColumn && props.actionColumnPosition === 'end') {
rowData.push('');
}
return rowData;
});
hotInstance.loadData(data);
hotInstance.render();
// 刷新尺寸以确保 Handsontable 内置滚动视口(.wtHolder)尺寸正确
setTimeout(() => {
if (hotInstance) {
hotInstance.refreshDimensions();
}
}, 50);
};
// 初始化完成后刷新尺寸(避免与 ResizeObserver 重复狂刷导致 stretchH 列宽错乱)
const finalizeInit = () => {
setTimeout(() => {
if (hotInstance) {
hotInstance.refreshDimensions();
setTimeout(() => hotInstance?.refreshDimensions(), 100);
}
}, 300);
};
// 应用选中行样式
const applySelectedRowStyles = () => {
if (!hotInstance || !hotContainer.value) return;
const tbody = hotContainer.value.querySelector('.ht_master tbody');
if (!tbody) return;
const rows = tbody.querySelectorAll('tr');
rows.forEach((row, index) => {
// 移除之前的所有选中状态
row.classList.remove('selected-row');
// 为当前选中的行添加类
if (selectedRowIndicesSet.value.has(index)) {
row.classList.add('selected-row');
}
});
};
// 监听数据变化
watch(() => props.data, () => {
if (hotInstance) {
updateData();
} else {
nextTick(() => {
setTimeout(() => {
initHandsontable();
}, 200);
});
}
}, { deep: true });
// 监听分页变化
watch([() => props.currentPage, () => props.pageSize], () => {
if (hotInstance && props.data.length) {
updateData();
}
});
// 外部列顺序变化时,刷新内部状态
watch(
() => props.columnOrder,
(next) => {
if (next && next.length > 0) {
internalColumnOrder.value = normalizeColumnOrder(props.columns, next);
}
},
{ deep: true }
);
// 列定义变化时,重新对齐顺序并重建表格
watch(
() => props.columns,
() => {
internalColumnOrder.value = normalizeColumnOrder(props.columns, internalColumnOrder.value);
if (hotInstance) {
nextTick(() => {
initHandsontable();
finalizeInit();
});
}
},
{ deep: true }
);
// 窗口大小变化处理函数
const handleResize = () => {
if (hotInstance) {
// 延迟执行,确保DOM更新完成
setTimeout(() => {
if (hotInstance) {
hotInstance.refreshDimensions();
}
}, 100);
}
};
// 定期刷新尺寸的定时器
let dimensionRefreshTimer: number | null = null;
// 开始定期刷新
const startDimensionRefresh = () => {
if (dimensionRefreshTimer) {
clearInterval(dimensionRefreshTimer);
}
dimensionRefreshTimer = window.setInterval(() => {
if (hotInstance && hotContainer.value) {
const rect = hotContainer.value.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
hotInstance.refreshDimensions();
}
}
}, 1000);
};
// 停止定期刷新
const stopDimensionRefresh = () => {
if (dimensionRefreshTimer) {
clearInterval(dimensionRefreshTimer);
dimensionRefreshTimer = null;
}
};
// 容器尺寸变化时同步表格高度,使列表高度与容器一致
let resizeObserver: ResizeObserver | null = null;
const setupResizeObserver = () => {
if (!containerRef.value) return;
resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry || !hotInstance) return;
const h = entry.contentRect.height;
// 高度以本组件根容器实测为准;勿用外层 props.height 覆盖,否则与 flex 内真实高度不一致,stretchH 会把列压满导致横向条消失
if (h <= 0) return;
const fh = Math.floor(h);
hotInstance.updateSettings({ height: fh });
nextTick(() => {
hotInstance?.refreshDimensions();
});
});
resizeObserver.observe(containerRef.value);
};
onMounted(() => {
// 初始化列顺序(优先级:props.columnOrder > localStorage > 默认列顺序)
if (props.columnOrder && props.columnOrder.length > 0) {
internalColumnOrder.value = normalizeColumnOrder(props.columns, props.columnOrder);
} else if (props.persistColumnOrderKey) {
try {
const raw = localStorage.getItem(props.persistColumnOrderKey);
const parsed = raw ? JSON.parse(raw) : [];
internalColumnOrder.value = normalizeColumnOrder(props.columns, parsed);
} catch (_) {
internalColumnOrder.value = normalizeColumnOrder(props.columns, []);
}
} else {
internalColumnOrder.value = normalizeColumnOrder(props.columns, []);
}
nextTick(() => {
// 确保样式已加载后再初始化
setTimeout(() => {
initHandsontable();
finalizeInit();
setupResizeObserver();
}, 200);
});
// 添加窗口大小变化监听器
window.addEventListener('resize', handleResize);
// 开始定期刷新尺寸
startDimensionRefresh();
});
onUnmounted(() => {
if (resizeObserver && containerRef.value) {
try {
resizeObserver.unobserve(containerRef.value);
} catch (_) {}
resizeObserver = null;
}
// 停止定期刷新
stopDimensionRefresh();
// 移除窗口大小变化监听器
window.removeEventListener('resize', handleResize);
if (hotInstance) {
hotInstance.destroy();
hotInstance = null;
}
});
// 暴露方法
defineExpose({
refresh: updateData,
getSelectedRows: () => selectedRows.value,
clearSelection: () => {
if (hotInstance) {
hotInstance.deselectCell();
selectedRows.value = [];
selectedRowIndicesSet.value = new Set();
}
},
});
</script>
<template>
<div ref="containerRef" class="handsontable-pro-container">
<div ref="hotContainer" class="handsontable-wrapper ht-theme-classic"></div>
</div>
</template>
<style scoped lang="less">
.handsontable-pro-container {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
border: 0.5px solid #f0f0f0 !important;
border-radius: 4px;
overflow: hidden; // 不出现外层滚动条;滚动仅发生在 Handsontable .ht_master .wtHolder
flex: 1;
min-height: 0;
box-sizing: border-box;
.handsontable-wrapper {
flex: 1;
min-height: 0;
width: 100%;
position: relative;
:deep(.handsontable) {
height: 100%;
min-height: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
border: none !important;
table {
border: none !important;
}
.ht_master,
.ht_clone_top,
.ht_clone_left,
.ht_clone_corner {
border: none !important;
}
.wtHolder {
border: none !important;
}
// 修复滚动条显示问题 - 确保横向滚动条在100%宽度时也能正确显示
.ht_master {
overflow: visible !important;
.wtHolder {
// 使用scroll强制显示滚动条,确保在内容宽度等于容器宽度时也能滚动
overflow-x: scroll !important;
overflow-y: auto !important;
// 确保滚动条区域可见
min-height: 0;
// 强制浏览器重新计算滚动条
will-change: scroll-position;
// 隐藏不需要的滚动条样式
&::-webkit-scrollbar-x {
height: 8px;
}
&::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
&:hover {
background: #a8a8a8;
}
}
&::-webkit-scrollbar-track {
background: #f1f1f1;
}
}
}
.htCore {
border-collapse: separate;
border-spacing: 0;
border: none !important;
td {
border-right: 0.5px solid #f0f0f0 !important;
border-bottom: 0.5px solid #f0f0f0 !important;
border-top: none !important;
border-left: none !important;
padding: 7px 16px;
font-size: 13px;
line-height: 1.4;
color: #262626;
transition: all 0.2s ease;
background: #fff;
text-align: left !important;
vertical-align: middle !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
&:hover {
background-color: #fafafa;
}
&[title]:not([title=""]):not([title="--"]) {
cursor: help;
}
.view-icon-wrapper {
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: all 0.2s;
&:hover {
background-color: #f0f0f0;
svg {
color: #2A7F7F !important;
}
}
svg {
color: #2D8C8C;
transition: color 0.2s;
}
}
}
th {
background: #f5f5f5 !important;
border-right: 0.5px solid #f0f0f0 !important;
border-bottom: 0.5px solid #f0f0f0 !important;
border-top: none !important;
border-left: none !important;
padding: 4px !important;
font-size: 12px;
font-weight: 500;
color: #1d2129;
}
}
.handsontableInput {
display: none;
}
// 确保原生筛选符号可见且不被遮挡
:deep(.ht_clone_top th),
:deep(.ht_master th) {
position: relative;
overflow: visible;
}
// 操作列、序号列极简表头(由 afterGetColHeader 替换,无多余容器)
:deep(.ht-header-simple) {
padding: 0 !important;
vertical-align: middle;
}
:deep(.ht-header-simple .ht-header-simple-text) {
display: block;
text-align: left;
font-size: 12px;
font-weight: 500;
color: #1d2129;
padding: 4px 6px;
white-space: nowrap;
overflow: visible;
}
:deep(.ht-header-order-config .ht-header-order-config-wrap) {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-width: 0;
padding: 0;
box-sizing: border-box;
}
:deep(.ht-header-order-config .column-order-config-btn) {
border: none;
background: transparent;
cursor: pointer;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto;
color: #4e5969;
border-radius: 2px;
line-height: 0;
font-size: 12px;
padding: 0;
flex-shrink: 0;
box-shadow: none;
outline: none;
}
:deep(.ht-header-order-config .column-order-config-icon) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
}
:deep(.ht-header-order-config .column-order-config-btn:hover) {
background: transparent;
color: #1d2129;
}
:deep(.ht-header-order-config .column-order-config-btn:focus),
:deep(.ht-header-order-config .column-order-config-btn:focus-visible),
:deep(.ht-header-order-config .column-order-config-btn:active) {
border: none;
outline: none;
box-shadow: none;
background: transparent;
}
// 搜索框容器样式(搜索框与筛选图标占比 9.9:0.1)
:deep(.ht_clone_top th .header-search-wrapper),
:deep(.ht_master th .header-search-wrapper) {
width: 99%;
position: relative;
z-index: 1;
}
// 筛选操作栏样式
:deep(.filter-action-bar) {
display: flex;
gap: 8px;
padding: 8px;
justify-content: flex-end;
.filter-ok-btn,
.filter-reset-btn {
padding: 4px 12px;
border: 1px solid #d0d0d0;
border-radius: 4px;
background-color: #fff;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
&:hover {
background-color: #f5f5f5;
border-color: #999;
}
}
.filter-ok-btn {
color: #1890ff;
border-color: #1890ff;
&:hover {
background-color: #1890ff;
color: #fff;
}
}
.filter-reset-btn {
color: #666;
&:hover {
background-color: #f5f5f5;
color: #333;
}
}
}
}
// 选中行样式 - 使用更具体的选择器确保样式生效
:deep(.handsontable .area) {
background-color: #e8f5e9 !important;
td {
background-color: #e8f5e9 !important;
}
}
:deep(.handsontable .currentRow) {
background-color: #e8f5e9 !important;
td {
background-color: #e8f5e9 !important;
}
}
// 手动应用的选中行样式(通过JavaScript函数)
:deep(.handsontable tbody tr) {
&.selected-row {
background-color: #e8f5e9 !important;
td {
background-color: #e8f5e9 !important;
}
}
}
// 确保选中单元格的背景色
:deep(.handsontable td.area) {
background-color: #e8f5e9 !important;
}
// 当前行高亮
:deep(.handsontable tbody tr.currentRow) {
background-color: #e8f5e9 !important;
td {
background-color: #e8f5e9 !important;
}
}
}
}
</style>