1603 lines
57 KiB
Vue
1603 lines
57 KiB
Vue
<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>
|
||
|