feat(admin): product families api client and types

This commit is contained in:
yeuimu
2026-08-28 13:25:10 +08:00
parent 8fad8de73e
commit c492469d70
3 changed files with 298 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
import request from './request'
import type {
AutoGroupPreviewGroup,
FamilyPriceOverrideRow,
PaginatedResult,
ProductFamily,
} from '@/types'
export const productFamiliesApi = {
list: (params: { keyword?: string; page?: number; pageSize?: number }) => {
return request.get<any, PaginatedResult<ProductFamily>>('/product-families', { params })
},
detail: (id: string) => {
return request.get<any, ProductFamily>(`/product-families/${id}`)
},
create: (data: {
familyName: string
familyCode?: string
familyImage?: string
originGoodIds?: string[]
primaryOriginGoodId?: string
}) => {
return request.post<any, ProductFamily>('/product-families', data)
},
patch: (id: string, data: Record<string, unknown>) => {
return request.patch<any, ProductFamily>(`/product-families/${id}`, data)
},
autoGroup: (apply: boolean) => {
return request.post<any, { applied: number; groups: AutoGroupPreviewGroup[] }>(
'/product-families/auto-group',
{ apply },
)
},
updateMembers: (id: string, data: { addOriginGoodIds?: string[]; removeOriginGoodIds?: string[] }) => {
return request.post<any, ProductFamily>(`/product-families/${id}/members`, data)
},
createCustomMember: (id: string, data: Record<string, unknown>) => {
return request.post<any, unknown>(`/product-families/${id}/members/custom`, data)
},
recompute: (id: string) => {
return request.post<any, ProductFamily>(`/product-families/${id}/recompute`)
},
listOverrides: (id: string) => {
return request.get<any, { items: FamilyPriceOverrideRow[] }>(`/product-families/${id}/price-overrides`)
},
putOverrides: (id: string, items: Array<Record<string, unknown>>) => {
return request.put<any, { items: FamilyPriceOverrideRow[] }>(`/product-families/${id}/price-overrides`, { items })
},
deleteOverrides: (id: string, cells: Array<Record<string, unknown>>) => {
return request.delete<any, { items: FamilyPriceOverrideRow[] }>(`/product-families/${id}/price-overrides`, {
data: { cells },
})
},
}
+87
View File
@@ -378,6 +378,10 @@ export interface OriginGoodsTreeNode {
variantCount: number
sizeRowCount: number
packageRowCount: number
familyId: string | null
familyName: string | null
familyCode: string | null
familyStale: boolean | null
}
export interface OriginGoodsTreeCategoryNode {
@@ -396,6 +400,89 @@ export interface OriginGoodsTreeResponse {
configuredCount: number
}
// Product Family (SPU layer) types
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
familyId: string
sizeId: string
colorId: string
craft: string
logistics: string
price: string
note: string | null
derivedPrice: string | null
diff: string | null
}
export interface ProductFamilyPriceMatrix {
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 }>
}>
}
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: { columns?: unknown; rows?: Array<Record<string, unknown>> } | null
packageSpecs: { columns?: unknown; rows?: Array<Record<string, unknown>> } | null
priceMatrix: ProductFamilyPriceMatrix | 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
}
// Sync types
export interface SyncLog {
id: string
@@ -0,0 +1,147 @@
# 产品族二期(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.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`;新增:
```ts
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`
```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.vue`tabs 增加 `{ name: 'families', label: '产品族', comp: FamilyView }`icon 用 `Files`
- [ ] **FamilyView**:顶部工具栏(关键词搜索、`自动成族`按钮开 AutoGroupDialog、`新建族`简易弹窗)+ el-tablefamilyCode/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.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 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。