455 lines
15 KiB
TypeScript
455 lines
15 KiB
TypeScript
import { ref, computed, watch, onMounted, nextTick, type Ref } from 'vue'
|
||
import type { TableInstance } from 'element-plus'
|
||
|
||
export interface ColumnConfig {
|
||
prop?: string
|
||
label: string
|
||
width?: number | string
|
||
minWidth?: number | string
|
||
fixed?: boolean | 'left' | 'right'
|
||
alwaysShow?: boolean
|
||
[key: string]: any
|
||
}
|
||
|
||
/**
|
||
* 自动从 el-table 提取列配置的 composable
|
||
* @param tableRef el-table 的 ref
|
||
* @param storageKey localStorage 存储的 key,用于持久化
|
||
* @param options 额外配置选项
|
||
*/
|
||
export function useTableColumns(
|
||
tableRef: Ref<TableInstance | undefined> | any, // 支持多种类型的 ref
|
||
storageKey?: string,
|
||
options?: {
|
||
// 默认隐藏的列(prop 或 label)
|
||
defaultHidden?: string[]
|
||
// 始终显示的列(prop 或 label)
|
||
alwaysShow?: string[]
|
||
// 列配置映射(用于自定义列的显示名称等)
|
||
columnMap?: Record<string, Partial<ColumnConfig>>
|
||
}
|
||
) {
|
||
const columns = ref<ColumnConfig[]>([])
|
||
const visibleColumns = ref<string[]>([])
|
||
const isInitialized = ref(false) // 标记是否已经初始化过(用户是否操作过)
|
||
const hasInitializedVisibleColumns = ref(false) // 标记是否已经初始化过可见列(避免重复初始化)
|
||
|
||
// 获取表格实例的辅助函数
|
||
const getTableInstance = (): TableInstance | null => {
|
||
// 方法1: 直接从 tableRef.value 获取
|
||
if (tableRef?.value) {
|
||
return tableRef.value
|
||
}
|
||
// 方法2: 如果 tableRef 本身有 value 属性(可能是直接的 ref 对象)
|
||
if ((tableRef as any).value) {
|
||
return (tableRef as any).value
|
||
}
|
||
// 方法3: 如果 tableRef 本身就是表格实例(不应该发生,但作为备用)
|
||
if ((tableRef as any).$el) {
|
||
return tableRef as any
|
||
}
|
||
return null
|
||
}
|
||
|
||
// 从 el-table 提取列配置
|
||
const extractColumns = (): ColumnConfig[] => {
|
||
const tableInstance = getTableInstance()
|
||
if (!tableInstance) {
|
||
return []
|
||
}
|
||
|
||
const table = tableInstance
|
||
const extracted: ColumnConfig[] = []
|
||
|
||
try {
|
||
// 方法1: 从 table.store 中提取(Element Plus 内部实现)
|
||
const store = (table as any).store
|
||
|
||
if (store) {
|
||
// 尝试多种路径访问列数据
|
||
let tableColumns: any[] = []
|
||
|
||
if (store.states && store.states.columns) {
|
||
tableColumns = store.states.columns.value || []
|
||
} else if (store.columns) {
|
||
tableColumns = Array.isArray(store.columns) ? store.columns : (store.columns.value || [])
|
||
} else if ((table as any).columns) {
|
||
tableColumns = Array.isArray((table as any).columns) ? (table as any).columns : ((table as any).columns.value || [])
|
||
}
|
||
|
||
if (tableColumns.length > 0) {
|
||
tableColumns.forEach((col: any) => {
|
||
// 跳过序号列
|
||
if (col.type === 'index' || col.type === 'selection') {
|
||
return
|
||
}
|
||
|
||
// 尝试多种方式获取 label
|
||
const label = col.label || col.columnKey || col.property || col.prop || ''
|
||
|
||
// 如果没有 label,跳过这一列
|
||
if (!label) {
|
||
return
|
||
}
|
||
|
||
const config: ColumnConfig = {
|
||
prop: col.property || col.prop || '',
|
||
label: label,
|
||
width: col.width,
|
||
minWidth: col.minWidth,
|
||
// Element Plus 中非固定列的 fixed 通常是 false,这里统一将 false 归一为 undefined
|
||
fixed: col.fixed ? col.fixed : undefined,
|
||
}
|
||
|
||
// 应用自定义映射
|
||
if (options?.columnMap && config.prop) {
|
||
const mapped = options.columnMap[config.prop]
|
||
if (mapped) {
|
||
Object.assign(config, mapped)
|
||
}
|
||
}
|
||
|
||
// 应用 alwaysShow 配置
|
||
if (options?.alwaysShow) {
|
||
const key = config.prop || config.label
|
||
if (key && options.alwaysShow.includes(key)) {
|
||
config.alwaysShow = true
|
||
}
|
||
}
|
||
|
||
extracted.push(config)
|
||
})
|
||
|
||
if (extracted.length > 0) {
|
||
return extracted
|
||
}
|
||
}
|
||
}
|
||
|
||
// 方法2: 从 DOM 中提取(备用方案,更可靠)
|
||
const tableEl = (table as any).$el
|
||
|
||
if (tableEl) {
|
||
// 尝试多种选择器来查找表头
|
||
let columnHeaders: NodeListOf<HTMLElement> | null = null
|
||
|
||
// 方法2.1: 从主表格头部查找
|
||
const headerWrapper = tableEl.querySelector('.el-table__header-wrapper')
|
||
if (headerWrapper) {
|
||
columnHeaders = headerWrapper.querySelectorAll('th')
|
||
}
|
||
|
||
// 方法2.2: 如果找不到,从整个表格查找
|
||
if (!columnHeaders || columnHeaders.length === 0) {
|
||
columnHeaders = tableEl.querySelectorAll('th')
|
||
}
|
||
|
||
// 方法2.3: 如果还是找不到,尝试查找固定列
|
||
if (!columnHeaders || columnHeaders.length === 0) {
|
||
const fixedLeft = tableEl.querySelector('.el-table__fixed-left')
|
||
if (fixedLeft) {
|
||
columnHeaders = fixedLeft.querySelectorAll('th')
|
||
}
|
||
}
|
||
|
||
if (!columnHeaders || columnHeaders.length === 0) {
|
||
return []
|
||
}
|
||
|
||
// 创建一个映射,通过列索引匹配 prop
|
||
const propMap = new Map<number, string>()
|
||
|
||
// 尝试从表格的 slot 或配置中获取 prop
|
||
// 通过检查表格的列定义来匹配
|
||
if (store && store.states && store.states.columns) {
|
||
const tableColumns = store.states.columns.value || []
|
||
tableColumns.forEach((col: any, idx: number) => {
|
||
if (col.property || col.prop) {
|
||
propMap.set(idx, col.property || col.prop)
|
||
}
|
||
})
|
||
}
|
||
|
||
columnHeaders.forEach((th: HTMLElement, index: number) => {
|
||
// 尝试多种方式获取 label 文本
|
||
// 1. 从 .cell 元素
|
||
const cell = th.querySelector('.cell') as HTMLElement | null
|
||
// 2. 从所有可能的文本节点
|
||
let rawLabel = ''
|
||
|
||
if (cell) {
|
||
rawLabel = cell.innerText || cell.textContent || ''
|
||
} else {
|
||
// 尝试从 th 的所有子元素中查找文本
|
||
const textNodes: string[] = []
|
||
const walker = document.createTreeWalker(
|
||
th,
|
||
NodeFilter.SHOW_TEXT,
|
||
null
|
||
)
|
||
let node: Node | null
|
||
while ((node = walker.nextNode())) {
|
||
const text = node.textContent?.trim()
|
||
if (text) {
|
||
textNodes.push(text)
|
||
}
|
||
}
|
||
rawLabel = textNodes.join(' ') || th.innerText || th.textContent || ''
|
||
}
|
||
|
||
const label = rawLabel.trim()
|
||
|
||
// 排除序号列和空列
|
||
if (!label || label === '序号') {
|
||
return
|
||
}
|
||
|
||
// 检查是否是固定列
|
||
let fixed: 'left' | 'right' | undefined
|
||
const thParent = th.closest('table')
|
||
if (thParent) {
|
||
if (thParent.classList.contains('el-table__fixed-left') || th.closest('.el-table__fixed-left')) {
|
||
fixed = 'left'
|
||
} else if (thParent.classList.contains('el-table__fixed-right') || th.closest('.el-table__fixed-right')) {
|
||
fixed = 'right'
|
||
} else if (th.classList.contains('is-left')) {
|
||
fixed = 'left'
|
||
} else if (th.classList.contains('is-right')) {
|
||
fixed = 'right'
|
||
}
|
||
}
|
||
|
||
// 尝试从属性中获取宽度
|
||
const widthAttr = th.getAttribute('width') || th.style.width
|
||
let width: number | string | undefined
|
||
if (widthAttr) {
|
||
const numWidth = parseInt(widthAttr)
|
||
width = isNaN(numWidth) ? widthAttr : numWidth
|
||
}
|
||
|
||
// 尝试从对应的 body cell 中获取 data-key 或其他属性来匹配 prop
|
||
let prop = propMap.get(index)
|
||
|
||
// 如果没有找到 prop,使用 label 作为 prop(isColumnVisible 会同时匹配 prop 和 label)
|
||
// 或者使用默认的 column_${index}
|
||
if (!prop) {
|
||
prop = `column_${index}`
|
||
}
|
||
|
||
const columnConfig = {
|
||
prop: prop,
|
||
label,
|
||
width,
|
||
// DOM 提取的 fixed 只有 left/right,这里也归一为 undefined 或 'left'/'right'
|
||
fixed: fixed ? fixed : undefined,
|
||
}
|
||
extracted.push(columnConfig)
|
||
})
|
||
|
||
if (extracted.length > 0) {
|
||
return extracted
|
||
}
|
||
}
|
||
} catch (error) {
|
||
// 提取失败,静默处理
|
||
}
|
||
|
||
return []
|
||
}
|
||
|
||
// 初始化列配置
|
||
const initColumns = async () => {
|
||
// 等待多个渲染周期,确保表格完全渲染
|
||
await nextTick()
|
||
await new Promise(resolve => setTimeout(resolve, 300))
|
||
await nextTick()
|
||
|
||
let extracted = extractColumns()
|
||
|
||
// 如果第一次提取失败,多次重试
|
||
if (extracted.length === 0) {
|
||
for (let i = 0; i < 5; i++) {
|
||
await new Promise(resolve => setTimeout(resolve, 200))
|
||
extracted = extractColumns()
|
||
if (extracted.length > 0) break
|
||
}
|
||
}
|
||
|
||
if (extracted.length > 0) {
|
||
// 合并列配置:
|
||
// - 第一次提取时直接赋值
|
||
// - 后续提取时与已有列做并集,避免因为列被隐藏导致配置丢失,
|
||
// 从而在“列设置”弹窗中看不到已隐藏的列
|
||
if (columns.value.length === 0) {
|
||
columns.value = extracted
|
||
} else {
|
||
const keyOf = (col: ColumnConfig) => col.prop || col.label
|
||
const map = new Map<string, ColumnConfig>()
|
||
|
||
// 先放入已有列
|
||
columns.value.forEach(col => {
|
||
const key = keyOf(col)
|
||
if (key) {
|
||
map.set(key, { ...col })
|
||
}
|
||
})
|
||
|
||
// 再合并本次提取的列(更新宽度、fixed 等信息,避免旧配置过时)
|
||
extracted.forEach(col => {
|
||
const key = keyOf(col)
|
||
if (!key) return
|
||
const exist = map.get(key)
|
||
if (exist) {
|
||
map.set(key, {
|
||
...exist,
|
||
...col,
|
||
})
|
||
} else {
|
||
map.set(key, { ...col })
|
||
}
|
||
})
|
||
|
||
columns.value = Array.from(map.values())
|
||
}
|
||
|
||
// 初始化可见列(只在第一次初始化时执行,避免后续刷新时重置用户设置)
|
||
if (!hasInitializedVisibleColumns.value) {
|
||
if (storageKey) {
|
||
const saved = localStorage.getItem(storageKey)
|
||
if (saved) {
|
||
try {
|
||
const savedColumns = JSON.parse(saved)
|
||
// 验证保存的列是否仍然存在
|
||
const validColumns = columns.value
|
||
.filter(col => !col.alwaysShow && col.fixed === undefined)
|
||
.map(col => col.prop || col.label)
|
||
const filteredSaved = savedColumns.filter((col: string) => validColumns.includes(col))
|
||
|
||
// 如果保存的列数量少于所有列,说明是旧数据,默认显示所有列
|
||
if (filteredSaved.length < validColumns.length) {
|
||
visibleColumns.value = validColumns
|
||
isInitialized.value = false // 旧数据,视为未初始化
|
||
} else {
|
||
visibleColumns.value = filteredSaved
|
||
isInitialized.value = true // 有保存的数据,视为已初始化
|
||
}
|
||
} catch (e) {
|
||
initDefaultVisibleColumns()
|
||
isInitialized.value = false
|
||
}
|
||
} else {
|
||
// 首次使用,默认显示所有列
|
||
initDefaultVisibleColumns()
|
||
isInitialized.value = false
|
||
}
|
||
} else {
|
||
// 没有 storageKey,默认显示所有列
|
||
initDefaultVisibleColumns()
|
||
isInitialized.value = false
|
||
}
|
||
hasInitializedVisibleColumns.value = true
|
||
}
|
||
}
|
||
}
|
||
|
||
// 初始化默认可见列
|
||
const initDefaultVisibleColumns = () => {
|
||
// 默认显示所有可选择的列(除了固定列和 alwaysShow 列)
|
||
// 注意:固定列和 alwaysShow 列不需要在 visibleColumns 中,因为它们始终显示
|
||
// 默认全部选中,展示所有列
|
||
visibleColumns.value = columns.value
|
||
.filter(col => !col.alwaysShow && !col.fixed)
|
||
.map(col => col.prop || col.label)
|
||
}
|
||
|
||
// 判断列是否可见
|
||
const isColumnVisible = (propOrLabel: string): boolean => {
|
||
// 如果列配置还没有提取完成,默认显示所有列(避免初始渲染时所有列被隐藏)
|
||
if (columns.value.length === 0) {
|
||
return true
|
||
}
|
||
|
||
const column = columns.value.find(col =>
|
||
(col.prop || col.label) === propOrLabel
|
||
)
|
||
|
||
// 如果找不到对应的列配置,默认显示(可能是新添加的列)
|
||
if (!column) {
|
||
return true
|
||
}
|
||
|
||
// 固定列和始终显示的列始终显示
|
||
if (column.fixed || column.alwaysShow) {
|
||
return true
|
||
}
|
||
|
||
// 如果还未初始化(用户未操作过),默认显示所有列
|
||
if (!isInitialized.value) {
|
||
return true
|
||
}
|
||
|
||
// 如果已经初始化,严格按照可见列列表判断
|
||
// 如果可见列列表为空,说明用户取消了所有列,应该隐藏所有非固定列
|
||
if (visibleColumns.value.length === 0) {
|
||
return false
|
||
}
|
||
|
||
// 检查是否在可见列列表中
|
||
return visibleColumns.value.includes(propOrLabel)
|
||
}
|
||
|
||
// 更新可见列
|
||
const updateVisibleColumns = (newColumns: string[]) => {
|
||
visibleColumns.value = newColumns
|
||
isInitialized.value = true // 标记已经初始化(用户已操作)
|
||
if (storageKey) {
|
||
localStorage.setItem(storageKey, JSON.stringify(newColumns))
|
||
}
|
||
}
|
||
|
||
// 监听表格变化,重新提取列配置
|
||
watch(() => {
|
||
// 尝试多种方式获取 tableRef.value
|
||
if (tableRef?.value) {
|
||
return tableRef.value
|
||
}
|
||
if ((tableRef as any).value) {
|
||
return (tableRef as any).value
|
||
}
|
||
return null
|
||
}, (newVal, oldVal) => {
|
||
if (newVal && newVal !== oldVal) {
|
||
// 延迟一下,确保表格已经渲染
|
||
setTimeout(() => {
|
||
initColumns()
|
||
}, 200)
|
||
} else if (newVal && !oldVal) {
|
||
// 如果从 undefined 变为有值,也触发提取
|
||
setTimeout(() => {
|
||
initColumns()
|
||
}, 200)
|
||
}
|
||
}, { immediate: true })
|
||
|
||
// 组件挂载后初始化
|
||
onMounted(() => {
|
||
// 延迟初始化,确保表格已经渲染
|
||
// 如果 tableRef.value 已经有值,立即初始化;否则等待 watch 触发
|
||
const tableInstance = getTableInstance()
|
||
if (tableInstance) {
|
||
setTimeout(() => {
|
||
initColumns()
|
||
}, 300)
|
||
}
|
||
})
|
||
|
||
return {
|
||
columns: computed(() => columns.value),
|
||
visibleColumns: computed(() => visibleColumns.value),
|
||
isColumnVisible,
|
||
updateVisibleColumns,
|
||
refreshColumns: initColumns,
|
||
}
|
||
}
|
||
|