소스 검색

feat: add pure helpers for list excel row and column copy

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 3 주 전
부모
커밋
aae4465f6c
2개의 변경된 파일과 184개의 추가작업 그리고 0개의 파일을 삭제
  1. 120 0
      web-admin/src/utils/list-excel-copy.test.ts
  2. 64 0
      web-admin/src/utils/list-excel-copy.ts

+ 120 - 0
web-admin/src/utils/list-excel-copy.test.ts

@@ -0,0 +1,120 @@
+import assert from 'node:assert/strict'
+import { describe, it } from 'node:test'
+import {
+  buildExcelTsv,
+  excelCell,
+  isCopyableColumn,
+  resolveCopyAxes,
+  toggleAxis
+} from './list-excel-copy'
+
+describe('isCopyableColumn', () => {
+  it('keeps ordinary data columns', () => {
+    assert.equal(isCopyableColumn({ id: 'name', label: '联系人' }), true)
+  })
+
+  it('drops selection, index, expand, label 操作, and className containing col-actions', () => {
+    assert.equal(isCopyableColumn({ id: 's', label: '', type: 'selection' }), false)
+    assert.equal(isCopyableColumn({ id: 'i', label: '', type: 'index' }), false)
+    assert.equal(isCopyableColumn({ id: 'e', label: '', type: 'expand' }), false)
+    assert.equal(isCopyableColumn({ id: 'a', label: '操作' }), false)
+    assert.equal(
+      isCopyableColumn({ id: 'act', label: '编辑', className: 'col-actions fixed' }),
+      false
+    )
+  })
+})
+
+describe('excelCell', () => {
+  it('trims and turns tabs and line breaks into spaces', () => {
+    assert.equal(excelCell('  合作中 \n'), '合作中')
+    assert.equal(excelCell('重庆\t长寿\r\n区'), '重庆 长寿 区')
+  })
+})
+
+describe('toggleAxis', () => {
+  it('toggles one index and remembers anchor; second toggle of same index removes it but keeps anchor', () => {
+    let selected = new Set<number>()
+    let anchor: number | null = null
+
+    const first = toggleAxis(selected, 2, false, anchor)
+    selected = first.selected
+    anchor = first.anchor
+    assert.deepEqual([...selected].sort(), [2])
+    assert.equal(anchor, 2)
+
+    const second = toggleAxis(selected, 2, false, anchor)
+    selected = second.selected
+    anchor = second.anchor
+    assert.deepEqual([...selected], [])
+    assert.equal(anchor, 2)
+  })
+
+  it('shift-selects inclusive span and keeps earlier picks: start with set containing 0 and anchor 0, shift-click 3 yields 0,1,2,3 and anchor 3', () => {
+    const selected = new Set([0])
+    const result = toggleAxis(selected, 3, true, 0)
+    assert.deepEqual([...result.selected].sort((a, b) => a - b), [0, 1, 2, 3])
+    assert.equal(result.anchor, 3)
+  })
+
+  it('shift without an anchor toggles the clicked index', () => {
+    const selected = new Set<number>()
+    const result = toggleAxis(selected, 1, true, null)
+    assert.deepEqual([...result.selected], [1])
+    assert.equal(result.anchor, 1)
+  })
+})
+
+describe('resolveCopyAxes', () => {
+  it('returns null when nothing selected', () => {
+    assert.equal(
+      resolveCopyAxes({
+        selectedCols: [],
+        selectedRows: [],
+        columnCount: 3,
+        rowCount: 2
+      }),
+      null
+    )
+  })
+
+  it('expands columns to every current row, sorting cols: selectedCols [2,0], rowCount 2, columnCount 3 => cols [0,2] rows [0,1]', () => {
+    const result = resolveCopyAxes({
+      selectedCols: [2, 0],
+      selectedRows: [],
+      columnCount: 3,
+      rowCount: 2
+    })
+    assert.deepEqual(result, { cols: [0, 2], rows: [0, 1] })
+  })
+
+  it('expands rows to every copyable column: selectedRows [3,1], columnCount 2, rowCount 4 => cols [0,1] rows [1,3]', () => {
+    const result = resolveCopyAxes({
+      selectedCols: [],
+      selectedRows: [3, 1],
+      columnCount: 2,
+      rowCount: 4
+    })
+    assert.deepEqual(result, { cols: [0, 1], rows: [1, 3] })
+  })
+
+  it('intersection when both selected: selectedCols [1], selectedRows [0,2], columnCount 3, rowCount 4 => cols [1] rows [0,2]', () => {
+    const result = resolveCopyAxes({
+      selectedCols: [1],
+      selectedRows: [0, 2],
+      columnCount: 3,
+      rowCount: 4
+    })
+    assert.deepEqual(result, { cols: [1], rows: [0, 2] })
+  })
+})
+
+describe('buildExcelTsv', () => {
+  it('builds CRLF-separated TSV with header row', () => {
+    const tsv = buildExcelTsv(['联系人', '状态'], [
+      ['张三', '合作中'],
+      ['李四', '跟进中']
+    ])
+    assert.equal(tsv, '联系人\t状态\r\n张三\t合作中\r\n李四\t跟进中')
+  })
+})

+ 64 - 0
web-admin/src/utils/list-excel-copy.ts

@@ -0,0 +1,64 @@
+export type ListExcelColumn = {
+  id: string
+  label: string
+  type?: string
+  className?: string
+}
+
+export function isCopyableColumn(col: ListExcelColumn): boolean {
+  if (col.type === 'selection' || col.type === 'index' || col.type === 'expand') return false
+  if (col.label === '操作') return false
+  const classes = (col.className || '').split(/\s+/).filter(Boolean)
+  if (classes.includes('col-actions')) return false
+  return true
+}
+
+export function excelCell(value: string): string {
+  return String(value ?? '')
+    .replace(/\t/g, ' ')
+    .replace(/\r?\n/g, ' ')
+    .trim()
+}
+
+export function toggleAxis(
+  selected: Set<number>,
+  index: number,
+  shift: boolean,
+  anchor: number | null
+): { selected: Set<number>; anchor: number } {
+  const next = new Set(selected)
+  if (shift && anchor !== null) {
+    const from = Math.min(anchor, index)
+    const to = Math.max(anchor, index)
+    for (let i = from; i <= to; i += 1) next.add(i)
+    return { selected: next, anchor: index }
+  }
+  if (next.has(index)) next.delete(index)
+  else next.add(index)
+  return { selected: next, anchor: index }
+}
+
+export function resolveCopyAxes(input: {
+  selectedCols: number[]
+  selectedRows: number[]
+  columnCount: number
+  rowCount: number
+}): { cols: number[]; rows: number[] } | null {
+  const { selectedCols, selectedRows, columnCount, rowCount } = input
+  if (!selectedCols.length && !selectedRows.length) return null
+  const cols = (selectedCols.length ? selectedCols : range(columnCount)).slice().sort((a, b) => a - b)
+  const rows = (selectedRows.length ? selectedRows : range(rowCount)).slice().sort((a, b) => a - b)
+  return { cols, rows }
+}
+
+export function buildExcelTsv(headers: string[], rows: string[][]): string {
+  const lines = [headers.map(excelCell).join('\t')]
+  rows.forEach((row) => {
+    lines.push(row.map(excelCell).join('\t'))
+  })
+  return lines.join('\r\n')
+}
+
+function range(count: number): number[] {
+  return Array.from({ length: count }, (_, index) => index)
+}