Files
shanxia2.0/web-admin/src/components/business/list-table/index.vue
T

487 lines
14 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.
<!--
主列表薄封装:在 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>