9.6 KiB
产品族二期(admin 前端)实施计划
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: admin 后台落地产品族管理界面:新增「产品族」标签页(列表/详情/成员管理/自定义成员/人工改价/自动成族),GoodsView 原产品树接入族信息。
Architecture: 新建 views/product-family/ 目录按职责拆组件(不复制 GoodsView 巨型单文件模式);API 走新增 src/api/product-families.ts;GoodsView 仅做两处增量(兄弟勾选按族匹配、树节点族徽标)。依赖一期后端 /product-families/* 端点(已上线 develop)。
Tech Stack: Vue 3 <script setup> + Element Plus(auto-import)+ vitest(纯逻辑 TDD;仓库无 @vue/test-utils,组件以 vue-tsc + vite build 验证)。
范围约定: Good 配置表单的 familyId 字段属三期(后端 Good.familyId 未上线),本期不做;现有 Good 级合并(mergedOriginGoodIds)保持原样。
Task 1: 类型与 API client
Files:
-
Modify:
apps/admin/src/types/index.ts(OriginGoodsTreeNode + 族字段;新增 ProductFamily 系列) -
Create:
apps/admin/src/api/product-families.ts -
types:
OriginGoodsTreeNode增加familyId: string | null; familyName: string | null; familyCode: string | null; familyStale: boolean | null;新增:
export interface ProductFamilyMember {
id: string; sdsGoodId: string; goodName: string; goodImage: string | null
source: 'SDS' | 'CUSTOM'; delisted: boolean
skuCode: string | null; logisticsLabel: string | null; craftLabel: string | null; warehouseLabel: string | null
}
export interface FamilyPriceOverrideRow {
id: string; sizeId: string; colorId: string; craft: string; logistics: string
price: string; note: string | null; derivedPrice: string | null; diff: string | null
}
export interface ProductFamily {
id: string; familyCode: string | null; familyName: string; familyImage: string | null
countryId: string | null; categoryId: string | null; primaryOriginGoodId: string | null
autoManaged: boolean; stale: boolean
detail: Record<string, unknown> | null
sizeChart: { rows?: Array<Record<string, unknown>> } | null
packageSpecs: { rows?: Array<Record<string, unknown>> } | null
priceMatrix: {
sizes: Array<{ key: string; name: string | null }>
colors: Array<{ key: string; name: string | null; hex: string | null; imageUrl: string | null }>
crafts: string[]; logistics: string[]
rows: Array<{ sizeId: string; sizeName: string | null; colorId: string; colorName: string | null
craft: string; logistics: string; price: string; manual: boolean
sources: Array<{ sdsGoodId: string; sdsVariantId: string; price: string }> }>
} | null
originGoods: ProductFamilyMember[]
priceOverrides: FamilyPriceOverrideRow[]
_count: { originGoods: number; priceOverrides: number }
}
export interface AutoGroupPreviewGroup {
groupKey: string; familyName: string; familyCode: string | null
memberCount: number; sampleNames: string[]
}
export interface CustomMemberVariantInput { sku: string; sizeId?: string | null; sizeName?: string | null
colorId?: string | null; colorName?: string | null; colorHex?: string | null; imageUrl?: string | null; price: number }
src/api/product-families.ts:
import request from './request'
import type { PaginatedResult, ProductFamily, AutoGroupPreviewGroup } from '@/types'
export const productFamiliesApi = {
list: (params: { keyword?: string; page?: number; pageSize?: number }) =>
request.get<any, PaginatedResult<ProductFamily>>('/product-families', { params }),
detail: (id: string) => request.get<any, ProductFamily>(`/product-families/${id}`),
create: (data: { familyName: string; familyCode?: string; familyImage?: string; originGoodIds?: string[]; primaryOriginGoodId?: string }) =>
request.post<any, ProductFamily>('/product-families', data),
patch: (id: string, data: Record<string, unknown>) =>
request.patch<any, ProductFamily>(`/product-families/${id}`, data),
autoGroup: (apply: boolean) =>
request.post<any, { applied: number; groups: AutoGroupPreviewGroup[] }>('/product-families/auto-group', { apply }),
updateMembers: (id: string, data: { addOriginGoodIds?: string[]; removeOriginGoodIds?: string[] }) =>
request.post<any, ProductFamily>(`/product-families/${id}/members`, data),
createCustomMember: (id: string, data: Record<string, unknown>) =>
request.post<any, unknown>(`/product-families/${id}/members/custom`, data),
recompute: (id: string) => request.post<any, ProductFamily>(`/product-families/${id}/recompute`),
listOverrides: (id: string) =>
request.get<any, { items: FamilyPriceOverrideRow[] }>(`/product-families/${id}/price-overrides`),
putOverrides: (id: string, items: Array<Record<string, unknown>>) =>
request.put<any, { items: FamilyPriceOverrideRow[] }>(`/product-families/${id}/price-overrides`, { items }),
deleteOverrides: (id: string, cells: Array<Record<string, unknown>>) =>
request.delete<any, { items: FamilyPriceOverrideRow[] }>(`/product-families/${id}/price-overrides`, { data: { cells } }),
}
(request 封装与 goods.ts 一致;delete 带 body 用 { data }。)
pnpm --filter @inkreach/admin test通过(现有 spec 不受影响)→ Commitfeat(admin): product families api client and types
Task 2: 族匹配工具(TDD)
Files:
-
Create:
apps/admin/src/utils/family-match.ts+family-match.spec.ts -
先写失败测试:
sameFamily(a, b)—— 两节点familyId相等且非空 → true;任一 familyId 为空 → 回退sameOriginGroup(goodName);均空 false。 -
实现(3 行逻辑,复用
sameOriginGroup)。 -
测试通过 → Commit
feat(admin): family match util
Task 3: 产品族标签页组件
Files:
-
Create:
apps/admin/src/views/product-family/FamilyView.vue -
Create:
apps/admin/src/views/product-family/FamilyDetailDrawer.vue -
Create:
apps/admin/src/views/product-family/AutoGroupDialog.vue -
Create:
apps/admin/src/views/product-family/PriceOverridePanel.vue -
Create:
apps/admin/src/views/product-family/CustomMemberDialog.vue -
Modify:
apps/admin/src/views/product-management/ProductManagementView.vue(tabs 增加{ name: 'families', label: '产品族', comp: FamilyView },icon 用Files) -
FamilyView:顶部工具栏(关键词搜索、
自动成族按钮开 AutoGroupDialog、新建族简易弹窗)+ el-table(familyCode/familyName/成员数/覆盖数/autoManaged 标签/stale 红点/更新时间/操作列详情)+ el-pagination;行点击开 FamilyDetailDrawer(@reload重新拉列表)。 -
FamilyDetailDrawer(el-drawer size 70%):
- 头部:familyName/familyCode/stale 标记/autoManaged 开关(
patch)/重算按钮(recompute); - canonical 编辑:名称/编码/主图(复用 ImageUpload)/主链接(成员下拉选择)→
patch; - 成员表:goodName/物流/工艺/仓库/来源/操作
移除(updateMembers remove); - 添加成员:输入 origin_good_id 列表(el-select remote 搜索用
originGoodsApi.getOriginGoodsListkeyword)→updateMembers add; 新建自定义成员按钮开 CustomMemberDialog;价格管理折叠区嵌入 PriceOverridePanel;- 矩阵摘要:sizes/crafts/logistics 数量 + 起价(rows 最低 price)。
- 头部:familyName/familyCode/stale 标记/autoManaged 开关(
-
AutoGroupDialog:打开先
autoGroup(false)预览(表格:族名/编码/成员数/示例名);确认建族按钮调autoGroup(true)后@done刷新。 -
PriceOverridePanel:按 (craft × logistics) el-tabs;表格行=尺码、列=颜色(或平铺行),单元格显示矩阵价 + manual 高亮 + 行内编辑(el-input-number)→ 失焦
putOverrides单格;顶部已设覆盖列表(推导价/差额/删除恢复)。 -
CustomMemberDialog:表单 goodName/goodImage/物流*/工艺*/skuCode/仓库 + 变体动态行(sku/sizeId/sizeName/colorId/colorName/price)→
createCustomMember。 -
pnpm --filter @inkreach/admin build(vue-tsc + vite)通过。 -
Commit
feat(admin): product family management tab
Task 4: GoodsView 接入族信息
Files:
-
Modify:
apps/admin/src/views/goods/GoodsView.vue -
L411 兄弟自动勾选:
sameOriginGroup(...)换成sameFamily(ogNode, s)(familyId 优先、名称回退)——collectSiblings收集的节点已带 familyId 字段(tree 接口已返回)。 -
右侧树节点 label:
familyCode存在时以 el-tag 小徽标展示在名称后;familyStale时加橙点提示。 -
构建通过 +
pnpm --filter @inkreach/admin test全绿。 -
Commit
feat(admin): goods view family badges and sibling matching
Task 5: 验证与文档
pnpm --filter @inkreach/admin test、pnpm --filter @inkreach/admin build、pnpm --filter @inkreach/api test全绿。- 起后端 + admin dev,浏览器冒烟(登录 → 产品族 tab → 列表 → 详情 → 自动成族预览 → 改价)——如环境不可用则以 curl 走 vite 代理替代。
- 更新
docs/references/structs.md(admin 章节目录树 + 新页面)与docs/references/product-center.md(后台操作路径)。 - Commit
docs: admin product family references
Self-Review
- 设计 §11.2 四组件全覆盖(FamilyList 并入 FamilyView 表格,粒度合并不改语义);§11.3 两处增量落地(Good 表单 familyId 明确延后三期并在范围约定声明);§11.4 client ✓。
- 组件无测试设施属仓库现状,以 vue-tsc/build 兜底并在计划头部声明;纯逻辑(family-match)走 TDD。