fix: convert apps/website from submodule to regular directory

This commit is contained in:
yeuimu
2026-07-16 01:42:45 +08:00
parent f12ae12ad3
commit 9595030625
341 changed files with 50808 additions and 1 deletions
@@ -0,0 +1,146 @@
# Nav Mega Menu Feature Plan
## 概述
仿照 HiCustom 首页导航栏的"选品推荐"和"解决方案",为 InkReach 官网添加带下拉菜单的导航项。
## 任务清单
- [x] T1: 创建 `useNavData.ts` composable(含类型定义和 mock 数据)
- [x] T2: 创建 `NavMegaMenu.vue` 选品推荐下拉组件
- [x] T3: 创建 `NavColumnMenu.vue` 解决方案下拉组件
- [x] T4: 改造 `AppHeader.vue` 集成新导航项(桌面端 hover + 移动端手风琴)
- [x] T5: 更新 `docs/references/structs.md`
## T1: useNavData.ts
创建 `app/composables/useNavData.ts`,定义接口类型和 mock 数据。
### 类型定义
```typescript
export interface RecommendItem {
id: number;
title: string;
image: string;
link: string;
}
export interface RecommendCategory {
id: number;
title: string;
items: RecommendItem[];
}
export interface SolutionLink {
id: number;
title: string;
link: string;
tag?: 'hot' | 'new';
}
export interface SolutionColumn {
id: number;
title: string;
links: SolutionLink[];
}
export interface NavBanner {
image: string;
link: string;
title: string;
}
```
### Mock 数据
**选品推荐** 3 个分类,每类 4-6 项。**解决方案** 4 列,每列 3-5 个链接 + 1 个广告 banner。
### Composable API
```typescript
export function useNavData() {
const recommendCategories: Ref<RecommendCategory[]>;
const solutionColumns: Ref<SolutionColumn[]>;
const solutionBanner: Ref<NavBanner | null>;
const activeRecommendIdx: Ref<number>;
function setActiveRecommend(idx: number): void;
return { recommendCategories, solutionColumns, solutionBanner, activeRecommendIdx, setActiveRecommend };
}
```
## T2: NavMegaMenu.vue
选品推荐下拉面板组件。
### Props
```typescript
const props = defineProps<{
categories: RecommendCategory[];
activeIndex: number;
visible: boolean;
}>();
const emit = defineEmits<{
'update:activeIndex': [index: number];
}>();
```
### 桌面端布局
- 绝对定位在导航项下方
- 左侧:分类列表(200px 宽),hover 时高亮当前项
- 右侧:卡片网格(2 列),每卡片含 360x120 图片 + 标题
- 卡片 hover 时图片 scale(1.05)
### 移动端布局
- 不使用此组件,在 AppHeader 抽屉中手风琴展开
## T3: NavColumnMenu.vue
解决方案下拉面板组件。
### Props
```typescript
const props = defineProps<{
columns: SolutionColumn[];
banner: NavBanner | null;
visible: boolean;
}>();
```
### 桌面端布局
- 绝对定位在导航项下方
- 多列布局,每列有标题 + 链接列表
- 链接可带 HOT(红)/ NEW(绿)标签
- 右侧可选广告 banner 图片
### 移动端布局
- 不使用此组件,在 AppHeader 抽屉中手风琴展开
## T4: AppHeader.vue 改造
在现有导航中插入"选品推荐"和"解决方案"两个导航项。
### 桌面端改动
- 在"产品中心"和"在线设计"之间/之后加入两个导航项
- 每个导航项带下拉箭头图标
-`@mouseenter` / `@mouseleave` 控制下拉面板显隐
- 下拉面板使用 `Transition` 组件包裹,opacity + translateY 动画
### 移动端改动
- 在抽屉导航中加入两个手风琴项
- 点击展开/收起子内容
- 选品推荐:分类标签 + 卡片列表
- 解决方案:链接列表
## T5: 更新 structs.md
添加新组件到项目结构文档。
@@ -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 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` 全绿
- 三端联调跑通完整业务流
- 文档与结构图已更新
@@ -0,0 +1,201 @@
# 响应式自适应布局
## 需求描述
当前首页已按设计稿完成桌面端还原,但大部分区域使用固定宽高,在平板和移动端显示异常。需要实现从大到小屏幕的三断点自适应布局。
## 断点定义
| 断点 | 范围 | Tailwind 前缀 |
| ------ | --------------- | ------------- |
| 移动端 | < 768px | 默认(无前缀) |
| 平板 | 768px - 1023px | `md:` |
| 桌面 | >= 1024px | `lg:` |
## 设计决策
- **导航**:移动端使用汉堡菜单 + 侧边滑出面板
- **触摸交互**:4步流程、全球POD货盘、客户案例支持触摸滑动
- **组件拆分**:先拆分 `app.vue` 为独立组件,再逐个做响应式
- **字号系统**:桌面大号 → 平板中号 → 移动端小号
## 工作阶段
### 阶段一:组件拆分
`app.vue` 拆为以下组件:
| 组件名 | 区块 |
| ------------------ | ------------ |
| `AppHeader` | 导航栏 |
| `HeroBanner` | 主视觉 |
| `TrustSection` | 信任背书 |
| `StepProcess` | 4步流程 |
| `PodProducts` | 全球POD货盘 |
| `FeatureCards` | 功能特性 |
| `WhyInkReach` | 为什么选我们 |
| `CompanyProfile` | 公司简介 |
| `CustomerCases` | 客户案例 |
| `CtaBanner` | 行动号召 |
| `AppFooter` | 页脚 |
### 阶段二:响应式适配
逐组件适配三断点布局。
### 阶段三:通用修复
- 修复缺失的 `pulse-animation` CSS 动画
- 统一容器宽度为响应式
## 任务列表
- [x] 1. 创建 develop 分支和 feature 分支
- [x] 2. 拆分 AppHeader 组件
- [x] 3. 拆分 HeroBanner 组件
- [x] 4. 拆分 TrustSection 组件
- [x] 5. 拆分 StepProcess 组件
- [x] 6. 拆分 PodProducts 组件
- [x] 7. 拆分 FeatureCards 组件
- [x] 8. 拆分 WhyInkReach 组件
- [x] 9. 拆分 CompanyProfile 组件
- [x] 10. 拆分 CustomerCases 组件
- [x] 11. 拆分 CtaBanner 组件
- [x] 12. 拆分 AppFooter 组件
- [x] 13. 重构 app.vue 为组件组合
- [x] 14. AppHeader 响应式(汉堡菜单 + 侧边滑出)
- [x] 15. HeroBanner 响应式
- [x] 16. TrustSection 响应式
- [x] 17. StepProcess 响应式(触摸滑动)
- [x] 18. PodProducts 响应式(触摸滑动)
- [x] 19. FeatureCards 响应式
- [x] 20. WhyInkReach 响应式
- [x] 21. CompanyProfile 响应式
- [x] 22. CustomerCases 响应式(触摸滑动)
- [x] 23. CtaBanner 响应式
- [x] 24. AppFooter 响应式
- [x] 25. 修复 pulse-animation + 通用样式
- [x] 26. 全页面三断点验证
- [x] 27. 更新 docs/references/structs.md
## 任务详情
### 1. 创建 develop 分支和 feature 分支
`main` 创建 `develop` 分支,再从 `develop` 创建 `feature/responsive-layout` 分支。
### 2. 拆分 AppHeader 组件
`app.vue` 提取导航栏部分为 `app/components/AppHeader.vue`,包含 logo、导航链接、登录和 CTA 按钮。
### 3. 拆分 HeroBanner 组件
提取主视觉区块为 `app/components/HeroBanner.vue`,包含标题、副标题、产品图片和 CTA。
### 4. 拆分 TrustSection 组件
提取信任背书区块为 `app/components/TrustSection.vue`,包含 4 个数据统计卡片。
### 5. 拆分 StepProcess 组件
提取 4 步流程为 `app/components/StepProcess.vue`,包含步骤选择器和图片预览。
### 6. 拆分 PodProducts 组件
提取全球 POD 货盘为 `app/components/PodProducts.vue`,包含国家 tab 和 5 列产品网格。
### 7. 拆分 FeatureCards 组件
提取功能特性为 `app/components/FeatureCards.vue`,包含 4 个带 hover 效果的特性卡片。
### 8. 拆分 WhyInkReach 组件
提取为什么选我们为 `app/components/WhyInkReach.vue`,包含 4 个理由卡片。
### 9. 拆分 CompanyProfile 组件
提取公司简介为 `app/components/CompanyProfile.vue`,包含 3 列文字和 4 个能力卡片。
### 10. 拆分 CustomerCases 组件
提取客户案例为 `app/components/CustomerCases.vue`,包含 marquee 滚动的案例卡片。
### 11. 拆分 CtaBanner 组件
提取行动号召为 `app/components/CtaBanner.vue`,包含 CTA 标题和按钮。
### 12. 拆分 AppFooter 组件
提取页脚为 `app/components/AppFooter.vue`,包含 4 列链接和版权信息。
### 13. 重构 app.vue 为组件组合
`app.vue` 简化为引入所有拆分组件的组合文件。
### 14. AppHeader 响应式(汉堡菜单 + 侧边滑出)
- 移动端:隐藏导航链接,显示汉堡图标
- 点击汉堡图标:从右侧滑出半透明遮罩 + 导航面板
- 平板:保持桌面导航或简化
### 15. HeroBanner 响应式
- 移动端:字号缩小(text-7xl → text-3xl),产品图竖向堆叠或隐藏
- 平板:中等字号,产品图适当缩小
### 16. TrustSection 响应式
- 移动端:2x2 网格布局,缩小卡片尺寸
- 平板:4 列但间距缩小
### 17. StepProcess 响应式(触摸滑动)
- 移动端:步骤选择器变为横向滚动标签,图片预览支持触摸滑动
- 平板:保持桌面布局但缩小尺寸
### 18. PodProducts 响应式(触摸滑动)
- 移动端:产品网格变为横向滚动列表,支持触摸滑动
- 平板:3 列网格
### 19. FeatureCards 响应式
- 移动端:2 列或堆叠布局
- 平板:保持 4 列但缩小卡片
### 20. WhyInkReach 响应式
- 移动端:2x2 网格
- 平板:4 列(已有 md:grid-cols-4
### 21. CompanyProfile 响应式
- 移动端:文字单列,能力卡片 2 列
- 平板:完善现有响应式类
### 22. CustomerCases 响应式(触摸滑动)
- 移动端:取消 marquee 自动滚动,改为触摸滑动卡片列表
- 平板:保持 marquee 但缩小卡片
### 23. CtaBanner 响应式
- 移动端:缩小字号和内边距
- 平板:适度缩小
### 24. AppFooter 响应式
- 移动端:单列堆叠布局
- 平板:2x2 网格(已有 md:grid-cols-4
### 25. 修复 pulse-animation + 通用样式
`tailwind.css``@theme` 中定义缺失的 `pulse-animation`,检查所有固定宽度改为响应式。
### 26. 全页面三断点验证
在浏览器中分别用移动端(375px)、平板(768px)、桌面(1440px)宽度验证每个区块的显示效果。
### 27. 更新 docs/references/structs.md
记录拆分后的项目目录结构和组件功能描述。