# 产品中心 (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= 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 `,响应拦截器统一脱壳 `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` 等。 ### 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 `` 实现:用户名 + 密码 + 登录按钮 - 居中卡片布局,背景渐变 / 品牌色 - 调用 `authStore.login()` 成功后跳 `/` ### A7: DefaultLayout **文件**:`src/layouts/DefaultLayout.vue` 布局: - 左侧 `` 侧边栏(240px 宽),可折叠 - 顶部 header:面包屑 + 用户头像下拉(含登出) - 中间 `` 内容区 菜单项与路由一一对应,使用 `@element-plus/icons-vue` 图标。 ### A8: GoodsView **文件**:`src/views/goods/{GoodsView.vue, components/GoodFormDialog.vue, components/BatchAddDialog.vue}` **功能**: - 顶部筛选栏:国家下拉 + 品类级联 + 标签下拉 + 关键词输入 + 搜索/重置按钮 - 表格列:商品名、国家、品类、标签、位置、优先级、创建时间、操作(编辑/删除) - 分页器(Element Plus ``) - 工具栏:新增按钮 + 批量添加按钮 + 批量调优先级按钮 - **新增/编辑对话框**:商品名、关联 originGood(远程搜索)、国家、品类(级联)、标签、位置、优先级 - **批量添加对话框**:左侧 originGoods 多选表格,右侧统一配置(国家/品类/标签/位置/优先级),保存调 `POST /goods/batch` **测试** (`GoodsView.spec.ts`): - mount 后展示列表 - 筛选触发重新请求 - 删除二次确认后调接口 ### A9: CategoriesView **文件**:`src/views/categories/{CategoriesView.vue, components/CategoryFormDialog.vue}` **功能**: - Element Plus `` `: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、操作 - 表单:标签名(唯一)、颜色(``)、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 个产品" - 中间:上一页 / 页码方形按钮(当前页橙色填充) / 下一页 - 右侧:`