|
|
@@ -6,6 +6,7 @@
|
|
|
<el-table
|
|
|
ref="tableRef"
|
|
|
v-bind="tableAttrs"
|
|
|
+ :data-list-excel-copy="excelCopy ? 'on' : undefined"
|
|
|
:border="resolvedBorder"
|
|
|
:allow-drag-last-column="true"
|
|
|
:class="tableClass"
|
|
|
@@ -16,7 +17,27 @@
|
|
|
</template>
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
- import { computed, nextTick, onActivated, onDeactivated, onMounted, ref, unref, useAttrs, watch } from 'vue'
|
|
|
+ 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,
|
|
|
@@ -33,10 +54,13 @@
|
|
|
persistColumnWidth?: boolean
|
|
|
/** 是否开启 border(列宽拖拽依赖);默认 true */
|
|
|
border?: boolean
|
|
|
+ /** 是否允许 Excel 行列复制,默认 true */
|
|
|
+ excelCopy?: boolean
|
|
|
}>(),
|
|
|
{
|
|
|
persistColumnWidth: true,
|
|
|
- border: true
|
|
|
+ border: true,
|
|
|
+ excelCopy: true
|
|
|
}
|
|
|
)
|
|
|
|
|
|
@@ -46,11 +70,17 @@
|
|
|
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', extra]
|
|
|
+ return ['list-table', { 'is-excel-copy': excelMode.value }, extra]
|
|
|
})
|
|
|
|
|
|
const tableAttrs = computed(() => {
|
|
|
@@ -115,10 +145,257 @@
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ 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(() => {
|
|
|
@@ -131,7 +408,10 @@
|
|
|
|
|
|
watch(
|
|
|
() => (attrs as any).data,
|
|
|
- () => scheduleApply(),
|
|
|
+ () => {
|
|
|
+ scheduleApply()
|
|
|
+ if (excelMode.value) clearExcelSelection()
|
|
|
+ },
|
|
|
{ deep: false }
|
|
|
)
|
|
|
|
|
|
@@ -146,7 +426,27 @@
|
|
|
const cols = tableRef.value?.store?.states?.columns
|
|
|
return unref(cols)?.length ?? 0
|
|
|
},
|
|
|
- () => scheduleApply()
|
|
|
+ () => {
|
|
|
+ scheduleApply()
|
|
|
+ if (excelMode.value) clearExcelSelection()
|
|
|
+ }
|
|
|
+ )
|
|
|
+
|
|
|
+ watch(
|
|
|
+ () =>
|
|
|
+ readColumns()
|
|
|
+ .map((col) => col.id)
|
|
|
+ .join('|'),
|
|
|
+ () => {
|
|
|
+ if (excelMode.value) clearExcelSelection()
|
|
|
+ }
|
|
|
+ )
|
|
|
+
|
|
|
+ watch(
|
|
|
+ () => props.excelCopy,
|
|
|
+ (enabled) => {
|
|
|
+ if (!enabled) setExcelMode(false)
|
|
|
+ }
|
|
|
)
|
|
|
|
|
|
/**
|
|
|
@@ -173,11 +473,7 @@
|
|
|
return typeof value === 'function' ? value.bind(table) : value
|
|
|
},
|
|
|
has(_target, prop) {
|
|
|
- if (
|
|
|
- prop === 'elTableRef' ||
|
|
|
- prop === 'applyColumnWidths' ||
|
|
|
- prop === 'resetColumnWidths'
|
|
|
- ) {
|
|
|
+ if (prop === 'elTableRef' || prop === 'applyColumnWidths' || prop === 'resetColumnWidths') {
|
|
|
return true
|
|
|
}
|
|
|
const table = tableRef.value
|