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

820 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 产品中心 (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 storesauth/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` hashrounds=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` 全绿
- 三端联调跑通完整业务流
- 文档与结构图已更新