Break the approved design into TDD-friendly tasks for the quote management list interactive summary row. Co-authored-by: Cursor <cursoragent@cursor.com>
17 KiB
报价单列表可配置列统计行 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: 在【报价单管理】主列表底部增加可点选的列统计行:默认空;用户按列选择求和/平均/最大/最小后,对当前页数据即时展示,并写入本机 localStorage。
Architecture: 纯函数放在 quoteListColumnStats.ts(聚合 + 文案 + storage);QuoteListPage.vue 在主表与分页之间渲染自定义统计行(不用 show-summary)。不改 ListTable、不改后端;其它列表本轮不接入。
Tech Stack: Vue 3、Element Plus(el-dropdown)、TypeScript、localStorage、Node 内置 test + tsx
Spec: docs/superpowers/specs/2026-08-16-quote-list-column-stats-design.md
File map
| 文件 | 职责 |
|---|---|
web-admin/src/views/crm/quote/quoteListColumnStats.ts |
类型、解析数字、聚合、展示文案、localStorage |
web-admin/src/views/crm/quote/quoteListColumnStats.test.ts |
纯函数单测(node:test + tsx) |
web-admin/src/views/crm/quote/QuoteListPage.vue |
统计行 UI、列显隐同步、取值适配(含 finalAmount) |
Task 1: 纯函数模块 + 失败单测骨架
Files:
-
Create:
web-admin/src/views/crm/quote/quoteListColumnStats.ts -
Create:
web-admin/src/views/crm/quote/quoteListColumnStats.test.ts -
Step 1: 写入完整纯函数模块
// web-admin/src/views/crm/quote/quoteListColumnStats.ts
export type ColumnStatMethod = 'sum' | 'avg' | 'max' | 'min'
export const COLUMN_STAT_STORAGE_KEY = 'crm-quote-column-stats'
export const COLUMN_STAT_METHOD_OPTIONS: { value: ColumnStatMethod | ''; label: string }[] = [
{ value: '', label: '无' },
{ value: 'sum', label: '求和' },
{ value: 'avg', label: '平均' },
{ value: 'max', label: '最大值' },
{ value: 'min', label: '最小值' }
]
export const COLUMN_STAT_METHOD_LABEL: Record<ColumnStatMethod, string> = {
sum: '求和',
avg: '平均',
max: '最大值',
min: '最小值'
}
/** 列表中按金额格式展示的列 prop */
export const QUOTE_MONEY_STAT_PROPS = new Set([
'discountAmount',
'finalAmount',
'totalAmount',
'freight',
'barrelDeposit'
])
export function parseStatNumber(raw: unknown): number | null {
if (raw == null || raw === '') return null
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null
let s = String(raw).trim()
if (!s) return null
s = s.replace(/^¥/, '').replace(/%$/, '').replace(/,/g, '')
const n = Number(s)
return Number.isFinite(n) ? n : null
}
function nonEmptyString(raw: unknown): string | null {
if (raw == null) return null
const s = String(raw).trim()
return s ? s : null
}
export function aggregateColumnValues(
values: unknown[],
method: ColumnStatMethod
): number | string | null {
const nums: number[] = []
const strs: string[] = []
for (const v of values) {
const n = parseStatNumber(v)
if (n != null) nums.push(n)
else {
const s = nonEmptyString(v)
if (s != null) strs.push(s)
}
}
if (method === 'sum') {
if (!nums.length) return null
return nums.reduce((a, b) => a + b, 0)
}
if (method === 'avg') {
if (!nums.length) return null
return nums.reduce((a, b) => a + b, 0) / nums.length
}
if (method === 'max') {
if (nums.length) return Math.max(...nums)
if (strs.length) return strs.reduce((a, b) => (a >= b ? a : b))
return null
}
// min
if (nums.length) return Math.min(...nums)
if (strs.length) return strs.reduce((a, b) => (a <= b ? a : b))
return null
}
export function formatStatNumberResult(
value: number,
opts: { money?: boolean } = {}
): string {
if (opts.money) {
return value.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
}
if (Number.isInteger(value)) return String(value)
return value.toLocaleString('zh-CN', { maximumFractionDigits: 4 })
}
/**
* 生成合计格展示文案。method 为空则返回 ''。
* 聚合结果为 null →「{标签} -」
*/
export function formatColumnStatDisplay(
method: ColumnStatMethod | '' | null | undefined,
values: unknown[],
opts: { money?: boolean } = {}
): string {
if (!method) return ''
const label = COLUMN_STAT_METHOD_LABEL[method]
const agg = aggregateColumnValues(values, method)
if (agg == null) return `${label} -`
if (typeof agg === 'number') {
return `${label} ${formatStatNumberResult(agg, opts)}`
}
return `${label} ${agg}`
}
export type ColumnStatConfigMap = Partial<Record<string, ColumnStatMethod>>
export function loadColumnStatConfig(
storageKey: string = COLUMN_STAT_STORAGE_KEY
): ColumnStatConfigMap {
try {
const raw = localStorage.getItem(storageKey)
if (!raw) return {}
const parsed = JSON.parse(raw) as Record<string, unknown>
if (!parsed || typeof parsed !== 'object') return {}
const out: ColumnStatConfigMap = {}
for (const [k, v] of Object.entries(parsed)) {
if (v === 'sum' || v === 'avg' || v === 'max' || v === 'min') out[k] = v
}
return out
} catch {
return {}
}
}
export function saveColumnStatConfig(
config: ColumnStatConfigMap,
storageKey: string = COLUMN_STAT_STORAGE_KEY
): void {
const cleaned: ColumnStatConfigMap = {}
for (const [k, v] of Object.entries(config)) {
if (v === 'sum' || v === 'avg' || v === 'max' || v === 'min') cleaned[k] = v
}
localStorage.setItem(storageKey, JSON.stringify(cleaned))
}
export function setColumnStatMethod(
config: ColumnStatConfigMap,
prop: string,
method: ColumnStatMethod | ''
): ColumnStatConfigMap {
const next = { ...config }
if (!method) delete next[prop]
else next[prop] = method
return next
}
- Step 2: 写单测(先假设实现正确;若模块尚未导出齐会 FAIL)
// web-admin/src/views/crm/quote/quoteListColumnStats.test.ts
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
aggregateColumnValues,
formatColumnStatDisplay,
parseStatNumber,
setColumnStatMethod
} from './quoteListColumnStats'
describe('parseStatNumber', () => {
it('parses plain and money-like strings', () => {
assert.equal(parseStatNumber(12.5), 12.5)
assert.equal(parseStatNumber('¥1,234.50'), 1234.5)
assert.equal(parseStatNumber('10%'), 10)
assert.equal(parseStatNumber('abc'), null)
assert.equal(parseStatNumber(''), null)
})
})
describe('aggregateColumnValues', () => {
it('sum and avg over numbers only', () => {
assert.equal(aggregateColumnValues([1, 2, 'x', null], 'sum'), 3)
assert.equal(aggregateColumnValues([1, 3], 'avg'), 2)
assert.equal(aggregateColumnValues(['a', 'b'], 'sum'), null)
})
it('max/min prefer numbers then lexicographic strings', () => {
assert.equal(aggregateColumnValues([1, 9, 3], 'max'), 9)
assert.equal(aggregateColumnValues([1, 9, 3], 'min'), 1)
assert.equal(aggregateColumnValues(['张三', '李四'], 'max'), '张三')
assert.equal(aggregateColumnValues(['张三', '李四'], 'min'), '李四')
assert.equal(aggregateColumnValues([null, ''], 'max'), null)
})
})
describe('formatColumnStatDisplay', () => {
it('empty method yields empty string', () => {
assert.equal(formatColumnStatDisplay('', [1, 2]), '')
})
it('formats money sum and dash for non-numeric sum', () => {
assert.equal(
formatColumnStatDisplay('sum', [1000, 234.5], { money: true }),
'求和 1,234.50'
)
assert.equal(formatColumnStatDisplay('sum', ['甲', '乙']), '求和 -')
assert.equal(formatColumnStatDisplay('max', ['甲', '乙']), '最大值 乙')
})
})
describe('setColumnStatMethod', () => {
it('sets and clears props', () => {
const a = setColumnStatMethod({}, 'totalAmount', 'sum')
assert.deepEqual(a, { totalAmount: 'sum' })
const b = setColumnStatMethod(a, 'totalAmount', '')
assert.deepEqual(b, {})
})
})
- Step 3: 跑单测,期望 PASS
cd web-admin && pnpm exec tsx --test src/views/crm/quote/quoteListColumnStats.test.ts
Expected: all tests pass.
- Step 4: Commit
git add web-admin/src/views/crm/quote/quoteListColumnStats.ts \
web-admin/src/views/crm/quote/quoteListColumnStats.test.ts
git commit -m "$(cat <<'EOF'
feat(crm): add quote list column stats helpers
Pure aggregation, display formatting, and localStorage helpers
for the quote list configurable summary row.
EOF
)"
Task 2: QuoteListPage 接入统计行 UI
Files:
-
Modify:
web-admin/src/views/crm/quote/QuoteListPage.vue -
Step 1: 在 script 中增加 import、状态与辅助逻辑
在现有 script 适当位置(imports 区与 quoteList / isColumnVisible 附近)加入:
import {
COLUMN_STAT_METHOD_OPTIONS,
QUOTE_MONEY_STAT_PROPS,
formatColumnStatDisplay,
loadColumnStatConfig,
saveColumnStatConfig,
setColumnStatMethod,
type ColumnStatConfigMap,
type ColumnStatMethod
} from './quoteListColumnStats'
固定始终展示、且可统计的列(不在 listColumnDefs 的显隐里,但主表始终有):
const ALWAYS_STAT_PROPS = ['quoteNo', 'quoteTitle'] as const
const columnStatConfig = ref<ColumnStatConfigMap>(loadColumnStatConfig())
/** 统计行业务列:报价单号/主题 + 当前可见可选列,顺序与主表一致 */
const statsBusinessColumns = computed(() => {
const cols: { prop: string; money: boolean }[] = [
{ prop: 'quoteNo', money: false },
{ prop: 'quoteTitle', money: false }
]
for (const def of listColumnDefs) {
if (!isColumnVisible(def.prop)) continue
cols.push({
prop: def.prop,
money: QUOTE_MONEY_STAT_PROPS.has(def.prop)
})
}
return cols
})
function getStatCellValues(prop: string): unknown[] {
return quoteList.value.map((row) => {
if (prop === 'finalAmount') return getQuoteFinalAmount(row)
if (prop === 'productInfo') {
const lines = formatQuoteProductInfoLines(row)
return lines.length ? lines.join('\n') : null
}
return (row as Record<string, unknown>)[prop]
})
}
function statsCellText(prop: string, money: boolean): string {
const method = columnStatConfig.value[prop]
return formatColumnStatDisplay(method, getStatCellValues(prop), { money })
}
function onSelectColumnStat(prop: string, method: ColumnStatMethod | '') {
columnStatConfig.value = setColumnStatMethod(columnStatConfig.value, prop, method)
saveColumnStatConfig(columnStatConfig.value)
}
注意:getQuoteFinalAmount / formatQuoteProductInfoLines 须在这些函数之前已定义,或把上述函数放到它们之后。
- Step 2: 在模板中于
</ListTable>与分页之间插入统计行
紧接 </ListTable> 之后、<div class="pagination list-pager-bar"> 之前插入:
<div class="quote-list-stats-row" aria-label="列表列统计">
<div class="quote-list-stats-row__fixed">
<div class="quote-list-stats-row__cell is-fixed" style="width: 55px" />
<div class="quote-list-stats-row__cell is-fixed" style="width: 55px" />
</div>
<div class="quote-list-stats-row__scroll">
<div
v-for="col in statsBusinessColumns"
:key="col.prop"
class="quote-list-stats-row__cell is-stat"
>
<el-dropdown trigger="click" @command="(cmd: string) => onSelectColumnStat(col.prop, cmd as any)">
<span class="quote-list-stats-row__trigger" :class="{ 'is-empty': !statsCellText(col.prop, col.money) }">
{{ statsCellText(col.prop, col.money) || '统计' }}
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="opt in COLUMN_STAT_METHOD_OPTIONS"
:key="opt.label"
:command="opt.value"
>
{{ opt.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
空态占位文案用浅色「统计」(或「—」),选中后替换为 求和 …。若产品更希望空态完全空白但可点,将 || '统计' 改为 \u00a0 并保留 min-height。
推荐空态: 显示淡色「选择统计」,避免完全空白不好点。与 spec「默认空」兼容做法:空态用 opacity 较低的「统计」占位,已选则正常色显示结果。上面模板已用 is-empty class 区分。
- Step 3: 增加 scoped 样式(对齐 Fresh Mint 合计行)
在该文件既有 <style> 中追加:
.quote-list-stats-row {
display: flex;
align-items: stretch;
border-top: 2px solid var(--el-color-primary, #10b981);
background: var(--mint-primary-bg, #ecfdf5);
font-weight: 600;
color: var(--mint-text, #1e293b);
min-height: 44px;
overflow-x: auto;
}
.quote-list-stats-row__fixed {
display: flex;
flex-shrink: 0;
}
.quote-list-stats-row__scroll {
display: flex;
flex: 1;
min-width: 0;
}
.quote-list-stats-row__cell {
display: flex;
align-items: center;
justify-content: center;
padding: 8px 10px;
box-sizing: border-box;
border-right: 1px solid var(--mint-border, #e2e8f0);
}
.quote-list-stats-row__cell.is-stat {
flex: 1 1 120px;
min-width: 120px;
justify-content: flex-start;
}
.quote-list-stats-row__trigger {
cursor: pointer;
width: 100%;
text-align: left;
line-height: 1.4;
font-size: 13px;
outline: none;
}
.quote-list-stats-row__trigger.is-empty {
color: var(--mint-text-muted, #64748b);
font-weight: 500;
}
- Step 4: 手动验收(dev)
cd web-admin && pnpm dev
打开 http://localhost:3006/#/crm/quote,按 spec §7 逐条核对:
- 合计行存在,默认业务格为空态占位
- 金额列「求和」数值正确
- 客户名称「求和」为
求和 -,「最大值」有字符串结果 - 换页方式保留、数值重算
- 刷新后 localStorage 恢复
- 隐藏列后统计格消失
- 展开/勾选对应固定格无下拉
- Step 5: Commit
git add web-admin/src/views/crm/quote/QuoteListPage.vue
git commit -m "$(cat <<'EOF'
feat(crm): add interactive column stats row on quote list
Let users pick sum/avg/max/min per column on the current page,
with localStorage persistence for the quote management list.
EOF
)"
Task 3: 列宽/横滚对齐加固(若 Step 4 发现错位)
Files:
-
Modify:
web-admin/src/views/crm/quote/QuoteListPage.vue(仅当手动验收发现统计行与表头明显错位时执行) -
Step 1: 用主表 DOM 同步 cell 宽度(可选加固)
若 flex 均分导致与 el-table 列宽不一致,在 onMounted + watch(statsBusinessColumns) 中:
const quoteTableWrapRef = ref<HTMLElement | null>(null)
function syncStatsColumnWidths() {
const table = quoteTableWrapRef.value?.querySelector('.el-table')
if (!table) return
const headerCells = table.querySelectorAll('.el-table__header-wrapper th')
// headerCells[0]=expand, [1]=selection, 之后与 statsBusinessColumns 对齐
const cells = quoteTableWrapRef.value?.querySelectorAll(
'.quote-list-stats-row__scroll .quote-list-stats-row__cell'
)
if (!cells) return
const offset = 2
cells.forEach((el, i) => {
const th = headerCells[i + offset] as HTMLElement | undefined
if (!th) return
;(el as HTMLElement).style.flex = 'none'
;(el as HTMLElement).style.width = `${th.offsetWidth}px`
;(el as HTMLElement).style.minWidth = `${th.offsetWidth}px`
})
}
给包住 ListTable + 统计行的容器加 ref="quoteTableWrapRef",并在 nextTick 调用 syncStatsColumnWidths;监听 window resize。
若 Task 2 验收对齐已可接受,本 Task 可整项勾选为跳过并在 PR 说明中注明。
- Step 2: Commit(仅当有改动)
git add web-admin/src/views/crm/quote/QuoteListPage.vue
git commit -m "$(cat <<'EOF'
fix(crm): sync quote list stats row widths to table headers
Keep the interactive summary cells aligned when columns resize.
EOF
)"
Spec coverage check
| Spec 要求 | Task |
|---|---|
| 当前页统计 | Task 2 getStatCellValues ← quoteList |
| 点选下拉 + 前缀文案 | Task 2 模板 + Task 1 formatColumnStatDisplay |
| 全业务列可配 | Task 2 statsBusinessColumns |
算不出显示 - |
Task 1 单测 + formatColumnStatDisplay |
localStorage crm-quote-column-stats |
Task 1 load/save |
| 展开/勾选不可配 | Task 2 固定空 cell |
| 不改 ListTable/后端 | 文件 map 已限制 |
| Fresh Mint 合计行视觉 | Task 2 样式 |
finalAmount 用同一取值 |
Task 2 getQuoteFinalAmount |
执行方式
Plan complete and saved to docs/superpowers/plans/2026-08-16-quote-list-column-stats.md. Two execution options:
1. Subagent-Driven (recommended) — 每个 Task 派发新子代理,Task 间复核
2. Inline Execution — 本会话按 executing-plans 连续执行并设检查点
Which approach?