Files
shanxia2.0/docs/superpowers/plans/2026-08-16-quote-list-column-stats.md
T
zlwuandCursor be55f23c21 docs: add quote list column stats implementation plan
Break the approved design into TDD-friendly tasks for the
quote management list interactive summary row.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 15:49:16 +08:00

17 KiB
Raw Blame History

报价单列表可配置列统计行 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 逐条核对:

  1. 合计行存在,默认业务格为空态占位
  2. 金额列「求和」数值正确
  3. 客户名称「求和」为 求和 -,「最大值」有字符串结果
  4. 换页方式保留、数值重算
  5. 刷新后 localStorage 恢复
  6. 隐藏列后统计格消失
  7. 展开/勾选对应固定格无下拉
  • 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?