16 KiB
产品管理列表卡(去色块 + 折叠)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 的导入与使用。
- 删除
statusFilterref、onStatusChange requestSnapshot/fetchPage/onShellReady去掉 statusFilterbuildProductListQuery调用去掉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。任务完成后由用户决定是否提交。