2026-06-26-web-admin-product-spec-field-implementation.md 16 KB

Web Admin 商品「规格」字段 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: 为商品新增可空文本字段 spec(规格),贯通 DB → 后端 API → web-admin 商品新增/编辑/列表/导出,并在所有展示「计量+单位」的业务页按规范展示(列表独立列、行内 计量单位(规格))。

Architecture: 在 bas_product / bas_product_his 增加 spec VARCHAR(100);Admin 商品 CRUD 与关联 join 接口统一返回 spec;前端新增 product-measure-display.ts 统一格式化;商品管理页负责录入,业务页只读展示。

Tech Stack: MySQL · FastAPI · SQLAlchemy · Vue3 · TypeScript · Element Plus

设计依据: docs/superpowers/specs/2026-06-26-web-admin-product-spec-field-design.md


文件结构总览

文件 职责
backend/scripts/migration_20260626_bas_product_spec.sql DDL 迁移
backend/app/utils/product_spec.py spec 空串规范化
backend/app/models/models.py ORM 字段
backend/app/crud/product.py 历史快照写入 spec
backend/app/api/admin.py 商品 CRUD + 订单/结算/询价报价 join
backend/app/api/delivery.py 配送任务商品行
web-admin/src/utils/product-measure-display.ts formatMeasureCell / formatMeasureUnitSpec
web-admin/src/api/admin.ts 创建/更新商品类型
web-admin/src/views/system/product/index.vue 表单、列表、导出
web-admin/src/components/business/product-view-dialog/index.vue 查看弹窗
6 个业务页 + 1 个结算组件 列表列 + 行内合并展示

Task 1: 数据库迁移脚本

Files:

  • Create: backend/scripts/migration_20260626_bas_product_spec.sql

  • [ ] Step 1: 编写迁移 SQL

-- 商品规格字段:自由文本,非必填
ALTER TABLE bas_product
  ADD COLUMN spec VARCHAR(100) NULL COMMENT '商品规格(自由文本,非必填)' AFTER measure_value;

ALTER TABLE bas_product_his
  ADD COLUMN spec VARCHAR(100) NULL COMMENT '商品规格(自由文本,非必填)' AFTER measure_value;
  • Step 2: 在开发库执行迁移

Run:

mysql -u<user> -p<pass> sanqiang < backend/scripts/migration_20260626_bas_product_spec.sql

Expected: Query OK × 2,无重复列错误。

  • Step 3: 验证列存在

Run:

mysql -u<user> -p<pass> sanqiang -e "SHOW COLUMNS FROM bas_product LIKE 'spec'; SHOW COLUMNS FROM bas_product_his LIKE 'spec';"

Expected: 两表均返回 spec | varchar(100) | YES。


Task 2: 后端 Model 与历史快照

Files:

  • Modify: backend/app/models/models.py(BasProduct ~348 行、BasProductHis ~386 行)
  • Modify: backend/app/crud/product.py(update_with_history ~76 行、soft_delete_with_history ~102 行)

  • [ ] Step 1: Model 增加 spec 列

在 BasProduct.measure_value 后增加:

spec = Column(String(100), nullable=True, comment="商品规格(自由文本,非必填)")

BasProductHis 同步增加同名字段(measure_value 后)。

  • Step 2: 历史快照字典补充 spec

product.py 两处 his_data 均在 measure_value 后增加:

"spec": getattr(original, "spec", None),
  • Step 3: 语法检查

Run:

python -m py_compile backend/app/models/models.py backend/app/crud/product.py

Expected: 无输出(成功)。


Task 3: 规格规范化工具 + 商品 CRUD API

Files:

  • Create: backend/app/utils/product_spec.py
  • Modify: backend/app/api/admin.py(list_products ~1639、get_product ~1684、create_product ~1740、update_product ~1852)

  • [ ] Step 1: 新建规范化函数

backend/app/utils/product_spec.py:

from typing import Any, Optional


def normalize_product_spec(value: Any) -> Optional[str]:
    """空串/纯空白 -> None;否则 strip 后返回,最长 100。"""
    if value is None:
        return None
    s = str(value).strip()
    if not s:
        return None
    return s[:100]
  • Step 2: 本地验证规范化逻辑

Run:

python -c "
from backend.app.utils.product_spec import normalize_product_spec
assert normalize_product_spec(None) is None
assert normalize_product_spec('') is None
assert normalize_product_spec('  ') is None
assert normalize_product_spec('礼盒装') == '礼盒装'
assert normalize_product_spec(' 礼盒装 ') == '礼盒装'
print('ok')
"

Expected: 输出 ok。

  • Step 3: 商品列表/详情响应增加 spec

在 admin.py 顶部 import:

from app.utils.product_spec import normalize_product_spec

列表项 dict(~1639 行 measureValue 旁)增加:

"spec": getattr(p, "spec", None) or None,

详情 dict(~1684 行)同样增加 "spec": product.spec or None。

  • Step 4: 创建/更新商品写入 spec

在 create_product 调用 product_crud.create 前:

if "spec" in request:
    request["spec"] = normalize_product_spec(request.get("spec"))

update_product 同样在 product_crud.update_with_history 前处理。

  • Step 5: 更新响应补 spec

update_product 返回体(~1874 行)增加 "spec": product.spec or None。

  • Step 6: 重启后端并冒烟

Run: 启动 uvicorn,GET /api/web/admin/products?page=1&pageSize=1(带 Token)。

Expected: list[0] 含 spec 字段(可为 null)。


Task 4: 关联 Admin API 补 spec

Files:

  • Modify: backend/app/api/admin.py

在下列响应 dict 中,凡已有 "measureValue" 的行,紧邻增加 "spec": getattr(product, "spec", None) if product else None(或从已加载的 product 变量读取):

约略行号 场景
~2410 询价详情 items
~2588 报价 items
~2833 询价商品池 items
~2976 报价对比 items
~4161 订单明细 items
~5448 客户结算 line children
~6175 供应商结算 line children
~7846 订单调整可选商品
~8370 配送预警/关联订单 items
  • [ ] Step 1: 订单明细 item_details.append(~4154 行)补 spec

  • [ ] Step 2: 客户结算 _build_client_pending_settlement_tree_rows children(~5439 行)补 spec

  • [ ] Step 3: 供应商结算对应 children(~6175 行)补 spec

  • [ ] Step 4: 询价/报价相关 4 处 items 补 spec

  • [ ] Step 5: 订单调整、配送预警 items 补 spec

  • [ ] Step 6: 语法检查

Run:

python -m py_compile backend/app/api/admin.py

Expected: 成功。


Task 5: 配送 API 补 spec

Files:

  • Modify: backend/app/api/delivery.py(~403 行)

  • [ ] Step 1: 配送任务商品行 dict 增加 spec

在 "measureValue": measure_value 后:

"spec": getattr(product, "spec", None) if product else None,
  • Step 2: 语法检查

Run:

python -m py_compile backend/app/api/delivery.py

Expected: 成功。


Task 6: 前端展示工具函数

Files:

  • Create: web-admin/src/utils/product-measure-display.ts

  • [ ] Step 1: 实现格式化函数

/** 列表「计量」列:与商品管理页现有逻辑一致 */
export function formatMeasureCell(v: unknown): string {
  if (v == null || v === '') return '—'
  const n = Number(v)
  if (Number.isNaN(n)) return '—'
  return n.toFixed(3).replace(/\.?0+$/, '')
}

/** 列表「规格」列 */
export function formatSpecCell(spec?: string | null): string {
  const s = spec != null ? String(spec).trim() : ''
  return s || '—'
}

/**
 * 行内合并:计量 + 单位 +(规格)
 * 例:500 克(礼盒装);无规格时不显示括号
 */
export function formatMeasureUnitSpec(
  measureValue: unknown,
  unitName?: string | null,
  spec?: string | null
): string {
  const m = formatMeasureCell(measureValue)
  const hasMeasure = m !== '—'
  const u = unitName != null ? String(unitName).trim() : ''
  const s = spec != null ? String(spec).trim() : ''

  let base = ''
  if (hasMeasure && u) base = `${m} ${u}`
  else if (hasMeasure) base = m
  else if (u) base = u
  else if (s) base = ''
  else return '—'

  if (s) {
    return base ? `${base}(${s})` : `(${s})`
  }
  return base || '—'
}

/** 数量 × 计量单位(规格) */
export function formatQtyMeasureUnitSpec(
  qty: number | string,
  measureValue: unknown,
  unitName?: string | null,
  spec?: string | null
): string {
  const inner = formatMeasureUnitSpec(measureValue, unitName, spec)
  if (inner === '—') return String(qty)
  return `${qty}×${inner}`
}
  • Step 2: TypeScript 检查

Run:

pnpm --dir web-admin exec vue-tsc --noEmit 2>&1 | head -20

Expected: 新文件无类型错误(允许项目既有其它告警)。


Task 7: 前端 API 类型

Files:

  • Modify: web-admin/src/api/admin.ts(fetchCreateProduct ~467、fetchUpdateProduct ~489)

  • [ ] Step 1: 类型增加 spec

spec?: string | null

分别加入 fetchCreateProduct data 与 fetchUpdateProduct Partial data。


Task 8: 商品管理页(表单 / 列表 / 导出)

Files:

  • Modify: web-admin/src/views/system/product/index.vue

  • [ ] Step 1: formData 增加 spec: ''(~983 行)

  • [ ] Step 2: 基本信息区增加规格输入框

在「计量/单位」el-row 之后新增一行:

<el-row :gutter="20">
  <el-col :span="12">
    <el-form-item label="规格">
      <el-input
        v-model="formData.spec"
        placeholder="如:礼盒装、约 2 斤/袋"
        maxlength="100"
        show-word-limit
        clearable
      />
    </el-form-item>
  </el-col>
</el-row>

不加 prop / required 规则。

  • Step 3: 列表在「单位」列后增加「规格」列(~133 行后)
<el-table-column prop="spec" label="规格" min-width="100" align="center" header-align="center">
  <template #default="{ row }">
    {{ formatSpecCell(row.spec) }}
  </template>
</el-table-column>

从 @/utils/product-measure-display import formatSpecCell;本地 formatMeasureCell 可改为从工具 import 或保留(二选一,推荐统一 import)。

  • Step 4: 导出列 productExportColumns(~871 行)增加
spec: { title: '规格' },

mapProductExportRow 增加 spec: row.spec || '—'。

  • Step 5: handleSubmit 的 basicData(~1658 行)增加
spec: formData.spec?.trim() ? formData.spec.trim() : undefined,
  • Step 6: resetForm / loadProductForEdit / handleCopy 读写 spec

resetForm:spec: ''。

编辑回填:spec: detail.spec || ''。

复制:formData.spec = detail.spec || ''。

  • Step 7: 页面冒烟

打开 http://localhost:3006/#/base/product:

  1. 新增商品,规格填「礼盒装」→ 保存 → 列表规格列显示「礼盒装」
  2. 编辑清空规格 → 列表显示 —
  3. 导出 Excel 含规格列

Task 9: 商品查看弹窗

Files:

  • Modify: web-admin/src/components/business/product-view-dialog/index.vue

  • [ ] Step 1: formData / resetForm / loadProduct 增加 spec

  • [ ] Step 2: Hero 文案(~34 行)

标签改为「计量/单位(规格)」;内容:

{{ formatMeasureUnitSpec(formData.measureValue, selectedUnitName, formData.spec) }}
  • Step 3: 基本信息 KV(~58 行)

dt 改为「计量/单位(规格)」;dd 使用 formatMeasureUnitSpec。

  • Step 4: 区域价格后缀(~102 行)
<span class="muted-text"> / {{ formatMeasureUnitSpec(formData.measureValue, row.unitName, formData.spec) }}</span>
  • Step 5: import formatMeasureUnitSpec,可删除本地 formatMeasureView 若已不再使用

Task 10: 订单管理页

Files:

  • Modify: web-admin/src/views/system/order/index.vue

  • [ ] Step 1: 所有含「计量」「单位」列的 el-table 在「单位」列后增加「规格」列

<el-table-column prop="spec" label="规格" min-width="90" align="center">
  <template #default="{ row }">{{ formatSpecCell(row.spec) }}</template>
</el-table-column>

涉及约 5 处表格(行 ~151、~481、~1016、~1314、~1466)。

  • Step 2: formatDeliveredQtyMeasureUnit(~1662 行)改用工具函数
import { formatQtyMeasureUnitSpec } from '@/utils/product-measure-display'

const formatDeliveredQtyMeasureUnit = (row: any, q: number) =>
  formatQtyMeasureUnitSpec(q, row?.measureValue, row?.unitName, row?.spec)
  • Step 3: 冒烟

订单详情弹窗:明细表有规格列;已配送文案为 10×500 克(礼盒装) 形式。


Task 11: 配送管理 + 配送预警

Files:

  • Modify: web-admin/src/views/system/delivery/index.vue
  • Modify: web-admin/src/views/system/delivery-warning/index.vue

  • [ ] Step 1: 各页含计量+单位列的表格增加「规格」列(模式同 Task 10)

  • [ ] Step 2: 行内若存在 formatMeasureCell 拼接单位展示,改为 formatMeasureUnitSpec

  • [ ] Step 3: 两页分别打开列表/详情验证


Task 12: 询价报价页

Files:

  • Modify: web-admin/src/views/system/inquiry-quotation/index.vue

  • [ ] Step 1: 所有同时展示计量、单位的表格增加「规格」列

该文件表格较多(~308、~895、~1271 等),统一在 unitName 列后插入规格列。

  • Step 2: 行内合并展示处(如 ~931、~1193、~1432 行的 measureValue + unitName)改为 formatMeasureUnitSpec

示例(~1432 行附近):

formatMeasureUnitSpec(row.measureValue, row.unitName, row.spec)
  • Step 3: 选品弹窗 fetchProductList 回填处确保 spec 透传到行数据(~2640、~3193 等 map 处加 spec: it?.spec ?? null)

Task 13: 客户结算 + 供应商结算 + 结算明细组件

Files:

  • Modify: web-admin/src/views/system/client-settlement/index.vue
  • Modify: web-admin/src/views/system/supplier-settlement/index.vue
  • Modify: web-admin/src/components/settlement/SettlementSheetDetailPanel.vue

  • [ ] Step 1: 三处表格增加「规格」列(unitName 列后)

  • [ ] Step 2: 行内计量单位展示改用 formatMeasureUnitSpec

SettlementSheetDetailPanel.vue 中 formatMeasureValue 相关拼接(~271 行)评估是否改为 formatMeasureUnitSpec(line.measureValue, line.unitName, line.spec)。

  • Step 3: 打开结算页展开明细验证

Task 14: 手工回归与文档留痕

Files:

  • Modify(可选): docs/superpowers/specs/2026-06-26-web-admin-product-spec-field-design.md 末尾追加「实施完成」勾选

  • [ ] Step 1: 商品管理全链路

步骤 预期
新增,无规格 保存成功,规格列 —
新增,规格「礼盒装」 列表/查看均为「礼盒装」
编辑清空规格 存 NULL,列表 —
复制商品 规格随复制
导出 Excel 含规格列
  • Step 2: 展示格式
场景 预期
查看弹窗 Hero 500 克(礼盒装) 或 500 克
订单已配送 10×500 克(礼盒装)
仅规格无计量单位 (礼盒装)
  • [ ] Step 3: 边界

  • 规格 100 字前端限制生效

  • 规格含括号原样显示,不再套第二层括号

  • [ ] Step 4: 确认未改页面仍正常

价格清单、报价、询价、订单调整页无回归。


Spec 覆盖自检

设计章节 对应 Task
数据模型 spec 字段 Task 1–2
历史表快照 Task 2
商品 CRUD API Task 3
关联业务 API Task 4–5
formatMeasureUnitSpec Task 6
商品表单/列表/导出 Task 8
查看弹窗 Task 9
业务列表 P1 Task 10–13
非目标(小程序/订单快照) 无 Task(符合预期)
手工测试 Task 14

执行建议顺序

Task 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → 13 → 14

Task 6–7 可与 Task 3–5 并行(前后端分工时)。