487 lines
14 KiB
Vue
487 lines
14 KiB
Vue
<!--
|
||
主列表薄封装:在 el-table 上启用列宽拖拽,并按 table-key 持久化到 localStorage。
|
||
用法:将 <el-table class="list-table" …> 换成 <ListTable table-key="crm-customer" class="list-table" …>
|
||
-->
|
||
<template>
|
||
<el-table
|
||
ref="tableRef"
|
||
v-bind="tableAttrs"
|
||
:data-list-excel-copy="excelCopy ? 'on' : undefined"
|
||
:border="resolvedBorder"
|
||
:allow-drag-last-column="true"
|
||
:class="tableClass"
|
||
@header-dragend="handleHeaderDragEnd"
|
||
>
|
||
<slot />
|
||
</el-table>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import {
|
||
computed,
|
||
nextTick,
|
||
onActivated,
|
||
onBeforeUnmount,
|
||
onDeactivated,
|
||
onMounted,
|
||
ref,
|
||
unref,
|
||
useAttrs,
|
||
watch
|
||
} from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import {
|
||
buildExcelTsv,
|
||
excelCell,
|
||
isCopyableColumn,
|
||
resolveCopyAxes,
|
||
toggleAxis,
|
||
type ListExcelColumn
|
||
} from '@/utils/list-excel-copy'
|
||
import {
|
||
isFixedNarrowListColumnKey,
|
||
resolveListColumnKey,
|
||
usePersistedListColumnWidths
|
||
} from '@/composables/usePersistedListColumnWidths'
|
||
|
||
defineOptions({ name: 'ListTable', inheritAttrs: false })
|
||
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
/** 表格唯一标识,用于 localStorage 分区(同页多表必须不同) */
|
||
tableKey: string
|
||
/** 是否持久化列宽,默认 true */
|
||
persistColumnWidth?: boolean
|
||
/** 是否开启 border(列宽拖拽依赖);默认 true */
|
||
border?: boolean
|
||
/** 是否允许 Excel 行列复制,默认 true */
|
||
excelCopy?: boolean
|
||
}>(),
|
||
{
|
||
persistColumnWidth: true,
|
||
border: true,
|
||
excelCopy: true
|
||
}
|
||
)
|
||
|
||
const attrs = useAttrs()
|
||
const tableRef = ref<any>(null)
|
||
const { setWidth, applyToTable, resetWidths, getWidths } = usePersistedListColumnWidths(
|
||
computed(() => props.tableKey)
|
||
)
|
||
|
||
const excelMode = ref(false)
|
||
const selectedCols = ref(new Set<number>())
|
||
const selectedRows = ref(new Set<number>())
|
||
let colAnchor: number | null = null
|
||
let rowAnchor: number | null = null
|
||
|
||
const resolvedBorder = computed(() => props.border !== false)
|
||
|
||
const tableClass = computed(() => {
|
||
const extra = attrs.class
|
||
return ['list-table', { 'is-excel-copy': excelMode.value }, extra]
|
||
})
|
||
|
||
const tableAttrs = computed(() => {
|
||
const raw = { ...attrs } as Record<string, unknown>
|
||
delete raw.class
|
||
delete raw.border
|
||
// 避免与内部 handler 重复绑定(父级若仍监听,下面会手动转发)
|
||
delete raw.onHeaderDragend
|
||
delete raw.onHeaderDragEnd
|
||
return raw
|
||
})
|
||
|
||
const parentHeaderDragEnd = computed(() => {
|
||
const a = attrs as Record<string, unknown>
|
||
return (a.onHeaderDragend || a.onHeaderDragEnd) as
|
||
| ((...args: any[]) => void)
|
||
| Array<(...args: any[]) => void>
|
||
| undefined
|
||
})
|
||
|
||
let applyTimer: ReturnType<typeof setTimeout> | null = null
|
||
let mountApplyTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
const scheduleApply = () => {
|
||
if (!props.persistColumnWidth) return
|
||
if (!Object.keys(getWidths()).length) return
|
||
if (applyTimer) clearTimeout(applyTimer)
|
||
applyTimer = setTimeout(() => {
|
||
nextTick(() => {
|
||
applyToTable(tableRef.value)
|
||
})
|
||
}, 16)
|
||
}
|
||
|
||
const clearApplyTimers = () => {
|
||
if (applyTimer) {
|
||
clearTimeout(applyTimer)
|
||
applyTimer = null
|
||
}
|
||
if (mountApplyTimer) {
|
||
clearTimeout(mountApplyTimer)
|
||
mountApplyTimer = null
|
||
}
|
||
}
|
||
|
||
const handleHeaderDragEnd = (
|
||
newWidth: number,
|
||
oldWidth: number,
|
||
column: any,
|
||
event: MouseEvent
|
||
) => {
|
||
if (props.persistColumnWidth) {
|
||
const key = resolveListColumnKey(column)
|
||
if (key && !isFixedNarrowListColumnKey(key)) setWidth(key, newWidth)
|
||
}
|
||
|
||
const parent = parentHeaderDragEnd.value
|
||
if (typeof parent === 'function') {
|
||
parent(newWidth, oldWidth, column, event)
|
||
} else if (Array.isArray(parent)) {
|
||
parent.forEach((fn) => fn?.(newWidth, oldWidth, column, event))
|
||
}
|
||
}
|
||
|
||
function readColumns(): ListExcelColumn[] {
|
||
const cols = tableRef.value?.store?.states?.columns
|
||
const list = (unref(cols) || []) as Array<Record<string, unknown>>
|
||
return list.map((col) => ({
|
||
id: String(col.id ?? ''),
|
||
label: String(col.label ?? ''),
|
||
type: col.type ? String(col.type) : undefined,
|
||
className: String(col.className ?? '')
|
||
}))
|
||
}
|
||
|
||
function copyableColumns(): ListExcelColumn[] {
|
||
return readColumns().filter((col) => col.id && isCopyableColumn(col))
|
||
}
|
||
|
||
function indexColumn(): ListExcelColumn | undefined {
|
||
return readColumns().find((col) => col.type === 'index' && col.id)
|
||
}
|
||
|
||
function tableRoot(): HTMLElement | null {
|
||
const el = tableRef.value?.$el as HTMLElement | undefined
|
||
return el ?? null
|
||
}
|
||
|
||
function bodyRows(): HTMLTableRowElement[] {
|
||
const root = tableRoot()
|
||
if (!root) return []
|
||
return Array.from(root.querySelectorAll('.el-table__body-wrapper tbody > tr'))
|
||
}
|
||
|
||
function paintExcelSelection() {
|
||
const root = tableRoot()
|
||
if (!root) return
|
||
root.querySelectorAll('.is-excel-col, .is-excel-rowhead, .is-excel-cell').forEach((el) => {
|
||
el.classList.remove('is-excel-col', 'is-excel-rowhead', 'is-excel-cell')
|
||
})
|
||
if (!excelMode.value) return
|
||
|
||
const columns = copyableColumns()
|
||
const rows = bodyRows()
|
||
const axes = resolveCopyAxes({
|
||
selectedCols: [...selectedCols.value],
|
||
selectedRows: [...selectedRows.value],
|
||
columnCount: columns.length,
|
||
rowCount: rows.length
|
||
})
|
||
const indexCol = indexColumn()
|
||
|
||
selectedCols.value.forEach((index) => {
|
||
const col = columns[index]
|
||
if (!col) return
|
||
root.querySelectorAll(`th.${CSS.escape(col.id)}`).forEach((el) => {
|
||
el.classList.add('is-excel-col')
|
||
})
|
||
})
|
||
const bodies = root.querySelectorAll(
|
||
'.el-table__body-wrapper tbody, .el-table__fixed-body-wrapper tbody'
|
||
)
|
||
if (indexCol) {
|
||
selectedRows.value.forEach((index) => {
|
||
bodies.forEach((tbody) => {
|
||
const row = tbody.querySelectorAll(':scope > tr')[index]
|
||
row?.querySelector(`td.${CSS.escape(indexCol.id)}`)?.classList.add('is-excel-rowhead')
|
||
})
|
||
})
|
||
}
|
||
if (!axes) return
|
||
axes.rows.forEach((rowIndex) => {
|
||
axes.cols.forEach((colIndex) => {
|
||
const col = columns[colIndex]
|
||
if (!col) return
|
||
bodies.forEach((tbody) => {
|
||
const row = tbody.querySelectorAll(':scope > tr')[rowIndex]
|
||
row?.querySelector(`td.${CSS.escape(col.id)}`)?.classList.add('is-excel-cell')
|
||
})
|
||
})
|
||
})
|
||
}
|
||
|
||
function clearExcelSelection() {
|
||
selectedCols.value = new Set()
|
||
selectedRows.value = new Set()
|
||
colAnchor = null
|
||
rowAnchor = null
|
||
paintExcelSelection()
|
||
}
|
||
|
||
function setExcelMode(next: boolean) {
|
||
// excelCopy 已关闭时拒绝进入;仍允许关掉,否则 prop 变 false 后无法退出。
|
||
if (!props.excelCopy && next) return
|
||
excelMode.value = next
|
||
if (!next) clearExcelSelection()
|
||
else paintExcelSelection()
|
||
tableRoot()?.dispatchEvent(
|
||
new CustomEvent('sx-list-excel-state', {
|
||
bubbles: true,
|
||
detail: { active: excelMode.value }
|
||
})
|
||
)
|
||
}
|
||
|
||
function clickedRowIndex(row: HTMLTableRowElement): number {
|
||
const rows = bodyRows()
|
||
const direct = rows.indexOf(row)
|
||
if (direct >= 0) return direct
|
||
const tbody = row.parentElement
|
||
if (!tbody) return -1
|
||
const index = Array.from(tbody.querySelectorAll(':scope > tr')).indexOf(row)
|
||
if (index < 0 || index >= rows.length) return -1
|
||
return index
|
||
}
|
||
|
||
function keepCopyFocus() {
|
||
const root = tableRoot()
|
||
if (!root) return
|
||
const panel = root.closest('.panel')
|
||
const active = document.activeElement
|
||
if (panel && active instanceof Node && panel.contains(active)) return
|
||
if (!root.hasAttribute('tabindex')) root.setAttribute('tabindex', '-1')
|
||
root.focus({ preventScroll: true })
|
||
}
|
||
|
||
function onTableClick(event: MouseEvent) {
|
||
if (!excelMode.value || !props.excelCopy) return
|
||
const target = event.target as HTMLElement | null
|
||
if (!target) return
|
||
const header = target.closest('th')
|
||
const cell = target.closest('td')
|
||
if (header) {
|
||
const columns = copyableColumns()
|
||
const index = columns.findIndex((col) => header.classList.contains(col.id))
|
||
if (index < 0) return
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
const result = toggleAxis(selectedCols.value, index, event.shiftKey, colAnchor)
|
||
selectedCols.value = result.selected
|
||
colAnchor = result.anchor
|
||
paintExcelSelection()
|
||
keepCopyFocus()
|
||
return
|
||
}
|
||
if (!cell) return
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
keepCopyFocus()
|
||
const indexCol = indexColumn()
|
||
const row = cell.closest('tr')
|
||
if (!indexCol || !row || !cell.classList.contains(indexCol.id)) return
|
||
const rowIndex = clickedRowIndex(row)
|
||
if (rowIndex < 0) return
|
||
const result = toggleAxis(selectedRows.value, rowIndex, event.shiftKey, rowAnchor)
|
||
selectedRows.value = result.selected
|
||
rowAnchor = result.anchor
|
||
paintExcelSelection()
|
||
}
|
||
|
||
function onTableDblClick(event: MouseEvent) {
|
||
if (!excelMode.value || !props.excelCopy) return
|
||
const target = event.target as HTMLElement | null
|
||
if (!target?.closest('td, th')) return
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
}
|
||
|
||
function currentTsv(): string {
|
||
const columns = copyableColumns()
|
||
const rows = bodyRows()
|
||
const axes = resolveCopyAxes({
|
||
selectedCols: [...selectedCols.value],
|
||
selectedRows: [...selectedRows.value],
|
||
columnCount: columns.length,
|
||
rowCount: rows.length
|
||
})
|
||
if (!axes) return ''
|
||
const headers = axes.cols.map((index) => columns[index]?.label ?? '')
|
||
const matrix = axes.rows.map((rowIndex) =>
|
||
axes.cols.map((colIndex) => {
|
||
const col = columns[colIndex]
|
||
const td = col ? rows[rowIndex]?.querySelector(`td.${CSS.escape(col.id)}`) : null
|
||
return excelCell(td?.textContent ?? '')
|
||
})
|
||
)
|
||
return buildExcelTsv(headers, matrix)
|
||
}
|
||
|
||
async function copyExcelSelection() {
|
||
const text = currentTsv()
|
||
if (!text) return
|
||
try {
|
||
await navigator.clipboard.write([
|
||
new ClipboardItem({ 'text/plain': new Blob([text], { type: 'text/plain' }) })
|
||
])
|
||
ElMessage.success('已复制,到 Excel 中直接粘贴')
|
||
} catch {
|
||
const area = document.createElement('textarea')
|
||
area.value = text
|
||
area.setAttribute('readonly', 'true')
|
||
area.style.position = 'fixed'
|
||
area.style.left = '-9999px'
|
||
document.body.appendChild(area)
|
||
area.select()
|
||
const ok = document.execCommand('copy')
|
||
area.remove()
|
||
if (ok) ElMessage.success('已复制,到 Excel 中直接粘贴')
|
||
else ElMessage.warning('复制失败,请允许剪贴板权限后重试')
|
||
}
|
||
}
|
||
|
||
function onKeydown(event: KeyboardEvent) {
|
||
if (!excelMode.value || !props.excelCopy) return
|
||
const root = tableRoot()
|
||
const panel = root?.closest('.panel')
|
||
const target = event.target as Node | null
|
||
if (panel && target && !panel.contains(target)) return
|
||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'c') {
|
||
if (!currentTsv()) return
|
||
event.preventDefault()
|
||
void copyExcelSelection()
|
||
}
|
||
if (event.key === 'Escape') clearExcelSelection()
|
||
}
|
||
|
||
function onExcelToggle() {
|
||
setExcelMode(!excelMode.value)
|
||
}
|
||
|
||
function bindExcelEvents() {
|
||
const root = tableRoot()
|
||
root?.addEventListener('click', onTableClick, true)
|
||
root?.addEventListener('dblclick', onTableDblClick, true)
|
||
root?.addEventListener('sx-list-excel-toggle', onExcelToggle)
|
||
document.addEventListener('keydown', onKeydown)
|
||
}
|
||
|
||
function unbindExcelEvents() {
|
||
const root = tableRoot()
|
||
root?.removeEventListener('click', onTableClick, true)
|
||
root?.removeEventListener('dblclick', onTableDblClick, true)
|
||
root?.removeEventListener('sx-list-excel-toggle', onExcelToggle)
|
||
document.removeEventListener('keydown', onKeydown)
|
||
}
|
||
|
||
onMounted(() => {
|
||
scheduleApply()
|
||
// 首屏列尚未完全注册时再补一次
|
||
mountApplyTimer = setTimeout(() => scheduleApply(), 100)
|
||
bindExcelEvents()
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
unbindExcelEvents()
|
||
})
|
||
|
||
onDeactivated(() => {
|
||
clearApplyTimers()
|
||
})
|
||
|
||
onActivated(() => {
|
||
scheduleApply()
|
||
})
|
||
|
||
watch(
|
||
() => (attrs as any).data,
|
||
() => {
|
||
scheduleApply()
|
||
if (excelMode.value) clearExcelSelection()
|
||
},
|
||
{ deep: false }
|
||
)
|
||
|
||
watch(
|
||
() => props.tableKey,
|
||
() => scheduleApply()
|
||
)
|
||
|
||
// 列显隐导致列数量变化时写回(避免在 drag 过程中用 onUpdated 反复 apply)
|
||
watch(
|
||
() => {
|
||
const cols = tableRef.value?.store?.states?.columns
|
||
return unref(cols)?.length ?? 0
|
||
},
|
||
() => {
|
||
scheduleApply()
|
||
if (excelMode.value) clearExcelSelection()
|
||
}
|
||
)
|
||
|
||
watch(
|
||
() =>
|
||
readColumns()
|
||
.map((col) => col.id)
|
||
.join('|'),
|
||
() => {
|
||
if (excelMode.value) clearExcelSelection()
|
||
}
|
||
)
|
||
|
||
watch(
|
||
() => props.excelCopy,
|
||
(enabled) => {
|
||
if (!enabled) setExcelMode(false)
|
||
}
|
||
)
|
||
|
||
/**
|
||
* 代理底层 el-table,保证父组件 ref 上的 clearSelection / setCurrentRow / doLayout 等仍可用。
|
||
* 必须实现 has:Vue exposeProxy 用 `key in target` 决定是否转发,空对象 Proxy 若不声明 has
|
||
* 则 `in` 恒为 false,父组件拿到的方法永远是 undefined。
|
||
*/
|
||
const exposed = new Proxy(
|
||
{},
|
||
{
|
||
get(_target, prop) {
|
||
if (prop === 'elTableRef') return tableRef
|
||
if (prop === 'applyColumnWidths') {
|
||
return () => applyToTable(tableRef.value)
|
||
}
|
||
if (prop === 'resetColumnWidths') {
|
||
return () => {
|
||
resetWidths()
|
||
}
|
||
}
|
||
const table = tableRef.value
|
||
if (!table) return undefined
|
||
const value = table[prop as keyof typeof table]
|
||
return typeof value === 'function' ? value.bind(table) : value
|
||
},
|
||
has(_target, prop) {
|
||
if (prop === 'elTableRef' || prop === 'applyColumnWidths' || prop === 'resetColumnWidths') {
|
||
return true
|
||
}
|
||
const table = tableRef.value
|
||
return !!table && prop in table
|
||
}
|
||
}
|
||
)
|
||
|
||
defineExpose(exposed)
|
||
</script>
|