Files
inkreach-official-website/plans/feature/product-family-admin-feature.md
T

9.6 KiB
Raw Blame History

产品族二期(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 Plusauto-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.tsOriginGoodsTreeNode + 族字段;新增 ProductFamily 系列)

  • Create: apps/admin/src/api/product-families.ts

  • typesOriginGoodsTreeNode 增加 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 不受影响)→ Commit feat(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.vuetabs 增加 { name: 'families', label: '产品族', comp: FamilyView }icon 用 Files

  • FamilyView:顶部工具栏(关键词搜索、自动成族按钮开 AutoGroupDialog、新建族简易弹窗)+ el-tablefamilyCode/familyName/成员数/覆盖数/autoManaged 标签/stale 红点/更新时间/操作列 详情+ el-pagination;行点击开 FamilyDetailDrawer@reload 重新拉列表)。

  • FamilyDetailDrawerel-drawer size 70%):

    • 头部:familyName/familyCode/stale 标记/autoManaged 开关(patch/重算按钮(recompute);
    • canonical 编辑:名称/编码/主图(复用 ImageUpload)/主链接(成员下拉选择)→ patch
    • 成员表:goodName/物流/工艺/仓库/来源/操作移除updateMembers remove);
    • 添加成员:输入 origin_good_id 列表(el-select remote 搜索用 originGoodsApi.getOriginGoodsList keyword)→ updateMembers add
    • 新建自定义成员按钮开 CustomMemberDialog价格管理折叠区嵌入 PriceOverridePanel
    • 矩阵摘要:sizes/crafts/logistics 数量 + 起价(rows 最低 price)。
  • 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 buildvue-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 接口已返回)。

  • 右侧树节点 labelfamilyCode 存在时以 el-tag 小徽标展示在名称后;familyStale 时加橙点提示。

  • 构建通过 + pnpm --filter @inkreach/admin test 全绿。

  • Commit feat(admin): goods view family badges and sibling matching

Task 5: 验证与文档

  • pnpm --filter @inkreach/admin testpnpm --filter @inkreach/admin buildpnpm --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。