# 产品中心

官网产品中心位于 `apps/website/app/pages/product-center.vue`，访问路径为 `/product-center`。

## 默认展示

页面首次加载时展示全部商品和全部国家，不默认选择分类、物流或工艺标签。每页默认请求 20 条商品，用户操作筛选项后再按真实业务 ID 查询。

## 筛选与分页

- 左侧分类树：选择父分类或子分类。
- 国家：点击国家胶囊筛选，点击“全部”取消国家筛选。
- 物流与工艺：按标签组展示下拉选项，每个分组内暂为单选，不同分组可各选择一项。
- 筛选菜单在选择选项或点击组件外部后自动关闭；已选 Tag 使用独立标签行。仅选择工艺时标签从最左侧开始；物流与工艺同时选择时按物流、工艺顺序与上方筛选项对应排列。
- 搜索：输入关键词后按回车或点击“搜索”。
- 商品卡：整卡链接到 `https://inkpod.vip/portal/detail/{商品ID}`，在新窗口打开。
- 公共商品接口的 `id` 来自 `originGood.sdsGoodId`，即 InkPOD 商品 ID；`goods.good_id` 仅作为本站数据库内部主键使用。
- URL 国家筛选：支持 `/product-center?countryId={后台国家ID}`，仅接受后台已配置的国家 ID。
- 无结果：展示设计稿空状态，可通过“查看全部商品”或“清空筛选”恢复完整列表。
- 加载中：展示两行共八个商品骨架卡，筛选栏保持可操作。
- 分页：支持页码、前后翻页、每页数量和页码跳转。

所有查询通过 `useProductCenter.ts` 调用 `/api/backend/*` 代理路由，不在页面中硬编码业务 ID。

## 后台配置联动

- 品类树、国家、Tag 与 Tag Group 均来自 NestJS `/public/*` 接口。
- 国家旗帜读取 `countryIcon`，品类图标读取 `categoryIcon`；前端不提供外部旗帜 CDN 或固定名称兜底。
- `/uploads/*` 与 `/assets/*` 等后台相对资源地址由 `useProductCenter.ts` 根据 `runtimeConfig.public.backendUrl` 转为完整 URL。
- 物流和工艺筛选分别由后台名称包含“物流”和“工艺”的 Tag Group 生成；“印刷位置”等其他分组仍可用于商品卡片标签，但不会自动成为顶部筛选项。
- Figma 基准图标位于 `apps/api/public/product-center/`，通过后端 `/assets/product-center/*` 提供。
- 更新现有数据库图标配置：`pnpm --filter @inkreach/api configure:product-center-icons`。脚本按中文名称幂等更新，不创建缺失的国家或品类。

## 响应式

桌面采用 240px 分类栏，页面左右保留 `24–56px` 自适应留白，分类栏可滚动但隐藏可见 scrollbar。商品网格以 240px 为最小卡片宽度自动增加或减少列数：1440px 基准宽度保持四列，1920px 可显示六列。国家筛选区按内容自然增高，不保留固定空白。

左侧品类栏不显示最左侧边线，一级品类之间保留 10px 间距；国家筛选区及物流、工艺、搜索工具区的底部分隔线从内容区向右延伸至页面边缘。

宽度低于 1000px 后分类栏变为抽屉；移动端商品网格降为两列或单列，国家筛选仅在自身区域横向滚动，不会撑宽页面。

## 多源合并商品（已废弃，由产品族替代）

> **2026-08-28 起，该机制已被「产品族」完全替代**：多链接合并由族承载（尺码/包装并集 +
> 五维价格矩阵），配置/编辑商品不再写入 `mergedOriginGoodIds`；`good_origin_goods` 表
> 只读保留（历史数据仍可命中详情），观察期后删除。以下为历史行为记录：

- 数据层：主源存 `goods.origin_good_id`，副源存中间表 `good_origin_goods`。
- 详情可达性：官网上通过**任一**关联原产品的 `sdsGoodId` 都能访问到该商品详情（族机制下
  同样成立：族内任何成员链接的 sdsGoodId 均命中同一商品）。

> 该 Good 级合并能力保留可用；新一级的「产品族（SPU）合并」见下节，公开读路径接入族数据属于三期范围。

## 产品族（SPU 层，一期后端已上线）

设计文档：`docs/superpowers/specs/2026-08-28-product-family-merge-design.md`。

**模型**：SDS 叶子分类即产品模型（如分类 `DG001 180G纯棉T恤（JSA002）` 下 16 条链接），
族 = 同分类链接的合并体；链接名解析列（`国家（物流）品名-SKU-工艺[-仓库]`）提供
物流/工艺归因。例：DG001 族 = 16 链接 × 3 工艺 × 3 物流 × 9 尺码（S~XXXXXL 并集）× 20 颜色 ≈ 470 格价格矩阵，起价取光板（不打印）最低价。

**价格**：`价格 = f(尺码, 颜色, 物流, 工艺[含印花数量])`，全部从成员链接变体推导透传
（同格子多仓库取最低价，来源全保留）；`family_price_overrides` 表支持按格人工改价，
删覆盖即恢复推导价。

**关键端点用法示例**（JWT）：

```bash
# 1. 自动成族：先预览
curl -X POST /product-families/auto-group -H "Authorization: Bearer $T" \
  -d '{"apply": false}'
# → { applied: 0, groups: [{ groupKey: "cat:8240", familyName: "DG001 180G纯棉T恤（JSA002）", memberCount: 16, ... }] }

# 2. 应用（幂等，可重复执行）
curl -X POST /product-families/auto-group -H "Authorization: Bearer $T" -d '{"apply": true}'

# 3. 族详情：成员 + 并集尺码表/包装 + 五维价格矩阵
curl /product-families/12 -H "Authorization: Bearer $T"

# 4. 人工改价（维度必须存在于族矩阵选项，否则 400 并列出非法键）
curl -X PUT /product-families/12/price-overrides -H "Authorization: Bearer $T" \
  -d '{"items": [{"sizeId":"size_S","colorId":"color_blk","craft":"单面印花","logistics":"包邮","price": 23, "note": "促销"}]}'

# 5. 删覆盖恢复推导价
curl -X DELETE /product-families/12/price-overrides -H "Authorization: Bearer $T" \
  -d '{"cells": [{"sizeId":"size_S","colorId":"color_blk","craft":"单面印花","logistics":"包邮"}]}'

# 6. 族内创建自定义商品（人工补链接，物流/工艺归因必填）
curl -X POST /product-families/12/members/custom -H "Authorization: Bearer $T" \
  -d '{"goodName":"美国（海运）180g纯棉T恤-DG001-烫画","logisticsLabel":"海运","craftLabel":"烫画",
       "variants":[{"sku":"C-M","sizeId":"size_M","sizeName":"M","colorId":"color_red","colorName":"红色","price": 33}]}'
```

**三层架构（解析去运行时化）**：

1. **同步 = 纯镜像**：SDS 给什么存什么（名称原文/图/价/分类 ID），不解析、不归族、不写解析列；
2. **整理 = 显式人工动作**（所有解析/派生集中于此，可审查可重跑）：回填解析列 → 派生链接标签
   （未人工接管的 SDS 链接按名称刷新；人工接管永不覆盖）→ 自动建族 → 全量重算。入口二选一：

```bash
pnpm --filter @inkreach/api organize                          # CLI
curl -X POST /product-families/organize -H "$AUTH"            # 后台「整理」按钮（同一逻辑）
```

3. **重算 = 自动的结构化聚合**：归族/成员变化/详情同步/标签调整自动触发；输入只有
   成员变体 + 链接标签 + 覆盖价，**不解析名称**——上游改名永远不会倒灌已派生的标签与矩阵；
   未整理（无标签）的 SDS 成员不进矩阵，整理后齐全。

派生默认（脚本层假设，显式可审查）：工艺无关键字 → 烫画；印花数量无单/双面且工艺非不打印 →
单面印花；不打印/光板 无印花面不补（矩阵中印花数量以单面占位，纯结构化规则）。
「恢复自动」（链接标签重置）本身是显式人工动作，同样走整理服务的单链接派生。

**公开读路径（四期族化契约，默认开启）**：`PUBLIC_DETAIL_FROM_FAMILY=false` 可应急回退旧行为。
公开端点**以款（族）为一等公民**：

- `GET /public/goods`：**一族一条**（族内多条 Good 去重，代表行=排序第一条），
  `goodId` = **族 ID**，`total` = 族数，分页作用于分组后；`price` = 族矩阵最低价（起价），
  价格排序按族起价重排；**无族商品（自定义）完全不返回**（列表/首页/分类树/标签统计同理）。
- `GET /public/goods/:goodId`：`goodId` 即**族 ID**（唯一公开寻址键；旧 SDS 链接 ID 与
  自定义商品 sdsGoodId 均 404）。响应为款级聚合：

  - 公共信息取代表 Good（名称/主图/国家/分类/标签）+ 主链接（详情文案）；
  - `variants` = 全体族成员 ∪ 旧副源（过渡期），按 `(链接, 变体)` 再按 `颜色+尺码` 去重；
    `mediaByColor` 同源；`options/media` 并入族成员 detail；
  - `sizeChart` / `packageSpecs` = 族物化并集（空并集回退主链接合并结果）；
  - **族块**（默认输出）：`family` 含并集尺码表/包装 + **严格五维价格矩阵** + 族起价：

```jsonc
{
  "family": {
    "familyId": "12", "familyCode": "DG015", "familyName": "DG015 180G纯棉T恤",
    "sizes": [...], "colors": [...],
    "printCounts": ["单面印花", "双面印花"], "crafts": ["烫画", "直喷", "不打印"], "logistics": ["包邮", "不包邮"],
    "sizeChart": { /* 并集 */ }, "packageSpecs": { /* 并集 */ },
    "priceMatrix": { /* rows 为 {sizeId, colorId, printCount, craft, logistics, price, manual, sources} */ },
    "minPrice": "29.5"
  }
}
```

矩阵维度来源是**链接级标签**（印花数量/印刷工艺/物流渠道三组；人工接管后按人工标签），
不再用原始链接名的第 3 段（`craftLabel` 含「直喷单面/光板不打印」等噪声）。名称派生规则已
覆盖裸「单面/双面」写法（如「直喷双面」→ 双面印花 + 直喷）。光板/不打印链接无印花面概念，
矩阵中印花数量维回退「单面印花」占位。前端本地按五维联动 `priceMatrix` 即可实时算价
（设计 D4）。

**边界声明**：价格区间筛选（minPrice/maxPrice）仍作用于链接 `goodPrice`（族内任一链接命中即
返回该族；SQL 层无法廉价解析族矩阵 JSON）；`good_origin_goods` 转只读保留，观察期后另行删除。

**后台操作入口（族替代旧主源/副源，界面保持原有布局）**：

- 商品配置页布局不变（左树=官网商品、右树=原产品库分类平铺）；配置弹窗保持原「合并同名」
  勾选流程，提交时**静默**把勾选链接与主链接归入同一族（无族自动成族）；族内**不区分主次**
  （成员列表无设为主链接按钮，主链接仅作为详情数据源的内部实现）；
- 编辑弹窗的「**关联原产品**」区块：显示族编码与链接数，成员行显示**原始链接名**（便于核对）
  并可**逐个展开**——查看成员详情（SDS ID / 链接价格 / SKU 数 / 物流备注 / 工艺位置 / 仓库）、
  **配置该链接的标签**（保存后人工接管；「恢复自动」回到按名称派生）、
  **改价格**（尺码/颜色/价格 可编辑表格，写入族价格覆盖）、
  **查看成员自己的详情**（商品详情 / 尺码表 / 包装规格 / SKU 四个页签，懒加载
  `GET /origin-goods/:id`，SKU 表带 物流/工艺/印花数量 维度列；未同步时可在面板内直接同步）；
  SDS 商品不再在弹窗底部展示单一链接的旧详情区（自定义商品的编辑表单仍保留在底部）；
- 成员列表下方有**族级并集视图**页签：尺码表 / 包装规格（族物化并集）与
  **SKU（全族动态合并）**——SKU 即价格矩阵行，各链接变体按 物流×工艺×印花数量×尺码×颜色
  归并（同组合取最低价，「链接数」列显示来源数，人工价带标签），无需额外请求；
- 编辑弹窗商品「名称」预填解析后的「品名 型号」，保存即采用解析名；右侧原产品库与配置弹窗
  始终显示**原始链接名**；
- 配置弹窗「合并同名」区块**常显**，候选为**全局同款链接**：按 链接款号（名称第 2 段，
  如 BRTF004）或族归属跨分类匹配，同款不同工艺/物流/仓库均在列（含已配置链接），
  支持搜索过滤；无候选时显示空态提示。
- 右树徽标语义（**族感知**，公开契约一族一款）：**已配置 N**（绿，本链接已配置为官网商品，
  N=国数）/ **已配置**（绿，本链接未单独建商品但所属族已配置）/ **成族**（蓝，已归族且全族
  未配置）/ **未配置**（橙）；「定位到官网商品」在自身或同族已配置时出现（按 主链接 → 同族 →
  旧副源顺序定位并高亮），「配置」在本链接未单独建商品时可用（含族已配置的情形，供调整归族；
  注意重复配置会产生官网不可见的冗余商品）；
- 族分组头部计数与「仅未配置」筛选同样按族语义（族已配置 → 计入已配置）。
- **左树一族一行**（国家/品类模式）：同款多条配置只显示排序最前的一条，名称旁
  「N 个配置」徽标悬浮列出其余配置（可打开编辑），分组头部计数仍按全部商品统计。
- 人工改价/自动成族等族管理 API（`/product-families/*`）保留，供脚本或后续界面使用；
- 页面组件化：`GoodsView.vue` 拆出 `components/` 下的 GoodsEditDialog（编辑弹窗）、
  GoodsConfigDialog（配置弹窗）、CustomGoodDialog（自定义商品）、TagFilterPopover（标签筛选弹层）。

> 注意：历史上每族多点「配置」会产生多个同名的官网商品（重复项可手动删除）。现在族已配置后
> 「配置」入口会被拦截；存量重复商品（如早期 DG120/AUTM003 测试期间产生的）仍建议清理。

**链接级标签（自动派生 + 人工修正，2026-08 规则改版）**：

- 标签与**产品链接一一对应**（每条链接因印花数量/工艺/物流不同而价格不同），
  落库在链接级 `origin_good_tags` 表（`manual` 标记人工/派生）；商品的自动组标签是其
  **主链接标签的镜像**；
- 解析规则（`apps/api/src/product-families/auto-tag-rules.ts`，与 admin 端
  `utils/origin-name.ts#deriveLinkTagNames` 同构）：
  - **印花数量**：名称含「双面印花」→ `双面印花`；否则含「单面印花」→ `单面印花`（组「印花数量」）；
  - **工艺**：名称含「直喷」→ `直喷`；含「不打印」或「光板」→ `不打印`（**光板即为不打印**，
    组「印刷工艺」）；都不含 → 默认 `烫画`；
  - **物流**：含「不包邮」→ `不包邮`；否则含「包邮」→ `包邮`（组「物流渠道」，先判不包邮防子串误命中）；
- 每次族重算/成员变更/商品创建更新时：未接管的 SDS 链接按名称刷新派生行；
  **人工接管的链接（`tagsManual=true`）永不被覆盖**；商品镜像其链接的有效标签；
  缺失的组与标签自动补建；
- **人工修正入口**：编辑弹窗成员行展开 → 修改标签 → 保存标签（全量替换为人工行，
  `PUT /origin-goods/:id/tags`）；「恢复自动」清掉人工行回到派生（`DELETE /origin-goods/:id/tags`）；
- 其他分组（如风格类）不受影响，保持人工管理；自定义链接（无名称可解析）同样支持人工配置；
- 实测：`美国（包邮）…-DG001-单面印花` → `包邮 / 烫画 / 单面印花`；
  `美国（不包邮光板）…-JSA002-不打印` → `不包邮 / 不打印 / 光板`；
  `美国（不包邮）…-DG501-双面印花` → `不包邮 / 烫画 / 双面印花`。

**链接名称展示解析**：后台所有链接名展示位（左右树、族成员列表、配置/编辑弹窗、搜索候选）
统一解析为「品名 型号」（如 `美国（包邮）180g纯棉T恤成人款-DG001-单面印花` →
`180g纯棉T恤成人款 DG001`），悬停 tooltip 保留原始全名；搜索同时匹配原始名与解析名
（`utils/origin-name.ts#cleanLinkName`）。




## 验证

```bash
pnpm --filter @inkreach/website test --run
pnpm --filter @inkreach/website build
pnpm --filter @inkreach/website dev
pnpm --filter @inkreach/api test -- --runInBand
pnpm --filter @inkreach/api build
```
