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 | 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。
Files:
web-admin/src/utils/list-excel-copy.tsTest: 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跟进中')
})
})
Run: cd web-admin && pnpm exec tsx --test src/utils/list-excel-copy.test.ts
Expected: FAIL,list-excel-copy 模块不存在,或导出未定义。
创建 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)
}
Run: cd web-admin && pnpm exec tsx --test src/utils/list-excel-copy.test.ts
Expected: PASS,5 个 describe 全部通过。
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
)"
Files:
web-admin/src/components/business/list-table/index.vue本任务不挂工具栏图标。组件监听原生事件 sx-list-excel-toggle,供 Task 3 的按钮触发。单测已覆盖拼装规则;这里把 DOM 接上。
在 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
在同一 <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,不读左右固定列的克隆表,避免同一格复制两次。
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() 的下标,不是含勾选列在内的原始下标。
在 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 模式下名称链接、勾选和编辑不会触发。
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。
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)。
Run: cd web-admin && pnpm exec vue-tsc --noEmit --pretty false
Expected: 无 list-table/index.vue 的新增错误。仓库里若已有无关的 vue-tsc 错误,只确认本文件不在新增报错里。
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
)"
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;
}
打开任意已使用 ListToolbarEndActions 的页面源码,确认调用处没有被本任务修改。组件默认 showExcelCopy=true,因此客户列表等页面会自动出现图标。需要关掉的页面以后传 :show-excel-copy="false"。
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
)"
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。本任务只提供颜色。
git add web-admin/src/assets/styles/core/app.scss
git commit -m "$(cat <<'EOF'
feat: highlight list cells selected for excel copy
EOF
)"
Files:
无新文件。浏览器核对客户列表。
[ ] Step 1: 再跑纯函数测试
Run: cd web-admin && pnpm exec tsx --test src/utils/list-excel-copy.test.ts
Expected: PASS。
启动 web-admin 后打开客户列表:
Ctrl/Cmd + C。粘贴到 Excel 是这两列、这两行,第一行是列名。Escape 只清高亮,图标仍为打开。再点图标,高亮消失,名称点击恢复。翻页后高亮消失,模式仍为打开。ListTable 设 excel-copy="false" 时,面板里若还有主表,图标仍控制主表;若该面板只有这一张表,图标点击无效果。本任务不必改业务弹窗,只在本地临时加上属性看一眼,确认后撤销。Run: ./scripts/update-graph.sh frontend
Expected: 命令结束且没有因为本改动失败。图谱产物与本次功能代码分开提交,或按仓库既有习惯随功能一起提交。不要提交与本功能无关的已有脏文件。