diff --git a/web-admin/src/utils/list-excel-copy.test.ts b/web-admin/src/utils/list-excel-copy.test.ts new file mode 100644 index 000000000..d4e5a7fd4 --- /dev/null +++ b/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() + 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() + 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跟进中') + }) +}) diff --git a/web-admin/src/utils/list-excel-copy.ts b/web-admin/src/utils/list-excel-copy.ts new file mode 100644 index 000000000..8351343a5 --- /dev/null +++ b/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, + index: number, + shift: boolean, + anchor: number | null +): { selected: Set; 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) +}