fix: convert apps/website from submodule to regular directory
This commit is contained in:
@@ -0,0 +1,819 @@
|
||||
# 产品中心 (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` 全绿
|
||||
- 三端联调跑通完整业务流
|
||||
- 文档与结构图已更新
|
||||
Reference in New Issue
Block a user