|
|
@@ -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?
|