Explorar el Código

feat: copy visible list cells as an excel sheet from ListTable

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong hace 1 semana
padre
commit
e33a1fbd39
Se han modificado 1 ficheros con 306 adiciones y 10 borrados
  1. 306 10
      web-admin/src/components/business/list-table/index.vue

+ 306 - 10
web-admin/src/components/business/list-table/index.vue

@@ -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