# 产品族二期（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`；新增：

```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-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.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。
