Просмотр исходного кода

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>
zlwu 2 месяцев назад
Родитель
Сommit
be55f23c21
1 измененных файлов с 550 добавлено и 0 удалено
  1. 550 0
      docs/superpowers/plans/2026-08-16-quote-list-column-stats.md

+ 550 - 0
docs/superpowers/plans/2026-08-16-quote-list-column-stats.md

@@ -0,0 +1,550 @@
+# 报价单列表可配置列统计行 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: 写入完整纯函数模块**
+
+```ts
+// 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)**
+
+```ts
+// 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**
+
+```bash
+cd web-admin && pnpm exec tsx --test src/views/crm/quote/quoteListColumnStats.test.ts
+```
+
+Expected: all tests pass.
+
+- [ ] **Step 4: Commit**
+
+```bash
+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` 附近)加入:
+
+```ts
+import {
+  COLUMN_STAT_METHOD_OPTIONS,
+  QUOTE_MONEY_STAT_PROPS,
+  formatColumnStatDisplay,
+  loadColumnStatConfig,
+  saveColumnStatConfig,
+  setColumnStatMethod,
+  type ColumnStatConfigMap,
+  type ColumnStatMethod
+} from './quoteListColumnStats'
+```
+
+固定始终展示、且可统计的列(不在 `listColumnDefs` 的显隐里,但主表始终有):
+
+```ts
+const ALWAYS_STAT_PROPS = ['quoteNo', 'quoteTitle'] as const
+```
+
+```ts
+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">` 之前插入:
+
+```vue
+        <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>` 中追加:
+
+```scss
+.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)**
+
+```bash
+cd web-admin && pnpm dev
+```
+
+打开 `http://localhost:3006/#/crm/quote`,按 spec §7 逐条核对:
+
+1. 合计行存在,默认业务格为空态占位  
+2. 金额列「求和」数值正确  
+3. 客户名称「求和」为 `求和 -`,「最大值」有字符串结果  
+4. 换页方式保留、数值重算  
+5. 刷新后 localStorage 恢复  
+6. 隐藏列后统计格消失  
+7. 展开/勾选对应固定格无下拉  
+
+- [ ] **Step 5: Commit**
+
+```bash
+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)` 中:
+
+```ts
+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(仅当有改动)**
+
+```bash
+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?