2026-10-19-web-list-excel-copy.md 22 KB

Web 列表 Excel 行列复制 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 在已有 ListToolbarEndActions 的业务列表上,于刷新图标左侧提供 Excel 复制开关;打开后点表头多选列、点序号多选行,Ctrl/Cmd + C 把当前页可见文字复制成 Excel 可直接粘贴的制表符文本。

Architecture: 选择范围、列过滤、TSV 拼装放在纯函数 list-excel-copy.ts,用 node:test 锁住。ListTable 负责模式、点击、高亮和剪贴板。ListToolbarEndActions 只渲染图标,并用面板内的自定义事件切换第一张允许复制的表。业务页面不改。

Tech Stack: Vue 3、Element Plus、TypeScript、pnpm exec tsx --test。

Spec: docs/superpowers/specs/2026-10-19-web-list-excel-copy-design.md

Demo: docs/fresh-mint-list-excel-select-demo.html


File map

File Responsibility
web-admin/src/utils/list-excel-copy.ts 可复制列判断、Shift 多选、复制轴、单元格清洗、TSV
web-admin/src/utils/list-excel-copy.test.ts 上述纯函数单测
web-admin/src/components/business/list-table/index.vue Excel 模式、点击、高亮、复制、随数据/列变化清空
web-admin/src/components/business/list-toolbar-end-actions/index.vue 刷新左侧图标、打开态、向面板内第一张表发切换事件
web-admin/src/assets/styles/core/app.scss 选中表头、序号格、复制格子的浅绿高亮

不新增依赖。不改各业务 index.vue。


Task 1: 纯函数 — 列过滤、多选、TSV

Files:

  • Create: web-admin/src/utils/list-excel-copy.ts
  • Test: web-admin/src/utils/list-excel-copy.test.ts

  • [ ] Step 1: 写失败测试

创建 web-admin/src/utils/list-excel-copy.test.ts:

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: 'c1', label: '客户名称' }), true)
  })

  it('drops selection, index, expand, 操作, and col-actions', () => {
    assert.equal(isCopyableColumn({ id: 'a', label: '', type: 'selection' }), false)
    assert.equal(isCopyableColumn({ id: 'b', label: '序号', type: 'index' }), false)
    assert.equal(isCopyableColumn({ id: 'c', label: '', type: 'expand' }), false)
    assert.equal(isCopyableColumn({ id: 'd', label: '操作' }), false)
    assert.equal(
      isCopyableColumn({ id: 'e', label: '更多', className: 'foo col-actions' }),
      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 it as the anchor', () => {
    const first = toggleAxis(new Set(), 1, false, null)
    assert.deepEqual([...first.selected], [1])
    assert.equal(first.anchor, 1)
    const second = toggleAxis(first.selected, 1, false, first.anchor)
    assert.deepEqual([...second.selected], [])
    assert.equal(second.anchor, 1)
  })

  it('shift-selects the inclusive span and keeps earlier picks', () => {
    const first = toggleAxis(new Set([0]), 0, false, null)
    const span = toggleAxis(first.selected, 3, true, first.anchor)
    assert.deepEqual([...span.selected].sort((a, b) => a - b), [0, 1, 2, 3])
    assert.equal(span.anchor, 3)
  })

  it('shift without an anchor toggles the clicked index', () => {
    const result = toggleAxis(new Set(), 2, true, null)
    assert.deepEqual([...result.selected], [2])
    assert.equal(result.anchor, 2)
  })
})

describe('resolveCopyAxes', () => {
  it('returns null when nothing is selected', () => {
    assert.equal(
      resolveCopyAxes({ selectedCols: [], selectedRows: [], columnCount: 3, rowCount: 4 }),
      null
    )
  })

  it('expands columns to every current row', () => {
    assert.deepEqual(
      resolveCopyAxes({ selectedCols: [2, 0], selectedRows: [], columnCount: 3, rowCount: 2 }),
      { cols: [0, 2], rows: [0, 1] }
    )
  })

  it('expands rows to every copyable column', () => {
    assert.deepEqual(
      resolveCopyAxes({ selectedCols: [], selectedRows: [3, 1], columnCount: 2, rowCount: 4 }),
      { cols: [0, 1], rows: [1, 3] }
    )
  })

  it('uses the intersection when both axes are selected', () => {
    assert.deepEqual(
      resolveCopyAxes({ selectedCols: [1], selectedRows: [0, 2], columnCount: 3, rowCount: 4 }),
      { cols: [1], rows: [0, 2] }
    )
  })
})

describe('buildExcelTsv', () => {
  it('puts labels on the first row and joins cells with tabs and CRLF', () => {
    const text = buildExcelTsv(
      ['联系人', '状态'],
      [
        ['张三', '合作中'],
        ['李四', '跟进中']
      ]
    )
    assert.equal(text, '联系人\t状态\r\n张三\t合作中\r\n李四\t跟进中')
  })
})
  • Step 2: 运行测试,确认失败

Run: cd web-admin && pnpm exec tsx --test src/utils/list-excel-copy.test.ts

Expected: FAIL,list-excel-copy 模块不存在,或导出未定义。

  • Step 3: 写最小实现

创建 web-admin/src/utils/list-excel-copy.ts:

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)
}
  • Step 4: 运行测试,确认通过

Run: cd web-admin && pnpm exec tsx --test src/utils/list-excel-copy.test.ts

Expected: PASS,5 个 describe 全部通过。

  • Step 5: Commit
git add web-admin/src/utils/list-excel-copy.ts web-admin/src/utils/list-excel-copy.test.ts
git commit -m "$(cat <<'EOF'
feat: add pure helpers for list excel row and column copy

EOF
)"

Task 2: ListTable 进入 Excel 模式并复制当前页

Files:

  • Modify: web-admin/src/components/business/list-table/index.vue

本任务不挂工具栏图标。组件监听原生事件 sx-list-excel-toggle,供 Task 3 的按钮触发。单测已覆盖拼装规则;这里把 DOM 接上。

  • Step 1: 给表格根节点打标,并增加 excelCopy

在 list-table/index.vue 的 el-table 上增加:

:data-list-excel-copy="excelCopy ? 'on' : undefined"
:class="tableClass"

tableClass 改为:

const tableClass = computed(() => {
  const extra = attrs.class
  return ['list-table', { 'is-excel-copy': excelMode.value }, extra]
})

props 增加,默认开启:

excelCopy?: boolean

默认值 excelCopy: true。

脚本增加:

import { ElMessage } from 'element-plus'
import {
  buildExcelTsv,
  excelCell,
  isCopyableColumn,
  resolveCopyAxes,
  toggleAxis,
  type ListExcelColumn
} from '@/utils/list-excel-copy'

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
  • Step 2: 从 Element Plus 列状态读取可复制列

在同一 <script setup> 增加:

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'))
}

只查询 .el-table__body-wrapper,不读左右固定列的克隆表,避免同一格复制两次。

  • Step 3: 高亮将要复制的格子
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) {
  if (!props.excelCopy) return
  excelMode.value = next
  if (!next) clearExcelSelection()
  else paintExcelSelection()
  tableRoot()?.dispatchEvent(
    new CustomEvent('sx-list-excel-state', { bubbles: true, detail: { active: excelMode.value } })
  )
}

selectedCols 的下标是 copyableColumns() 的下标,不是含勾选列在内的原始下标。

  • Step 4: 捕获点击,列表模式下什么都不拦截

在 onMounted 里对 tableRoot() 注册捕获阶段监听,并在 onBeforeUnmount 移除。

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()
    return
  }
  if (!cell) return
  event.preventDefault()
  event.stopPropagation()
  const indexCol = indexColumn()
  const row = cell.closest('tr')
  if (!indexCol || !row || !cell.classList.contains(indexCol.id)) return
  const rowIndex = bodyRows().indexOf(row as HTMLTableRowElement)
  if (rowIndex < 0) return
  const result = toggleAxis(selectedRows.value, rowIndex, event.shiftKey, rowAnchor)
  selectedRows.value = result.selected
  rowAnchor = result.anchor
  paintExcelSelection()
}

序号列表头的 th 不在 copyableColumns() 里,所以上面的 index < 0 会直接返回,不会选中「全部行」。数据格、勾选框、操作按钮会 preventDefault + stopPropagation,因此 Excel 模式下名称链接、勾选和编辑不会触发。

  • Step 5: Ctrl/Cmd + C 写剪贴板
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()
}

document 上注册 onKeydown。没有选区时不拦截 Ctrl/Cmd + C。

  • Step 6: 切换、翻页、列变化时清空
function onExcelToggle() {
  setExcelMode(!excelMode.value)
}

tableRoot() 上监听 sx-list-excel-toggle。

已有的 watch(() => (attrs as any).data, ...) 里追加:若 excelMode 为真,调用 clearExcelSelection()。不要因此退出 Excel 模式。

已有的列数量 watch 里同样调用 clearExcelSelection()。列显隐导致列数不变时,再监听列 id 拼接串:

watch(
  () => readColumns().map((col) => col.id).join('|'),
  () => {
    if (excelMode.value) clearExcelSelection()
  }
)

excelCopy 变为 false 时调用 setExcelMode(false)。

  • Step 7: 类型检查该文件无新增报错

Run: cd web-admin && pnpm exec vue-tsc --noEmit --pretty false

Expected: 无 list-table/index.vue 的新增错误。仓库里若已有无关的 vue-tsc 错误,只确认本文件不在新增报错里。

  • Step 8: Commit
git add web-admin/src/components/business/list-table/index.vue
git commit -m "$(cat <<'EOF'
feat: copy visible list cells as an excel sheet from ListTable

EOF
)"

Task 3: 刷新图标左侧的 Excel 开关

Files:

  • Modify: web-admin/src/components/business/list-toolbar-end-actions/index.vue

  • [ ] Step 1: 在刷新按钮前插入图标按钮

模板里,刷新 el-tooltip 之前插入:

<el-tooltip v-if="showExcelCopy" :content="excelActive ? '退出 Excel 复制' : 'Excel 复制'" placement="top">
  <el-button
    class="list-toolbar-end-btn"
    :class="{ 'is-excel-active': excelActive }"
    :aria-pressed="excelActive"
    aria-label="Excel 复制"
    @click="toggleExcelCopy"
  >
    <svg class="list-excel-icon" viewBox="0 0 16 16" aria-hidden="true">
      <rect x="1.5" y="2" width="13" height="12" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4" />
      <path d="M1.5 6.2h13M1.5 10h13M6.2 2v12M10.4 2v12" stroke="currentColor" stroke-width="1.1" />
    </svg>
  </el-button>
</el-tooltip>

props 增加 showExcelCopy?: boolean,默认 true。

脚本:

const excelActive = ref(false)
const rootEl = ref<HTMLElement | null>(null)

function controlledTable(): HTMLElement | null {
  const panel = rootEl.value?.closest('.panel')
  const table = panel?.querySelector('[data-list-excel-copy="on"]')
  return table instanceof HTMLElement ? table : null
}

function toggleExcelCopy() {
  controlledTable()?.dispatchEvent(new CustomEvent('sx-list-excel-toggle'))
}

function onExcelState(event: Event) {
  const custom = event as CustomEvent<{ active?: boolean }>
  if (event.target !== controlledTable()) return
  excelActive.value = !!custom.detail?.active
}

根节点改为 <div ref="rootEl" class="list-toolbar-end" @sx-list-excel-state="onExcelState">。sx-list-excel-state 从表格冒泡到面板时,这个 div 不一定是祖先。表格在工具栏后面,事件冒泡到不了工具栏。改为在 onMounted 给 closest('.panel') 注册捕获监听 sx-list-excel-state,onBeforeUnmount 移除。

is-excel-active 样式加在现有 scoped 样式里,复用刷新按钮的悬停色:

.list-toolbar-end-btn.is-excel-active {
  color: var(--mint-primary-dark);
  background: var(--mint-primary-bg);
}

.list-excel-icon {
  width: 16px;
  height: 16px;
  display: block;
}
  • Step 2: 确认按钮顺序与默认显示

打开任意已使用 ListToolbarEndActions 的页面源码,确认调用处没有被本任务修改。组件默认 showExcelCopy=true,因此客户列表等页面会自动出现图标。需要关掉的页面以后传 :show-excel-copy="false"。

  • Step 3: Commit
git add web-admin/src/components/business/list-toolbar-end-actions/index.vue
git commit -m "$(cat <<'EOF'
feat: add excel copy toggle beside the list refresh icon

EOF
)"

Task 4: 选区高亮

Files:

  • Modify: web-admin/src/assets/styles/core/app.scss

  • [ ] Step 1: 在列表表格样式附近追加高亮

在已有 .list-table 规则旁追加。颜色只用 Fresh Mint token,不用小程序绿 #0a9f6d。

.list-table.is-excel-copy {
  th.is-excel-col,
  td.is-excel-rowhead {
    background: var(--mint-primary-bg) !important;
    color: var(--mint-primary-dark);
  }

  td.is-excel-cell {
    background: rgba(16, 185, 129, 0.16) !important;
    box-shadow: inset 0 0 0 1px var(--mint-primary-dark);
  }
}

Task 2 已同时给主表体和固定列克隆表涂 is-excel-cell / is-excel-rowhead。本任务只提供颜色。

  • Step 2: Commit
git add web-admin/src/assets/styles/core/app.scss
git commit -m "$(cat <<'EOF'
feat: highlight list cells selected for excel copy

EOF
)"

Task 5: 按规格验收

Files:

  • 无新文件。浏览器核对客户列表。

  • [ ] Step 1: 再跑纯函数测试

Run: cd web-admin && pnpm exec tsx --test src/utils/list-excel-copy.test.ts

Expected: PASS。

  • Step 2: 打开客户列表核对七条验收

启动 web-admin 后打开客户列表:

  1. 刷新图标左侧有表格线框图标。悬停提示「Excel 复制」。
  2. 未打开时,点客户名称仍打开查看,勾选框仍计入已选,表头筛选仍能打开。
  3. 点图标后按钮保持浅绿底,提示变为「退出 Excel 复制」。点两列表头、两行序号,Ctrl/Cmd + C。粘贴到 Excel 是这两列、这两行,第一行是列名。
  4. Shift 再点更远的表头,中间列被选中。再点已选表头,该列取消。
  5. 复制文本里没有勾选框、序号、操作按钮。状态是「合作中」这类文字。
  6. Escape 只清高亮,图标仍为打开。再点图标,高亮消失,名称点击恢复。翻页后高亮消失,模式仍为打开。
  7. 给一个选择器弹窗里的 ListTable 设 excel-copy="false" 时,面板里若还有主表,图标仍控制主表;若该面板只有这一张表,图标点击无效果。本任务不必改业务弹窗,只在本地临时加上属性看一眼,确认后撤销。
  • Step 3: 更新代码图谱

Run: ./scripts/update-graph.sh frontend

Expected: 命令结束且没有因为本改动失败。图谱产物与本次功能代码分开提交,或按仓库既有习惯随功能一起提交。不要提交与本功能无关的已有脏文件。