Files
shanxia2.0/docs/superpowers/plans/2026-10-18-mp-crm-product-list-card.md
T
2026-10-19 18:28:02 +08:00

16 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: 小程序 WebView 产品管理列表去掉状态 Tab 与双色块,固定只查上架,卡片对齐 PC 默认可见列并用客户列表式「更多字段」折叠。

Architecture: 在 mp-crm-product-card.ts 集中查询固定 statusIn=on、卡片 meta/预览/展开字段与品牌别名价格格式化;index.vue 去掉 Tab、改 DOM;mp-webview.scss 将产品卡样式改为客户卡扁平结构。不改后端契约。

Tech Stack: Vue 3 + Vant(WebView)+ TypeScript;node:test 单测;三墙 Token。

Spec: docs/superpowers/specs/2026-10-18-mp-crm-product-list-card-design.md

Demo: bak/mobile-ui/shanxia-mp-crm-product-list-card-demo.html


File map

File Responsibility
web-admin/src/utils/mp-crm-product-card.ts 查询固定上架;卡片模型(meta、preview、extra、格式化)
web-admin/src/utils/mp-crm-product-card.test.ts 上述行为单测
web-admin/src/views/m/crm/product/index.vue 去 Tab;折叠卡 DOM;expandedIds
web-admin/src/styles/mp-webview.scss 删除产品色块样式;加产品扁平卡样式
web-admin/src/utils/money-format.ts 复用 formatMoneyDisplay(只读,不改)

Task 1: 单测驱动 — 查询固定上架 + 卡片字段拆分

Files:

  • Modify: web-admin/src/utils/mp-crm-product-card.test.ts

  • Modify: web-admin/src/utils/mp-crm-product-card.ts

  • Step 1: 重写失败测试

将 mp-crm-product-card.test.ts 替换为:

import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
  buildProductListQuery,
  buildProductCard,
  productStatusLabel,
  productStatusTagType
} from './mp-crm-product-card'

describe('buildProductListQuery', () => {
  it('always sets statusIn on (list is on-shelf only)', () => {
    assert.deepEqual(
      buildProductListQuery({
        page: 1,
        pageSize: 20,
        keyword: ' 酸 ',
        categoryId: 'cat-1',
        model: ' 98% ',
        priceMin: 10,
        priceMax: 30
      }),
      {
        page: 1,
        pageSize: 20,
        statusIn: 'on',
        keyword: '酸',
        categoryId: 'cat-1',
        model: '98%',
        priceMin: 10,
        priceMax: 30
      }
    )
  })

  it('omits blank keyword and blank category/model', () => {
    const q = buildProductListQuery({ page: 2, pageSize: 10, keyword: '  ' })
    assert.deepEqual(q, { page: 2, pageSize: 10, statusIn: 'on' })
  })
})

describe('buildProductCard', () => {
  it('builds meta, preview, extra without cost; formats brands/aliases/price', () => {
    const card = buildProductCard({
      id: 'p1',
      productNo: 'SX001',
      productName: '硫酸',
      categoryPath: '化工 / 酸类',
      model: '98%',
      status: 'on',
      isToxic: 1,
      isExplosive: 0,
      createTime: '2026-10-17 09:00:00',
      images: '/fixture/product.png',
      createBy: 'user-1',
      brands: [{ brandName: '山峡' }, { brandName: '华恒' }],
      aliases: [{ aliasName: 'H2SO4' }],
      price: 1280
    })
    assert.equal(card.id, 'p1')
    assert.equal(card.productName, '硫酸')
    assert.equal(card.statusLabel, '上架')
    assert.equal(card.statusTagType, 'success')
    assert.equal(card.showToxic, true)
    assert.equal(card.showExplosive, false)
    assert.equal(card.metaLine, '上架 · SX001 · 2026-10-17 09:00:00')
    assert.deepEqual(card.previewFields, [
      { key: 'categoryPath', label: '产品分类', value: '化工 / 酸类' },
      { key: 'model', label: '规格型号', value: '98%' }
    ])
    assert.deepEqual(
      card.extraFields.map((f) => f.key),
      ['productNo', 'brands', 'aliases', 'price', 'status']
    )
    assert.equal(card.extraFields.find((f) => f.key === 'brands')?.value, '山峡、华恒')
    assert.equal(card.extraFields.find((f) => f.key === 'aliases')?.value, 'H2SO4')
    assert.equal(card.extraFields.find((f) => f.key === 'price')?.value, '¥1,280.00')
    assert.equal(card.extraFields.find((f) => f.key === 'status')?.value, '上架')
    assert.equal(card.extraFields.some((f) => f.key === 'cost'), false)
    assert.equal(card.createBy, 'user-1')
  })

  it('uses em dash for empty brands/aliases/price/model', () => {
    const card = buildProductCard({
      id: 'p2',
      productName: '片碱',
      status: 'on'
    })
    assert.equal(card.metaLine, '上架 · — · —')
    assert.equal(card.previewFields[0].value, '—')
    assert.equal(card.previewFields[1].value, '—')
    assert.equal(card.extraFields.find((f) => f.key === 'brands')?.value, '—')
    assert.equal(card.extraFields.find((f) => f.key === 'price')?.value, '—')
  })
})

describe('productStatusTagType', () => {
  it('maps on→success off→info', () => {
    assert.equal(productStatusTagType('on'), 'success')
    assert.equal(productStatusTagType('off'), 'info')
    assert.equal(productStatusLabel('on'), '上架')
    assert.equal(productStatusLabel('off'), '下架')
  })
})
  • Step 2: 跑测确认失败

Run(在 web-admin 目录):

pnpm exec tsx --test src/utils/mp-crm-product-card.test.ts

Expected: FAIL(statusFilter 仍为旧 API / 无 metaLine 等)

  • Step 3: 实现 mp-crm-product-card.ts

完整替换为:

import { formatMoneyDisplay } from '@/utils/money-format'

export type ProductBrandLike = { brandName?: string }
export type ProductAliasLike = { aliasName?: string }

export type ProductCardField = { key: string; label: string; value: string }

export function productStatusLabel(status?: string): string {
  if (status === 'off') return '下架'
  if (status === 'on') return '上架'
  return status || '—'
}

export function productStatusTagType(status?: string): 'success' | 'info' | 'warning' {
  if (status === 'on') return 'success'
  if (status === 'off') return 'info'
  return 'warning'
}

function dash(value?: string | null): string {
  const t = String(value ?? '').trim()
  return t || '—'
}

export function formatProductBrandsForCard(brands?: ProductBrandLike[]): string {
  if (!brands?.length) return '—'
  const names = brands.map((b) => String(b.brandName || '').trim()).filter(Boolean)
  return names.length ? names.join('、') : '—'
}

export function formatProductAliasesForCard(aliases?: ProductAliasLike[]): string {
  if (!aliases?.length) return '—'
  const names = aliases.map((a) => String(a.aliasName || '').trim()).filter(Boolean)
  return names.length ? names.join('、') : '—'
}

export function formatProductPriceForCard(value?: number | string | null): string {
  if (value === null || value === undefined || value === '') return '—'
  const num = Number(value)
  if (Number.isNaN(num) || num === 0) return '—'
  return `¥${formatMoneyDisplay(num)}`
}

/** 产品管理列表固定只查上架 */
export function buildProductListQuery(input: {
  page: number
  pageSize: number
  keyword: string
  categoryId?: string
  model?: string
  priceMin?: number
  priceMax?: number
}): Record<string, unknown> {
  const params: Record<string, unknown> = {
    page: input.page,
    pageSize: input.pageSize,
    statusIn: 'on'
  }
  const kw = String(input.keyword || '').trim()
  if (kw) params.keyword = kw
  const categoryId = String(input.categoryId || '').trim()
  if (categoryId) params.categoryId = categoryId
  const model = String(input.model || '').trim()
  if (model) params.model = model
  if (input.priceMin !== undefined && Number.isFinite(input.priceMin))
    params.priceMin = input.priceMin
  if (input.priceMax !== undefined && Number.isFinite(input.priceMax))
    params.priceMax = input.priceMax
  return params
}

export function buildProductCard(row: {
  id: string
  productNo?: string
  productName?: string
  categoryPath?: string
  categoryName?: string
  model?: string
  status?: string
  isToxic?: number
  isExplosive?: number
  createTime?: string
  images?: string
  createBy?: string
  brands?: ProductBrandLike[]
  aliases?: ProductAliasLike[]
  price?: number | string | null
}) {
  const productNo = dash(row.productNo)
  const statusLabel = productStatusLabel(row.status || 'on')
  const createTime = dash(row.createTime)
  const categoryPath = dash(row.categoryPath || row.categoryName)
  const model = dash(row.model)
  const coverImage = String(row.images || '')
    .split(',')
    .map((item) => item.trim())
    .find(Boolean)

  const previewFields: ProductCardField[] = [
    { key: 'categoryPath', label: '产品分类', value: categoryPath },
    { key: 'model', label: '规格型号', value: model }
  ]
  const extraFields: ProductCardField[] = [
    { key: 'productNo', label: '产品编号', value: productNo },
    { key: 'brands', label: '品牌', value: formatProductBrandsForCard(row.brands) },
    { key: 'aliases', label: '别名', value: formatProductAliasesForCard(row.aliases) },
    { key: 'price', label: '标准价(元)', value: formatProductPriceForCard(row.price) },
    { key: 'status', label: '状态', value: statusLabel }
  ]

  return {
    id: row.id,
    productNo,
    productName: dash(row.productName) === '—' ? '—' : String(row.productName || '').trim() || '—',
    categoryPath,
    model,
    status: row.status || '',
    statusLabel,
    statusTagType: productStatusTagType(row.status || 'on'),
    showToxic: Number(row.isToxic) === 1,
    showExplosive: Number(row.isExplosive) === 1,
    createTime: row.createTime || '',
    coverImage: coverImage || '',
    createBy: row.createBy || '',
    metaLine: `${statusLabel} · ${productNo} · ${createTime}`,
    previewFields,
    extraFields
  }
}
  • Step 4: 跑测确认通过
pnpm exec tsx --test src/utils/mp-crm-product-card.test.ts

Expected: PASS

注:若 @/ 别名在 tsx 测试中解析失败,改为相对导入 ./money-format(同目录层级:import { formatMoneyDisplay } from './money-format')。


Task 2: 列表页 DOM — 去 Tab + 折叠卡

Files:

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

  • Step 1: 更新 script 导入与状态

删除对 MP_PRODUCT_STATUS_FILTERS、MpProductStatusFilter 的导入与使用。

  • 删除 statusFilter ref、onStatusChange
  • requestSnapshot / fetchPage / onShellReady 去掉 statusFilter
  • buildProductListQuery 调用去掉 statusFilter
  • 增加:
const expandedIds = ref<Set<string>>(new Set())

function isExpanded(id: string) {
  return expandedIds.value.has(String(id))
}
function toggleExpand(id: string, e?: Event) {
  e?.stopPropagation()
  const key = String(id)
  const next = new Set(expandedIds.value)
  if (next.has(key)) next.delete(key)
  else next.add(key)
  expandedIds.value = next
}
  • reload / fetchPage(true) 时清空:expandedIds.value = new Set()

  • ProductRow 增加可选:brands?: Array<{ brandName?: string }>、aliases?: Array<{ aliasName?: string }>、price?: number | string

  • Step 2: 更新模板 header

删除整个 #before-search 的 van-tabs 块。MpBusinessHeader 仅保留搜索与 #after-search 下拉。

  • Step 3: 更新卡片模板

将 article.mp-product-list-card 内 head 以下至 actions 之前替换为(对齐客户列表):

<div class="mp-wv-card__head">
  <div class="mp-product-list-card__head-main">
    <div class="mp-product-list-card__titles">
      <div class="mp-wv-card__no">
        {{ card.productName }}
        <span v-if="card.showToxic" class="status-tag status-tag--danger mp-product-hazard">毒</span>
        <span v-if="card.showExplosive" class="status-tag status-tag--warning mp-product-hazard">爆</span>
      </div>
      <div class="mp-product-list-card__meta">{{ card.metaLine }}</div>
    </div>
  </div>
  <span class="status-tag" :class="`status-tag--${card.statusTagType}`">{{
    card.statusLabel
  }}</span>
</div>

<div class="mp-product-list-card__preview">
  <div
    v-for="field in card.previewFields"
    :key="field.key"
    class="mp-product-list-card__line"
  >
    <span>{{ field.label }}</span>
    <span>{{ field.value }}</span>
  </div>
</div>

<button
  class="mp-product-list-card__toggle"
  type="button"
  :aria-expanded="isExpanded(card.id)"
  @click="toggleExpand(card.id, $event)"
>
  {{ isExpanded(card.id) ? '收起' : '更多字段' }}
  <span class="mp-product-list-card__toggle-icon" aria-hidden="true">{{
    isExpanded(card.id) ? '▴' : '▾'
  }}</span>
</button>

<div v-show="isExpanded(card.id)" class="mp-product-list-card__extra">
  <div
    v-for="field in card.extraFields"
    :key="field.key"
    class="mp-product-list-card__row"
  >
    <span>{{ field.label }}</span>
    <span>{{ field.value }}</span>
  </div>
</div>

保留原有 mp-wv-card-actions 四按钮不变。

  • Step 4: 手动冒烟检查点

打开 /#/m/crm/product:无状态 Tab;卡无绿/灰色块;可展开更多字段含品牌/别名/标准价;下架后卡消失。


Task 3: 样式 — 去色块、对齐客户扁平卡

Files:

  • Modify: web-admin/src/styles/mp-webview.scss

  • Step 1: 替换产品列表卡样式块

删除注释 /* 产品列表:保留强调条 + KV 色块 */ 起至该段结束的 .mp-product-list .mp-product-list-card { ... } 整块,改为:

/* 产品列表:扁平摘要 + 预览 / 展开(无色块,对齐客户列表) */
.mp-product-list .mp-product-list-card {
  .mp-product-list-card__meta {
    margin-top: 4px;
    font-size: 12px;
    line-height: 1.5;
    color: #6b7b74;
  }

  .mp-wv-card__no .mp-product-hazard {
    margin-left: 6px;
    vertical-align: middle;
  }

  .mp-product-list-card__preview {
    display: flex;
    flex-direction: column;
    gap: 4px;
    margin-top: 8px;
  }

  .mp-product-list-card__line {
    display: flex;
    gap: 10px;
    justify-content: space-between;
    font-size: 13px;
    color: #223b33;

    > span:first-child {
      color: #6b7b74;
      font-size: 12px;
    }

    > span:last-child {
      font-weight: 500;
      text-align: right;
    }
  }

  .mp-product-list-card__toggle {
    display: flex;
    gap: 4px;
    align-items: center;
    justify-content: center;
    width: 100%;
    margin-top: 10px;
    padding: 8px 0 2px;
    font-size: 13px;
    font-weight: 600;
    color: #087f57;
    background: none;
    border: 0;
    border-top: 1px dashed #dcebe4;
    cursor: pointer;
  }

  .mp-product-list-card__toggle-icon {
    font-size: 11px;
    line-height: 1;
  }

  .mp-product-list-card__extra {
    margin-top: 8px;
  }

  .mp-product-list-card__row {
    display: flex;
    gap: 12px;
    align-items: flex-start;
    padding: 4px 0;
    font-size: 13px;
    line-height: 1.45;

    > span:first-child {
      flex: 0 0 auto;
      min-width: 5em;
      color: #6b7b74;
      white-space: nowrap;
    }

    > span:last-child {
      flex: 1;
      min-width: 0;
      color: #1f2a26;
      text-align: right;
      word-break: break-word;
    }
  }
}
  • Step 2: 确认 product.scss 无需状态 Tab 残留依赖

已从 mp-webview.scss 移除无引用的 .mp-product-view-tabs 选择器;product.scss 的 .mp-product-tag-row 仍被详情页使用,保留。


Task 4: 图谱更新 + 验证

Files:

  • Run: ./scripts/update-graph.sh frontend 或 graphify update .(按仓库惯例)

  • Step 1: 再跑单测

cd web-admin && pnpm exec tsx --test src/utils/mp-crm-product-card.test.ts

Expected: PASS

  • Step 2: 更新知识图谱
./scripts/update-graph.sh frontend

或在仓库根目录:graphify update .(若脚本要求在子目录则用脚本)。

  • Step 3: 完成核对(对照 spec §5.3)

  • 无状态 Tab

  • 无绿底编号条 / 灰底 KV

  • 预览分类+型号;展开含编号/品牌/别名/标准价/状态;无成本价

  • 毒/爆小标 + 右上上架 Tag

  • 查询固定上架


Spec coverage check

Spec 项 Task
去状态 Tab / 固定上架 Task 1 + 2
去双色块 Task 2 + 3
PC 默认列字段(无成本价) Task 1
折叠交互 Task 2 + 3
meta 上架 · 编号 · 时间 Task 1
验证清单 Task 4

Commit note

按用户仓库规则:未明确要求则不自动 git commit。任务完成后由用户决定是否提交。