# 产品中心 (Product Center) Feature Plan

## 需求描述

为 InkReach 官网新增"产品中心"二级页面，串联三个子系统：

1. **NestJS 后端** (`inkreach-official-nestjs/`)：Prisma + PostgreSQL，提供 Admin RESTful API、Website Public API、SDS 定时同步
2. **Vue 3 Admin** (`inkreach-official-admin/`)：Element Plus，管理商品/品类/国家/标签/位置/同步
3. **Nuxt 4 官网页** (`inkreach-official-website/`)：在已有首页基础上新增 `/product-center` 二级页

实施顺序：**后端 → Admin → 官网**。所有任务按 TDD 流程开发：先写测试，再写实现，再重构。

## 架构与端口

| 项目 | 端口 | 直连方式 |
|------|------|----------|
| NestJS Backend | 3001 | Admin 直连（Vite proxy），Website 通过 Nitro 代理 |
| Admin (Vite dev) | 5173 | - |
| Website (Nuxt dev) | 3000 | - |

## 跨项目环境变量

| 文件 | 变量 | 值 |
|------|------|----|
| `inkreach-official-nestjs/.env` | `DATABASE_URL` | `postgresql://postgres:yoyoki219765.@localhost:5432/inkreach-official` |
| `inkreach-official-nestjs/.env` | `JWT_SECRET` | 随机字符串（运行前生成） |
| `inkreach-official-nestjs/.env` | `SDS_API_BASE` | `https://mapi.sdspod.com` |
| `inkreach-official-nestjs/.env` | `PORT` | `3001` |
| `inkreach-official-admin/.env` | `VITE_API_BASE` | `http://localhost:3001` |
| `inkreach-official-website/.env` | `NUXT_PUBLIC_BACKEND_URL` | `http://localhost:3001` |

## 任务列表

### 阶段一：NestJS 后端 (`inkreach-official-nestjs/`)

- [ ] B1: 初始化 NestJS + TypeScript 项目骨架
- [ ] B2: 配置 Prisma + 数据库连接
- [ ] B3: 编写 Prisma Schema 并执行 initial migration
- [ ] B4: 实现 PrismaService 和全局 DatabaseModule
- [ ] B5: 实现 Auth 模块（JWT + Guard + 注册/登录）
- [ ] B6: 实现 Countries 模块（CRUD + 测试）
- [ ] B7: 实现 Categories 模块（树形 CRUD + 测试）
- [ ] B8: 实现 Tags 模块（CRUD + 测试）
- [ ] B9: 实现 Positions 模块（CRUD + 测试）
- [ ] B10: 实现 OriginGoods 模块（只读 + 测试）
- [ ] B11: 实现 Goods 模块（CRUD + 批量 + 筛选 + 测试）
- [ ] B12: 实现 SDS 同步模块（cron + 手动触发 + 测试）
- [ ] B13: 实现 Public API 模块（无需鉴权 + 测试）
- [ ] B14: 全局异常过滤器、响应拦截器、CORS
- [ ] B15: 启动验证 + Swagger 文档生成

### 阶段二：Admin 后台 (`inkreach-official-admin/`)

- [ ] A1: 初始化 Vite + Vue 3 + TS + Element Plus
- [ ] A2: 配置 Vite proxy、环境变量、Tailwind（可选）
- [ ] A3: 建立 Axios 实例与 API 模块层
- [ ] A4: 建立 Pinia stores（auth/app）
- [ ] A5: 建立路由 + 鉴权守卫
- [ ] A6: 实现 LoginView
- [ ] A7: 实现 DefaultLayout（侧边栏 + 头部）
- [ ] A8: 实现 GoodsView（列表 + 筛选 + 增删改 + 批量添加 + 测试）
- [ ] A9: 实现 CategoriesView（树形表格 + 测试）
- [ ] A10: 实现 CountriesView（含图标上传/URL + 测试）
- [ ] A11: 实现 TagsView（含颜色选择 + 测试）
- [ ] A12: 实现 PositionsView（含国家/品类联动 + 测试）
- [ ] A13: 实现 SyncView（同步状态 + 手动触发 + 测试）

### 阶段三：官网产品中心页 (`inkreach-official-website/`)

- [ ] W1: 配置 runtimeConfig 与 `.env`
- [ ] W2: 新增 Nitro 后端代理 routes
- [ ] W3: 创建 `useProductCenter` composable
- [ ] W4: 创建 `ProductSidebar.vue` 组件
- [ ] W5: 创建 `ProductCountryFilter.vue` 组件
- [ ] W6: 创建 `ProductFilterBar.vue` 组件
- [ ] W7: 创建 `ProductCard.vue` 与 `ProductCardSkeleton.vue`
- [ ] W8: 创建 `ProductGrid.vue` 组件
- [ ] W9: 创建 `ProductPagination.vue` 组件
- [ ] W10: 创建 `app/pages/product-center.vue` 页面 + 启用 Nuxt 路由
- [ ] W11: 更新 `AppHeader.vue` 与 `PodProducts.vue` 内部跳转链接
- [ ] W12: 三断点视觉验证

### 阶段四：收尾

- [ ] F1: 更新根级 `docs/references/structs.md`（新增 3 子项目结构）
- [ ] F2: 更新各子项目 `README.md` 与 `docs/references/*` 使用文档
- [ ] F3: 更新 `skills/[项目名]/SKILL.md`
- [ ] F4: 全链路联调（后端 + Admin + 官网三端跑通）

---

## 任务详情

### B1: 初始化 NestJS + TypeScript 项目骨架

在 `inkreach-official-nestjs/` 目录初始化 NestJS 工程。

**操作步骤**：

1. `npm init` 创建 `package.json`，包名 `inkreach-official-nestjs`
2. 安装核心依赖：`@nestjs/core @nestjs/common @nestjs/platform-express @nestjs/config reflect-metadata rxjs`
3. 安装开发依赖：`@nestjs/cli @nestjs/testing typescript ts-node tsconfig-paths @types/node jest ts-jest @types/jest supertest @types/supertest`
4. 创建配置文件：`nest-cli.json`、`tsconfig.json`、`tsconfig.build.json`、`.eslintrc.js`、`.prettierrc`
5. 创建入口文件：`src/main.ts` 与 `src/app.module.ts`，监听 `process.env.PORT ?? 3001`
6. `package.json` 中加入脚本：`start`/`start:dev`/`start:debug`/`build`/`test`/`test:e2e`/`test:cov`

**验收**：`npm run start:dev` 能启动并监听 3001 端口。

### B2: 配置 Prisma + 数据库连接

**操作步骤**：

1. 安装：`prisma @prisma/client`（dev: `prisma`）
2. `npx prisma init` 初始化 `prisma/` 与 `.env`
3. 在 `.env` 写入：
   ```
   DATABASE_URL=postgresql://postgres:yoyoki219765.@localhost:5432/inkreach-official
   JWT_SECRET=<openssl rand -hex 32 生成>
   SDS_API_BASE=https://mapi.sdspod.com
   PORT=3001
   ```
4. `.gitignore` 加入 `.env`、`node_modules`、`dist`
5. 在 `app.module.ts` 注册 `ConfigModule.forRoot({ isGlobal: true })`

**验收**：`npx prisma db pull` 或 `prisma validate` 通过。

### B3: 编写 Prisma Schema 并执行 initial migration

将 `docs/dev/database-table-design.md` 的 DDL 翻译为 `prisma/schema.prisma`，**严格保持 6 张表（`origin_goods` / `countries` / `categories` / `tags` / `positions` / `goods`）的字段、外键、删除策略**。

**关系约束（必须）**：

- `Good.originGoods` → `OriginGoods` (RESTRICT)
- `Good.country` → `Country` (RESTRICT, NOT NULL)
- `Good.category` → `Category` (RESTRICT, NOT NULL)
- `Good.tag` → `Tag` (SET NULL, optional)
- `Good.position` → `Position` (SET NULL, optional)
- `Category.parent` → `Category` (RESTRICT, self-relation)
- `Position.country` → `Country` (CASCADE)
- `Position.category` → `Category` (CASCADE)

**字段映射**：所有 `BIGINT` 用 `BigInt`，`TIMESTAMPTZ` 用 `DateTime @db.Timestamptz(6)`，`TEXT` 用 `String`。表名通过 `@@map("xxx")` 映射到 snake_case，字段名同理用 `@map`。

**额外**：

- 创建 `User` 表用于 Admin 登录（`id` / `username` UNIQUE / `passwordHash` / 时间戳）
- 创建 `SyncLog` 表记录同步历史（`id` / `type` enum / `status` enum / `message` / `startedAt` / `finishedAt`）
- 索引按 DDL 中 `CREATE INDEX` 创建对应 `@@index`

**操作**：

1. 编写 schema
2. `npx prisma migrate dev --name init` 生成首个 migration
3. `npx prisma generate` 生成 Client

**验收**：数据库内 8 张表（6 业务表 + `users` + `sync_logs`）正确生成。

### B4: 实现 PrismaService 和全局 DatabaseModule

**文件**：

- `src/prisma/prisma.service.ts`：继承 `PrismaClient`，在 `onModuleInit` 中 `$connect()`，在 `onModuleDestroy` 中 `$disconnect()`
- `src/prisma/prisma.module.ts`：`@Global()` + 导出 `PrismaService`
- 在 `app.module.ts` 注册 `PrismaModule`

**测试**：`src/prisma/prisma.service.spec.ts`，验证可注入并连接数据库。

### B5: 实现 Auth 模块

**文件**：

- `src/auth/auth.module.ts`、`auth.controller.ts`、`auth.service.ts`
- `src/auth/strategies/jwt.strategy.ts`、`src/auth/guards/jwt-auth.guard.ts`
- `src/auth/dto/login.dto.ts`、`register.dto.ts`

**依赖**：`@nestjs/jwt @nestjs/passport passport passport-jwt bcrypt class-validator class-transformer`

**接口**：

| 方法 | 路径 | 描述 |
|------|------|------|
| POST | `/auth/register` | 注册（仅初始化用，可加环境变量门禁） |
| POST | `/auth/login` | 登录返回 `{ accessToken, user }` |

**实现要点**：

- 密码用 `bcrypt` hash（rounds=10）
- JWT payload: `{ sub: userId, username }`，过期 7 天
- `JwtAuthGuard` 默认作用于 Admin 路由

**测试** (`src/auth/auth.service.spec.ts`)：

1. 注册新用户 → 数据库存在记录，密码已 hash
2. 重复用户名 → 抛 `ConflictException`
3. 登录正确密码 → 返回 token
4. 登录错误密码 → 抛 `UnauthorizedException`

### B6: Countries 模块

**文件**：`src/countries/{countries.module.ts, countries.controller.ts, countries.service.ts, dto/create-country.dto.ts, dto/update-country.dto.ts}`

**接口**：

| 方法 | 路径 | 描述 |
|------|------|------|
| GET | `/countries` | 列表 |
| POST | `/countries` | 创建 (`{ countryName, countryIcon? }`) |
| PATCH | `/countries/:id` | 更新 |
| DELETE | `/countries/:id` | 删除（被引用时抛错） |

所有路由加 `@UseGuards(JwtAuthGuard)`。`DELETE` 在数据库 RESTRICT 抛错时返回 `409 Conflict` + 友好信息。

**测试** (`countries.service.spec.ts`)：

1. CRUD 正常路径
2. 重名 → `ConflictException`
3. 被 `goods` 引用时删除 → 抛 `BadRequestException`

### B7: Categories 模块（树形）

**文件**：`src/categories/{categories.module.ts, ...}`

**接口**：

| 方法 | 路径 | 描述 |
|------|------|------|
| GET | `/categories` | 返回树形结构（按 `parentCategoryId` 递归拼接） |
| GET | `/categories/flat` | 扁平列表（用于下拉选择） |
| GET | `/categories/:id` | 详情 |
| POST | `/categories` | 创建（`parentCategoryId` 可空） |
| PATCH | `/categories/:id` | 更新 |
| DELETE | `/categories/:id` | 删除（含子节点 → 抛错） |

**树形构建**：一次查询所有记录 → 在内存中按 `parentCategoryId` 分组拼装。

**测试**：CRUD + 树结构组装 + 删除存在子项时报错。

### B8: Tags 模块

**文件**：`src/tags/{tags.module.ts, ...}`

**接口**：`GET / POST / PATCH /:id / DELETE /:id`（同 Countries）

**字段**：`tagName`（唯一）、`tagColor`（hex 字符串验证 `/^#[0-9A-Fa-f]{6}$/`）、`timing`

**测试**：CRUD + 颜色格式验证。

### B9: Positions 模块

**文件**：`src/positions/{positions.module.ts, ...}`

**接口**：`GET / POST / PATCH /:id / DELETE /:id`

**字段**：`indexVal`、`countryId` (可空)、`categoryId` (可空)

**查询参数**：`GET /positions?countryId=&categoryId=`

**测试**：CRUD + 联表返回 country/category 名称。

### B10: OriginGoods 模块（只读）

**文件**：`src/origin-goods/{origin-goods.module.ts, ...}`

**接口**：

| 方法 | 路径 | 描述 |
|------|------|------|
| GET | `/origin-goods` | 分页 + 关键词搜索 (`page`/`pageSize`/`keyword`) |

只读模块（数据由 SDS 同步生成），无 POST/PATCH/DELETE。

**测试**：分页 + 关键词搜索。

### B11: Goods 模块

**文件**：`src/goods/{goods.module.ts, goods.controller.ts, goods.service.ts, dto/...}`

**接口**：

| 方法 | 路径 | 描述 |
|------|------|------|
| GET | `/goods` | 分页 + 多维筛选 (`countryId`/`categoryId`/`tagId`/`positionId`/`keyword`/`page`/`pageSize`) |
| GET | `/goods/:id` | 详情（含关联 country/category/tag/position/originGoods） |
| POST | `/goods` | 创建 |
| PATCH | `/goods/:id` | 更新 |
| DELETE | `/goods/:id` | 删除 |
| PATCH | `/goods/batch-priority` | 批量更新优先级 (`[{ id, priority }]`) |
| POST | `/goods/batch` | 从 `origin_goods` 批量创建（统一国家/品类/标签/位置/起始优先级） |

**关键实现**：

- 筛选 `categoryId` 时，递归查出所有子品类 ID，用 `IN` 查询
- 关键词搜索 `goodName` 用 `contains` mode insensitive
- 排序：`goodPriority DESC` + `createdAt DESC`
- 关联返回用 Prisma `include`

**测试** (`goods.service.spec.ts`)：

1. 单个 CRUD
2. 筛选（每种参数）
3. categoryId 包含子品类
4. 批量优先级更新（事务）
5. 批量创建（事务，全成或全败）

### B12: SDS 同步模块

**文件**：`src/sync/{sync.module.ts, sync.controller.ts, sync.service.ts, sds-client.service.ts}`

**依赖**：`@nestjs/schedule @nestjs/axios axios`

**SDS Client**：复用现网逻辑（参考 `inkreach-official-website/server/utils/pod-api.ts`）：

```typescript
const POD_API_BASE = 'https://mapi.sdspod.com';
const POD_HEADERS = {
  'Content-Type': 'application/json;charset=UTF-8',
  'Origin': 'https://inkpod.vip',
  'Referer': 'https://inkpod.vip/',
};
```

**接口**：

| 方法 | 路径 | 描述 |
|------|------|------|
| POST | `/sync/categories` | 手动触发品类同步 |
| POST | `/sync/products` | 手动触发产品同步 |
| GET | `/sync/status` | 最近 N 条 `sync_logs` |

**同步逻辑**：

1. **品类同步** `syncCategories()`：调用 `POST /category/tree/3` body `{ withActivityArea:true, withPrivate:true, onlyHaveProduct:true }`，递归扁平化结果，按 `sdsId` 去重写入/更新 `categories` 表（需扩展 schema 增 `sdsCategoryId TEXT UNIQUE` 字段，B3 步骤已含）
2. **产品同步** `syncProducts()`：遍历叶子品类，调用 `GET /products/page?categoryId=&page=&size=50`，去重写入 `origin_goods` 表（按 `sdsGoodId`）
3. **Cron**：`@Cron('0 * * * *')` 每小时执行一次完整同步
4. **日志**：每次同步开始写入 `sync_logs`（status=RUNNING），完成后更新为 SUCCESS/FAILED

**测试**：mock `axios`，验证 mapper 与去重逻辑（不真实调用 SDS）。

### B13: Public API 模块

**文件**：`src/public/{public.module.ts, public.controller.ts, public.service.ts}`

**接口（无需鉴权）**：

| 方法 | 路径 | 描述 |
|------|------|------|
| GET | `/public/categories` | 品类树，仅含有商品的品类 |
| GET | `/public/countries` | 国家列表，仅含有商品的国家 |
| GET | `/public/goods` | 分页商品 (`countryId`/`categoryId`/`tagId`/`keyword`/`page`/`pageSize`) |
| GET | `/public/goods/:id` | 商品详情 |

**实现要点**：

- 排序：`goodPriority DESC → position.indexVal ASC → createdAt DESC`
- 仅返回 Public 字段（排除内部 `originGoodId` 等），DTO 转换在 service 完成
- 接受 `countryId` 与 `categoryId` 多维过滤
- `GET /public/categories` 用 `EXISTS` 子查询过滤

**测试**：覆盖所有筛选维度 + 排序正确性。

### B14: 全局异常过滤器、响应拦截器、CORS

**文件**：

- `src/common/filters/http-exception.filter.ts`：统一错误格式 `{ statusCode, message, error, timestamp, path }`
- `src/common/interceptors/transform.interceptor.ts`：统一响应 `{ data, success: true }`
- `src/common/pipes/validation.pipe.ts`：全局 `ValidationPipe({ whitelist: true, transform: true })`

在 `main.ts`：

```typescript
app.enableCors({ origin: ['http://localhost:5173', 'http://localhost:3000'], credentials: true });
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor());
```

### B15: 启动验证 + Swagger 文档

**操作**：

1. 安装 `@nestjs/swagger`
2. `main.ts` 中配置 `SwaggerModule.setup('api/docs', app, document)`
3. 每个 controller / DTO 加 `@ApiTags`、`@ApiOperation`、`@ApiProperty`
4. `npm run start:dev` 启动后访问 `http://localhost:3001/api/docs` 确认所有接口可见

**验收**：所有阶段一测试 `npm run test` 全绿，启动后 Swagger 完整。

---

### A1: 初始化 Vite + Vue 3 + TS + Element Plus

在 `inkreach-official-admin/` 执行：

1. `npm create vite@latest . -- --template vue-ts`（注意当前目录）
2. 安装：`element-plus @element-plus/icons-vue pinia vue-router@4 axios @vueuse/core dayjs`
3. 开发依赖：`@types/node sass unplugin-auto-import unplugin-vue-components`
4. 在 `vite.config.ts` 配置 `AutoImport` + `Components` 插件按需引入 Element Plus
5. `main.ts` 注册 Pinia、Router、Element Plus 中文 locale

**验收**：`npm run dev` 启动 5173 端口，首页可见。

### A2: Vite proxy + 环境变量

**`.env.development`**：
```
VITE_API_BASE=/api
```

**`vite.config.ts`**：

```typescript
server: {
  port: 5173,
  proxy: {
    '/api': {
      target: 'http://localhost:3001',
      changeOrigin: true,
      rewrite: (path) => path.replace(/^\/api/, ''),
    },
  },
},
```

### A3: Axios 实例与 API 模块层

**文件**：

- `src/api/request.ts`：创建 axios 实例，请求拦截器加 `Authorization: Bearer <token>`，响应拦截器统一脱壳 `data.data`，401 跳登录
- `src/api/auth.ts`：`login(username, password)` / `register(...)`
- `src/api/goods.ts`、`categories.ts`、`countries.ts`、`tags.ts`、`positions.ts`、`origin-goods.ts`、`sync.ts`：对应后端接口，TS 类型明确

**类型**：`src/types/index.ts` 定义 `Good`、`Country`、`Category`、`Tag`、`Position`、`OriginGoods`、`PaginatedResult<T>` 等。

### A4: Pinia stores

**文件**：

- `src/stores/auth.ts`：`token`、`user`、`login()`、`logout()`、`isLoggedIn`，token 持久化到 `localStorage`
- `src/stores/app.ts`：侧边栏折叠状态、面包屑

### A5: 路由 + 鉴权守卫

**文件**：`src/router/index.ts`

```
/login                  → LoginView
/                       → DefaultLayout（重定向到 /goods）
  ├── /goods            → GoodsView
  ├── /categories       → CategoriesView
  ├── /countries        → CountriesView
  ├── /tags             → TagsView
  ├── /positions        → PositionsView
  ├── /origin-goods     → OriginGoodsView（只读列表）
  └── /sync             → SyncView
```

**全局守卫**：未登录访问受保护路由 → 跳 `/login`；已登录访问 `/login` → 跳 `/goods`。

### A6: LoginView

**文件**：`src/views/login/LoginView.vue`

- Element Plus `<el-form>` 实现：用户名 + 密码 + 登录按钮
- 居中卡片布局，背景渐变 / 品牌色
- 调用 `authStore.login()` 成功后跳 `/`

### A7: DefaultLayout

**文件**：`src/layouts/DefaultLayout.vue`

布局：

- 左侧 `<el-menu>` 侧边栏（240px 宽），可折叠
- 顶部 header：面包屑 + 用户头像下拉（含登出）
- 中间 `<router-view>` 内容区

菜单项与路由一一对应，使用 `@element-plus/icons-vue` 图标。

### A8: GoodsView

**文件**：`src/views/goods/{GoodsView.vue, components/GoodFormDialog.vue, components/BatchAddDialog.vue}`

**功能**：

- 顶部筛选栏：国家下拉 + 品类级联 + 标签下拉 + 关键词输入 + 搜索/重置按钮
- 表格列：商品名、国家、品类、标签、位置、优先级、创建时间、操作（编辑/删除）
- 分页器（Element Plus `<el-pagination>`）
- 工具栏：新增按钮 + 批量添加按钮 + 批量调优先级按钮
- **新增/编辑对话框**：商品名、关联 originGood（远程搜索）、国家、品类（级联）、标签、位置、优先级
- **批量添加对话框**：左侧 originGoods 多选表格，右侧统一配置（国家/品类/标签/位置/优先级），保存调 `POST /goods/batch`

**测试** (`GoodsView.spec.ts`)：

- mount 后展示列表
- 筛选触发重新请求
- 删除二次确认后调接口

### A9: CategoriesView

**文件**：`src/views/categories/{CategoriesView.vue, components/CategoryFormDialog.vue}`

**功能**：

- Element Plus `<el-table>` `:tree-props` 树形展示
- 列：品类名、图标预览、子品类数、操作
- 新增/编辑：品类名、父品类（级联，可空）、图标 URL
- 删除：如有子品类二次确认

**测试**：树形展开、新增子品类后刷新。

### A10: CountriesView

**文件**：`src/views/countries/{CountriesView.vue, components/CountryFormDialog.vue}`

- 表格：国家名、图标预览、操作
- 表单：国家名（唯一校验）、图标 URL（可选）

**测试**：CRUD 流程。

### A11: TagsView

**文件**：`src/views/tags/{TagsView.vue, components/TagFormDialog.vue}`

- 表格：标签名、颜色色块、timing、操作
- 表单：标签名（唯一）、颜色（`<el-color-picker>`）、timing

**测试**：颜色验证、唯一冲突提示。

### A12: PositionsView

**文件**：`src/views/positions/{PositionsView.vue, components/PositionFormDialog.vue}`

- 表格：index_val、关联国家、关联品类、操作
- 表单：index_val（整数）、国家下拉（可空）、品类级联（可空）
- 筛选：按国家/品类筛选

**测试**：国家/品类联动加载。

### A13: SyncView

**文件**：`src/views/sync/SyncView.vue`

- 卡片 1：品类同步 — 按钮 + 最近同步时间/状态
- 卡片 2：产品同步 — 按钮 + 最近同步时间/状态
- 表格：最近 20 条 `sync_logs`
- 触发后调 `POST /sync/categories` 或 `POST /sync/products`，加 loading + 成功/失败提示
- 自动每 30s 拉取一次状态（`useIntervalFn` from VueUse）

**测试**：触发同步 + 状态刷新。

---

### W1: Nuxt runtimeConfig 与 `.env`

**文件**：`inkreach-official-website/.env`（追加）

```
NUXT_PUBLIC_BACKEND_URL=http://localhost:3001
```

**`nuxt.config.ts`** 追加：

```typescript
runtimeConfig: {
  public: {
    backendUrl: 'http://localhost:3001',
  },
},
```

### W2: Nitro 后端代理 routes

新建文件：

| 路径 | 转发到 |
|------|--------|
| `server/api/backend/categories.get.ts` | `GET :3001/public/categories` |
| `server/api/backend/countries.get.ts` | `GET :3001/public/countries` |
| `server/api/backend/goods.get.ts` | `GET :3001/public/goods?...` |
| `server/api/backend/goods/[id].get.ts` | `GET :3001/public/goods/:id` |

所有 handler 用 `defineCachedEventHandler` 缓存 60s，透传 query 参数。参考现有 `server/api/pod/categories.post.ts` 写法。

**示例** `server/api/backend/goods.get.ts`：

```typescript
export default defineCachedEventHandler(async (event) => {
  const config = useRuntimeConfig();
  const query = getQuery(event);
  return $fetch(`${config.public.backendUrl}/public/goods`, { query });
}, {
  maxAge: 60,
  swr: true,
  name: 'backend-goods',
  getKey: (event) => JSON.stringify(getQuery(event)),
});
```

### W3: `useProductCenter` composable

**文件**：`app/composables/useProductCenter.ts`

**类型**：

```typescript
export interface Country { id: number; name: string; icon?: string; }
export interface Category {
  id: number;
  name: string;
  icon?: string;
  parentId: number | null;
  children: Category[];
}
export interface Tag { id: number; name: string; color?: string; }
export interface Product {
  id: number;
  name: string;
  image: string;
  price: number;
  country: Country;
  category: Category;
  tag?: Tag;
}
export interface ProductQuery {
  countryId?: number;
  categoryId?: number;
  tagId?: number;
  keyword?: string;
  page: number;
  pageSize: number;
}
```

**API**：

- `categories` / `countries`（用 `useFetch` SSR 拉取）
- `products` / `total` / `loading`
- `query`（reactive 筛选条件）
- `selectedCategory`、`selectedCountry`、`selectedTags`
- `setCategory(id)`、`setCountry(id)`、`setKeyword(v)`、`setPage(n)`、`setPageSize(n)`
- `fetchProducts()` watch query 变化自动触发

### W4: ProductSidebar.vue

**文件**：`app/components/product/ProductSidebar.vue`

按设计稿 `product-center-1.png` 还原：

- 顶级品类带 emoji/图标 + 名称 + 右侧折叠箭头
- 点击顶级展开/收起子品类
- 子品类列表带缩进；选中项左侧 4px 橙色竖条 + `bg-inkreach-homepage-3` + 橙色文字
- 未选中：灰色文字 + hover 浅灰背景
- 宽度 240px，背景白色，圆角 12px

**Props**：`categories`、`activeCategoryId`，emit `update:activeCategoryId`

### W5: ProductCountryFilter.vue

**文件**：`app/components/product/ProductCountryFilter.vue`

- 横向 pill 按钮组，每个 pill：国旗图标 + 国家名
- "全部"为默认项，selected 时橙色边框 + 橙色文字 + 白底
- 未选中：灰色边框 + 灰色文字
- 横向溢出可滚动

**Props**：`countries`、`activeCountryId` (null 表示"全部")

### W6: ProductFilterBar.vue

**文件**：`app/components/product/ProductFilterBar.vue`

按设计稿 `product-center-2.png`：

- 左侧：筛选下拉（如"价格区间"等占位）+ 已选标签 chips（可点击 × 清除）
- 右侧：分体式搜索框 — 灰色输入框 + 橙色"搜索"按钮（圆角矩形）

**Props/Emits**：`keyword` v-model、`selectedTags`、`onSearch`

### W7: ProductCard + ProductCardSkeleton

**`ProductCard.vue`**：

- 整卡白底圆角 12px，hover 浮起阴影
- 顶部 1:1 商品图（背景 `bg-inkreach-homepage-1`）
- 商品名（单行 truncate）
- 标签行：横向 pill 标签，颜色按 tag.color 渲染；约定：绿=包邮，橙=工艺，灰=其他
- 价格："¥XX.XX 起"，粗体橙色

**`ProductCardSkeleton.vue`**：

- 与 ProductCard 同尺寸
- 图片区灰色 + animate-pulse
- 标题/价格行用灰色矩形条 + animate-pulse

### W8: ProductGrid.vue

**文件**：`app/components/product/ProductGrid.vue`

- 4 列（lg）/ 3 列（md）/ 2 列（sm）网格
- `loading=true` 时渲染 12 个 `ProductCardSkeleton`
- `products` 为空时渲染空状态插画 + "暂无商品"提示

**Props**：`products`、`loading`

### W9: ProductPagination.vue

**文件**：`app/components/product/ProductPagination.vue`

按设计稿 `product-center-3.png`：

- 左侧："总计 N 个产品"
- 中间：上一页 / 页码方形按钮（当前页橙色填充） / 下一页
- 右侧：`<select>` 每页条数（12/24/48） + 跳转到 input + Go 按钮

**Props/Emits**：`total`、`page`、`pageSize`，emit `update:page`、`update:pageSize`

### W10: app/pages/product-center.vue

**文件**：`app/pages/product-center.vue`

布局：

```
<AppHeader />
<main class="bg-inkreach-homepage-2 min-h-screen pt-6 pb-12">
  <div class="max-w-7xl mx-auto px-4 lg:px-6 flex gap-6">
    <ProductSidebar :categories="..." v-model:activeCategoryId="..." />
    <div class="flex-1 flex flex-col gap-4">
      <ProductCountryFilter ... />
      <ProductFilterBar ... />
      <ProductGrid ... />
      <ProductPagination v-if="!loading" ... />
    </div>
  </div>
</main>
<AppFooter />
```

由于 Nuxt 4 项目目前用 `app.vue` 单入口，需在 `app.vue` 替换为 `<NuxtPage />` 并把首页改成 `app/pages/index.vue`（迁移现有 `app.vue` 内容到 `pages/index.vue`），同时新增 `pages/product-center.vue`。

**响应式**：移动端侧边栏改为顶部折叠 `<details>`，国家筛选可横向滚动。

**SEO**：`useSeoMeta({ title: '产品中心 | InkReach', description: '...' })`

### W11: AppHeader + PodProducts 链接更新

**改动**：

- `app/components/AppHeader.vue`：将"产品中心"的 `<a :href="PORTAL_URL">` 替换为 `<NuxtLink to="/product-center">`，桌面端 + 移动端两处
- `app/components/PodProducts.vue`：把底部"更多产品"按钮改为 `<NuxtLink to="/product-center">`，并补样式（橙色边框/填充等品牌按钮样式）

### W12: 三断点视觉验证

依次在 375 / 768 / 1440 宽度验证：

- 侧边栏在移动端是否正常折叠
- 国家筛选横向滚动是否流畅
- 商品网格列数响应正确（2/3/4）
- 分页器在移动端单行显示

---

### F1: 更新 `docs/references/structs.md`

更新根级 `docs/references/structs.md`（若无则新建），按以下结构记录：

```
inkreach-official/
├── inkreach-official-nestjs/    # NestJS 后端，:3001
│   └── src/
│       ├── auth/                # JWT 认证
│       ├── countries/           # 国家 CRUD
│       ├── categories/          # 品类树 CRUD
│       ├── ...
│       ├── sync/                # SDS 定时同步
│       └── public/              # 官网公开 API
├── inkreach-official-admin/     # Vue 3 Admin, :5173
│   └── src/
│       ├── views/               # 各管理页
│       ├── api/                 # API 调用
│       └── stores/              # Pinia
└── inkreach-official-website/   # Nuxt 4 官网, :3000
    └── app/
        ├── pages/
        │   ├── index.vue
        │   └── product-center.vue  # ← 新增
        └── components/
            └── product/             # ← 新增
```

每个子项目下也维护自己的 `docs/references/structs.md`。

### F2: 更新 README 与 docs/references

- 根目录 `README.md`：补充三子项目启动方式与端口
- 每个子项目的 `README.md`：补充本子项目快速启动
- 各子项目 `docs/references/index.md`：详细使用说明（接口、命令、配置）

### F3: 更新 SKILL.md

- `skills/inkreach-official-nestjs/SKILL.md`：后端开发约定（如何加模块、TDD 流程）
- `skills/inkreach-official-admin/SKILL.md`：Admin 开发约定（如何加页面、API 层）
- `skills/inkreach-official-website/SKILL.md`：补充产品中心页面与代理路由相关说明

### F4: 全链路联调

1. 启动 Postgres + 后端：`cd inkreach-official-nestjs && npm run start:dev`
2. 手动调用 `POST /auth/register` 创建管理员
3. 手动调用 `POST /sync/categories` 和 `/sync/products` 拉取 SDS 数据到 `origin_goods`
4. 启动 Admin：`cd inkreach-official-admin && npm run dev`，登录后在各页配置国家/品类/标签/位置，并批量从 origin_goods 创建 goods
5. 启动 Website：`cd inkreach-official-website && npm run dev`，访问 `/product-center` 验证侧边栏、筛选、分页、跳转
6. 三端同时运行无 console 报错，所有筛选条件返回正确数据

**完成标准**：

- 三个子项目均能 `npm run test` 全绿
- 三端联调跑通完整业务流
- 文档与结构图已更新
