feat(product-center): implement full product center with NestJS backend, Vue3 admin, and Nuxt4 website page
- NestJS backend: Prisma + PostgreSQL, JWT auth, CRUD for goods/categories/countries/tags/positions, SDS sync with cron, public API, Swagger docs - Vue3 Admin: Element Plus, goods/categories/countries/tags/positions/sync management pages, batch operations, login with JWT - Nuxt4 Website: product-center page with sidebar navigation, country filter, search, product grid, pagination, skeleton loading - Nitro proxy routes for backend API - 54 backend tests passing - Updated docs and README
This commit is contained in:
@@ -0,0 +1,109 @@
|
|||||||
|
# InkReach Product Center
|
||||||
|
|
||||||
|
InkReach 官方产品中心项目,包含三个协同工作的子项目:
|
||||||
|
|
||||||
|
| 子项目 | 技术栈 | 端口 | 角色 |
|
||||||
|
|--------|--------|------|------|
|
||||||
|
| `inkreach-official-nestjs` | NestJS 10 + Prisma 5 + PostgreSQL | 3001 | 后端 API(鉴权 / CRUD / SDS 同步 / 公开 API / Swagger) |
|
||||||
|
| `inkreach-official-admin` | Vue 3 + Vite + Element Plus + Pinia | 5173 | 后台管理(登录 / 商品 / 品类 / 国家 / 标签 / 坑位 / 同步) |
|
||||||
|
| `inkreach-official-website` | Nuxt 4 + Vue 3 + Tailwind v4 | 3000 | 官方展示站 + 产品中心页 |
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 启动顺序
|
||||||
|
|
||||||
|
后端必须先于前两个子项目启动,否则前端代理会失败。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1:后端(需要 PostgreSQL)
|
||||||
|
cd inkreach-official-nestjs
|
||||||
|
npm install
|
||||||
|
npm run prisma:generate
|
||||||
|
npm run prisma:migrate
|
||||||
|
npm run start:dev
|
||||||
|
# → http://localhost:3001 · Swagger: http://localhost:3001/api/docs
|
||||||
|
|
||||||
|
# Terminal 2:官网
|
||||||
|
cd inkreach-official-website
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
# → http://localhost:3000
|
||||||
|
|
||||||
|
# Terminal 3:后台
|
||||||
|
cd inkreach-official-admin
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
# → http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 环境变量
|
||||||
|
|
||||||
|
在仓库根目录的 `.env` 中配置数据库:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgresql://postgres:<password>@localhost:5432/inkreach-official
|
||||||
|
```
|
||||||
|
|
||||||
|
后端额外需要的变量(在 `inkreach-official-nestjs/.env` 中):
|
||||||
|
|
||||||
|
```env
|
||||||
|
JWT_SECRET=<your-secret>
|
||||||
|
SDS_API_BASE=https://api.sds.example.com
|
||||||
|
SDS_API_KEY=<your-key>
|
||||||
|
PORT=3001
|
||||||
|
```
|
||||||
|
|
||||||
|
官网默认通过 `NUXT_PUBLIC_BACKEND_URL=http://localhost:3001` 指向后端,可写入 `inkreach-official-website/.env` 覆盖。
|
||||||
|
|
||||||
|
后台无需额外环境变量;Vite 代理已把 `/api/*` 转给 `http://localhost:3001`。
|
||||||
|
|
||||||
|
## 端口分配
|
||||||
|
|
||||||
|
| 端口 | 项目 | 入口 |
|
||||||
|
|------|------|------|
|
||||||
|
| 3001 | NestJS 后端 | `inkreach-official-nestjs/src/main.ts` |
|
||||||
|
| 5173 | Vue Admin | `inkreach-official-admin/vite.config.ts` |
|
||||||
|
| 3000 | Nuxt 官网 | `inkreach-official-website/nuxt.config.ts` |
|
||||||
|
|
||||||
|
## 数据流概览
|
||||||
|
|
||||||
|
```
|
||||||
|
[SDS 外部 API]
|
||||||
|
│ (同步)
|
||||||
|
▼
|
||||||
|
[NestJS 后端 :3001] ── public/goods ──► [Nitro 代理 /api/backend/*] ──► [Nuxt 官网 :3000]
|
||||||
|
│ ▲
|
||||||
|
├── /auth /goods /categories ... ──► │ Vite proxy /api/*
|
||||||
|
│ │
|
||||||
|
[PostgreSQL] [Vue Admin :5173]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 后端
|
||||||
|
cd inkreach-official-nestjs
|
||||||
|
npm run start:dev # 开发
|
||||||
|
npm run build # 编译
|
||||||
|
npm run test # 单元测试
|
||||||
|
npm run prisma:studio # 打开 Prisma Studio
|
||||||
|
|
||||||
|
# 官网
|
||||||
|
cd inkreach-official-website
|
||||||
|
npm run dev # 开发
|
||||||
|
npm run build # 构建
|
||||||
|
npm run generate # 静态站点生成
|
||||||
|
|
||||||
|
# 后台
|
||||||
|
cd inkreach-official-admin
|
||||||
|
npm run dev # 开发
|
||||||
|
npm run build # 构建(含 vue-tsc 类型检查)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
- 整体目录结构:[`docs/references/structs.md`](docs/references/structs.md)
|
||||||
|
- 数据库表设计:[`docs/dev/database-table-design.md`](docs/dev/database-table-design.md)
|
||||||
|
- 产品中心 PRD:[`docs/dev/product-center-prd.md`](docs/dev/product-center-prd.md)
|
||||||
|
- 官网子项目结构:[`inkreach-official-website/docs/references/structs.md`](inkreach-official-website/docs/references/structs.md)
|
||||||
|
- 开发规范:[`AGENTS.md`](AGENTS.md)
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
# InkReach Product Center — Monorepo Structure
|
||||||
|
|
||||||
|
> 全局根目录结构说明。三个子项目独立维护各自 `package.json` 与技术栈,通过约定的端口协同工作。
|
||||||
|
|
||||||
|
## 根目录
|
||||||
|
|
||||||
|
```
|
||||||
|
inkreach-official/
|
||||||
|
├── .agents/ # AI Agent 共享技能(brainstorming / TDD / git-spec / ...)
|
||||||
|
├── docs/ # 跨子项目文档
|
||||||
|
│ ├── dev/ # 设计文档(数据库表设计、PRD、原型图)
|
||||||
|
│ │ ├── database-table-design.md
|
||||||
|
│ │ ├── product-center-prd.md
|
||||||
|
│ │ └── product-center-{1,2,3}.png
|
||||||
|
│ ├── references/
|
||||||
|
│ │ └── structs.md # 本文件
|
||||||
|
│ └── superpowers/specs/ # superpowers 规格说明
|
||||||
|
├── plans/ # 跨子项目计划
|
||||||
|
│ └── feature/
|
||||||
|
├── AGENTS.md # 根级 AI Agent 开发规范
|
||||||
|
├── skills-lock.json # 技能版本锁定
|
||||||
|
├── .env # 根级环境变量(DATABASE_URL 等)
|
||||||
|
├── .git/ # 单一 Git 仓库,trunk-based
|
||||||
|
├── inkreach-official-nestjs/ # 子项目 1:NestJS 后端
|
||||||
|
├── inkreach-official-admin/ # 子项目 2:Vue 3 后台
|
||||||
|
└── inkreach-official-website/ # 子项目 3:Nuxt 4 官网
|
||||||
|
```
|
||||||
|
|
||||||
|
## 端口与子项目
|
||||||
|
|
||||||
|
| 子项目 | 端口 | 启动命令 | 说明 |
|
||||||
|
|--------|------|----------|------|
|
||||||
|
| inkreach-official-nestjs | 3001 | `npm run start:dev` | NestJS + Prisma + PostgreSQL 后端 API;Swagger 文档 `/api/docs` |
|
||||||
|
| inkreach-official-admin | 5173 | `npm run dev` | Vue 3 + Element Plus 后台管理;通过 Vite proxy `/api → :3001` |
|
||||||
|
| inkreach-official-website | 3000 | `npm run dev` | Nuxt 4 官网;通过 Nitro `server/api/backend/*` 代理 `:3001` |
|
||||||
|
|
||||||
|
启动顺序:先启动 `inkreach-official-nestjs`,再启动另两个。
|
||||||
|
|
||||||
|
## 子项目 1:inkreach-official-nestjs(后端)
|
||||||
|
|
||||||
|
```
|
||||||
|
inkreach-official-nestjs/
|
||||||
|
├── prisma/
|
||||||
|
│ ├── schema.prisma # 数据模型(OriginGood/Country/Category/Tag/Position/Good/User/SyncLog)
|
||||||
|
│ └── migrations/ # Prisma migrate 历史
|
||||||
|
├── src/
|
||||||
|
│ ├── main.ts # 入口:CORS、ValidationPipe、Swagger、BigInt JSON 序列化
|
||||||
|
│ ├── app.module.ts # 根模块,聚合所有业务模块
|
||||||
|
│ ├── prisma/ # PrismaService 封装
|
||||||
|
│ ├── auth/ # JWT 认证:register / login / JwtStrategy / JwtAuthGuard
|
||||||
|
│ ├── countries/ # 国家 CRUD(受 JWT 保护)
|
||||||
|
│ ├── categories/ # 品类 CRUD(受 JWT 保护,自引用树)
|
||||||
|
│ ├── tags/ # 标签 CRUD(受 JWT 保护)
|
||||||
|
│ ├── positions/ # 坑位 CRUD(受 JWT 保护)
|
||||||
|
│ ├── origin-goods/ # SDS 原始商品快照(只读分页)
|
||||||
|
│ ├── goods/ # 商品 CRUD + 批量优先级 + 批量创建
|
||||||
|
│ ├── sync/ # SDS 同步:分类 / 商品 / 同步日志
|
||||||
|
│ ├── public/ # 公开 API:分类树 / 国家 / 商品分页 / 商品详情
|
||||||
|
│ └── common/ # 全局装饰器 / 过滤器 / 拦截器
|
||||||
|
│ ├── decorators/current-user.decorator.ts
|
||||||
|
│ ├── filters/http-exception.filter.ts
|
||||||
|
│ └── interceptors/transform.interceptor.ts
|
||||||
|
├── test/ # e2e 测试
|
||||||
|
├── dist/ # 构建产物
|
||||||
|
├── nest-cli.json
|
||||||
|
├── tsconfig.json / tsconfig.build.json
|
||||||
|
├── jest.config.js
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据模型(Prisma)
|
||||||
|
|
||||||
|
| 模型 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `OriginGood` | SDS 原始商品缓存,关联 `sds_good_id`(唯一) |
|
||||||
|
| `Country` | 国家,关联 goods / positions |
|
||||||
|
| `Category` | 自引用树形品类,可选 `sds_category_id` |
|
||||||
|
| `Tag` | 标签,含 `tagColor`、`timing` |
|
||||||
|
| `Position` | 坑位:`(country, category)` 维度,关联多个 goods |
|
||||||
|
| `Good` | 商品:`originGood × country × category × tag? × position?`,含 `goodPriority` |
|
||||||
|
| `User` | 后台用户(bcrypt 哈希) |
|
||||||
|
| `SyncLog` | 同步任务日志,含 `SyncType`(CATEGORIES / PRODUCTS)和 `SyncStatus` |
|
||||||
|
|
||||||
|
### 关键设计
|
||||||
|
|
||||||
|
- **所有主键为 `BigInt`**,路由解析后 `BigInt(id)` 处理,序列化时通过 `BigInt.prototype.toJSON` 转为字符串。
|
||||||
|
- **全局 `TransformInterceptor`**:把响应包装为 `{ data: T, success: true }`,前端读取 `response.data`。
|
||||||
|
- **全局 `ValidationPipe`**:`whitelist + transform + forbidNonWhitelisted`。
|
||||||
|
- **全局 `HttpExceptionFilter`**:统一错误响应形态。
|
||||||
|
- **CORS 白名单**:`http://localhost:5173`(admin)和 `http://localhost:3000`(website)。
|
||||||
|
- **JWT**:所有 `/goods /categories /countries /tags /positions /origin-goods /sync/*` 路由受 `JwtAuthGuard` 保护;`/public/*` 与 `/auth/*` 公开。
|
||||||
|
|
||||||
|
### API 路由
|
||||||
|
|
||||||
|
| 前缀 | 说明 | 鉴权 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/auth/register` `POST` | 注册后台用户 | 公开 |
|
||||||
|
| `/auth/login` `POST` | 登录获取 JWT | 公开 |
|
||||||
|
| `/public/categories` `GET` | 公开品类树(仅含已挂商品的品类) | 公开 |
|
||||||
|
| `/public/countries` `GET` | 公开国家列表(仅含已挂商品的国家) | 公开 |
|
||||||
|
| `/public/goods` `GET` | 分页商品(支持 `countryId/categoryId/tagId/keyword/page/pageSize`) | 公开 |
|
||||||
|
| `/public/goods/:id` `GET` | 商品详情 | 公开 |
|
||||||
|
| `/categories` `/tags` `/countries` `/positions` | 后台 CRUD | JWT |
|
||||||
|
| `/origin-goods` `GET` | SDS 原始商品快照分页 | JWT |
|
||||||
|
| `/goods` | 后台商品 CRUD + `POST /goods/batch` + `PATCH /goods/batch-priority` | JWT |
|
||||||
|
| `/sync/categories` `POST` | 手动触发分类同步 | JWT |
|
||||||
|
| `/sync/products` `POST` | 手动触发商品同步 | JWT |
|
||||||
|
| `/sync/status` `GET` | 最近同步日志(`?limit=20`) | JWT |
|
||||||
|
| `/api/docs` | Swagger UI | 公开 |
|
||||||
|
|
||||||
|
## 子项目 2:inkreach-official-admin(后台)
|
||||||
|
|
||||||
|
```
|
||||||
|
inkreach-official-admin/
|
||||||
|
├── public/ # 静态资源
|
||||||
|
├── src/
|
||||||
|
│ ├── main.ts # 入口:Pinia + Vue Router + ElementPlus
|
||||||
|
│ ├── App.vue
|
||||||
|
│ ├── style.css # 全局样式(含品牌色变量)
|
||||||
|
│ ├── api/ # 按业务模块拆分的 API 客户端
|
||||||
|
│ │ ├── request.ts # axios 实例 + JWT 拦截 + 全局错误处理
|
||||||
|
│ │ ├── auth.ts # /auth/login, /auth/me, /auth/logout
|
||||||
|
│ │ ├── goods.ts # 商品 CRUD + 批量
|
||||||
|
│ │ ├── categories.ts # 品类 CRUD
|
||||||
|
│ │ ├── countries.ts # 国家 CRUD
|
||||||
|
│ │ ├── tags.ts # 标签 CRUD
|
||||||
|
│ │ ├── positions.ts # 坑位 CRUD
|
||||||
|
│ │ ├── origin-goods.ts # 原始商品快照
|
||||||
|
│ │ └── sync.ts # 同步触发 + 日志
|
||||||
|
│ ├── layouts/DefaultLayout.vue # 侧边栏 + 顶部条 + 用户菜单
|
||||||
|
│ ├── router/index.ts # 路由 + 登录守卫
|
||||||
|
│ ├── stores/
|
||||||
|
│ │ ├── auth.ts # 登录态 + token + user(持久化到 localStorage)
|
||||||
|
│ │ └── app.ts # 侧边栏折叠
|
||||||
|
│ ├── types/index.ts # 共享类型
|
||||||
|
│ ├── views/
|
||||||
|
│ │ ├── login/LoginView.vue # 登录
|
||||||
|
│ │ ├── goods/GoodsView.vue
|
||||||
|
│ │ ├── categories/CategoriesView.vue
|
||||||
|
│ │ ├── countries/CountriesView.vue
|
||||||
|
│ │ ├── tags/TagsView.vue
|
||||||
|
│ │ ├── positions/PositionsView.vue
|
||||||
|
│ │ └── sync/SyncView.vue
|
||||||
|
│ ├── auto-imports.d.ts # 自动生成的自动导入类型
|
||||||
|
│ └── components.d.ts # 自动生成的组件类型
|
||||||
|
├── index.html
|
||||||
|
├── vite.config.ts # Vite + ElementPlus 自动导入 + /api → :3001 代理
|
||||||
|
├── tsconfig.json / tsconfig.app.json / tsconfig.node.json
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 路由
|
||||||
|
|
||||||
|
| 路径 | 视图 | 鉴权 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/login` | LoginView | 公开 |
|
||||||
|
| `/goods` | GoodsView(默认页) | JWT |
|
||||||
|
| `/categories` | CategoriesView | JWT |
|
||||||
|
| `/countries` | CountriesView | JWT |
|
||||||
|
| `/tags` | TagsView | JWT |
|
||||||
|
| `/positions` | PositionsView | JWT |
|
||||||
|
| `/sync` | SyncView | JWT |
|
||||||
|
| `/:pathMatch(.*)*` | 重定向到 `/goods` | — |
|
||||||
|
|
||||||
|
### 关键设计
|
||||||
|
|
||||||
|
- **Vite 代理**:`/api/*` 代理到 `http://localhost:3001`,rewrite 去掉 `/api` 前缀。
|
||||||
|
- **axios 拦截器**:请求注入 `Authorization: Bearer <token>`;响应直接返回 `response.data`;401 自动登出跳转。
|
||||||
|
- **ElementPlus 自动导入**:通过 `unplugin-auto-import` + `unplugin-vue-components` + `ElementPlusResolver`。
|
||||||
|
- **路由守卫**:未登录访问受保护路由跳 `/login`;已登录访问 `/login` 跳 `/`。
|
||||||
|
- **Pinia 持久化**:`auth` store 主动读写 `localStorage`(`token` + `user`)。
|
||||||
|
|
||||||
|
## 子项目 3:inkreach-official-website(官网)
|
||||||
|
|
||||||
|
```
|
||||||
|
inkreach-official-website/
|
||||||
|
├── app/
|
||||||
|
│ ├── app.vue # 根容器:<NuxtPage />
|
||||||
|
│ ├── pages/
|
||||||
|
│ │ ├── index.vue # 首页
|
||||||
|
│ │ └── product-center.vue # 产品中心(侧边栏 + 国家/筛选 + 网格 + 分页)
|
||||||
|
│ ├── assets/css/tailwind.css # Tailwind v4 主题(@theme 定义颜色与动画)
|
||||||
|
│ ├── composables/
|
||||||
|
│ │ ├── useNavData.ts # 导航数据(选品推荐/解决方案)
|
||||||
|
│ │ ├── usePodProducts.ts # 首页 POD 产品(SDS)
|
||||||
|
│ │ └── useProductCenter.ts # 产品中心数据(调用 /api/backend/*)
|
||||||
|
│ └── components/
|
||||||
|
│ ├── AppHeader.vue / AppFooter.vue
|
||||||
|
│ ├── nav/ # 导航下拉面板
|
||||||
|
│ │ ├── NavMegaMenu.vue
|
||||||
|
│ │ └── NavColumnMenu.vue
|
||||||
|
│ ├── product/ # 产品中心专用组件
|
||||||
|
│ │ ├── ProductSidebar.vue # 树形品类侧边栏
|
||||||
|
│ │ ├── ProductCountryFilter.vue # 国家 pill 筛选
|
||||||
|
│ │ ├── ProductFilterBar.vue # 搜索输入 + 按钮
|
||||||
|
│ │ ├── ProductCard.vue # 商品卡片
|
||||||
|
│ │ ├── ProductCardSkeleton.vue # 骨架占位
|
||||||
|
│ │ ├── ProductGrid.vue # 网格容器
|
||||||
|
│ │ └── ProductPagination.vue # 分页器
|
||||||
|
│ ├── HeroBanner.vue
|
||||||
|
│ ├── TrustSection.vue
|
||||||
|
│ ├── StepProcess.vue
|
||||||
|
│ ├── PodProducts.vue
|
||||||
|
│ ├── FeatureCards.vue
|
||||||
|
│ ├── WhyInkReach.vue
|
||||||
|
│ ├── CompanyProfile.vue
|
||||||
|
│ ├── CustomerCases.vue
|
||||||
|
│ └── CtaBanner.vue
|
||||||
|
├── server/ # Nitro 后端
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── pod/ # 原有 SDS POD API 代理
|
||||||
|
│ │ └── backend/ # NestJS 后端代理(同源 + Nitro 缓存 60s)
|
||||||
|
│ │ ├── categories.get.ts # → GET :3001/public/categories
|
||||||
|
│ │ ├── countries.get.ts # → GET :3001/public/countries
|
||||||
|
│ │ ├── goods.get.ts # → GET :3001/public/goods
|
||||||
|
│ │ └── goods/[id].get.ts # → GET :3001/public/goods/:id
|
||||||
|
│ └── utils/pod-api.ts
|
||||||
|
├── public/ # 静态资源
|
||||||
|
├── plans/feature/ # 历史功能计划
|
||||||
|
├── docs/
|
||||||
|
│ ├── references/structs.md # 子项目级结构文档
|
||||||
|
│ └── superpowers/specs/
|
||||||
|
├── nuxt.config.ts # runtimeConfig.public.backendUrl
|
||||||
|
├── .env # NUXT_PUBLIC_BACKEND_URL=http://localhost:3001
|
||||||
|
├── AGENTS.md # 子项目 AI Agent 规范
|
||||||
|
├── README.md
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
> 子项目内部的页面、组件、composable、代理路由、组件响应式断点等详细信息见 `inkreach-official-website/docs/references/structs.md`。
|
||||||
|
|
||||||
|
## 数据库
|
||||||
|
|
||||||
|
- PostgreSQL 14+,Prisma 5.x
|
||||||
|
- 连接配置在根 `.env` 的 `DATABASE_URL` 中
|
||||||
|
- 所有 `TIMESTAMPTZ` 列:`@db.Timestamptz(6)`
|
||||||
|
- 所有主键:`BigInt @default(autoincrement())`
|
||||||
|
- 表名与列名通过 `@map` / `@@map` 映射为 `snake_case`
|
||||||
|
- 迁移位于 `inkreach-official-nestjs/prisma/migrations/`
|
||||||
|
|
||||||
|
## 环境变量总览
|
||||||
|
|
||||||
|
| 变量 | 位置 | 用途 | 默认 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `DATABASE_URL` | 根 `.env` | PostgreSQL 连接串 | — |
|
||||||
|
| `JWT_SECRET` | inkreach-official-nestjs | JWT 签名密钥 | — |
|
||||||
|
| `PORT` | inkreach-official-nestjs | 后端端口 | `3001` |
|
||||||
|
| `SDS_API_*` | inkreach-official-nestjs | 同步上游 SDS 接口凭据 | — |
|
||||||
|
| `VITE_API_BASE` | inkreach-official-admin | axios baseURL | `/api` |
|
||||||
|
| `NUXT_PUBLIC_BACKEND_URL` | inkreach-official-website | NestJS 后端地址 | `http://localhost:3001` |
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE=/api
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Vue 3 + TypeScript + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>inkreach-official-admin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+2758
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "inkreach-official-admin",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
|
"@vueuse/core": "^14.3.0",
|
||||||
|
"axios": "^1.18.0",
|
||||||
|
"dayjs": "^1.11.21",
|
||||||
|
"element-plus": "^2.14.2",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"vue": "^3.5.34",
|
||||||
|
"vue-router": "^4.6.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.12.3",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
|
"@vue/tsconfig": "^0.9.1",
|
||||||
|
"sass": "^1.101.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"unplugin-auto-import": "^21.0.0",
|
||||||
|
"unplugin-vue-components": "^32.1.0",
|
||||||
|
"vite": "^8.0.12",
|
||||||
|
"vue-tsc": "^3.2.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Root component renders router-managed views.
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import request from './request'
|
||||||
|
import type { LoginRequest, LoginResponse, User } from '@/types'
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
// Login
|
||||||
|
login: (data: LoginRequest) => {
|
||||||
|
return request.post<any, LoginResponse>('/auth/login', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
getCurrentUser: () => {
|
||||||
|
return request.get<any, User>('/auth/me')
|
||||||
|
},
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
logout: () => {
|
||||||
|
return request.post('/auth/logout')
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import request from './request'
|
||||||
|
import type {
|
||||||
|
Category,
|
||||||
|
CategoryTree,
|
||||||
|
CreateCategoryRequest,
|
||||||
|
UpdateCategoryRequest,
|
||||||
|
CategoryFilter,
|
||||||
|
PaginatedResult,
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
|
export const categoriesApi = {
|
||||||
|
// Get categories list
|
||||||
|
getCategoriesList: (params: CategoryFilter) => {
|
||||||
|
return request.get<any, PaginatedResult<Category>>('/categories', { params })
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get category tree
|
||||||
|
getCategoryTree: () => {
|
||||||
|
return request.get<any, CategoryTree[]>('/categories/tree')
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get category by id
|
||||||
|
getCategoryById: (id: string) => {
|
||||||
|
return request.get<any, Category>(`/categories/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create category
|
||||||
|
createCategory: (data: CreateCategoryRequest) => {
|
||||||
|
return request.post<any, Category>('/categories', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update category
|
||||||
|
updateCategory: (id: string, data: UpdateCategoryRequest) => {
|
||||||
|
return request.patch<any, Category>(`/categories/${id}`, data)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete category
|
||||||
|
deleteCategory: (id: string) => {
|
||||||
|
return request.delete(`/categories/${id}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import request from './request'
|
||||||
|
import type {
|
||||||
|
Country,
|
||||||
|
CreateCountryRequest,
|
||||||
|
UpdateCountryRequest,
|
||||||
|
CountryFilter,
|
||||||
|
PaginatedResult,
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
|
export const countriesApi = {
|
||||||
|
// Get countries list
|
||||||
|
getCountriesList: (params: CountryFilter) => {
|
||||||
|
return request.get<any, PaginatedResult<Country>>('/countries', { params })
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get country by id
|
||||||
|
getCountryById: (id: string) => {
|
||||||
|
return request.get<any, Country>(`/countries/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create country
|
||||||
|
createCountry: (data: CreateCountryRequest) => {
|
||||||
|
return request.post<any, Country>('/countries', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update country
|
||||||
|
updateCountry: (id: string, data: UpdateCountryRequest) => {
|
||||||
|
return request.patch<any, Country>(`/countries/${id}`, data)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete country
|
||||||
|
deleteCountry: (id: string) => {
|
||||||
|
return request.delete(`/countries/${id}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import request from './request'
|
||||||
|
import type {
|
||||||
|
Good,
|
||||||
|
CreateGoodRequest,
|
||||||
|
UpdateGoodRequest,
|
||||||
|
BatchCreateGoodsRequest,
|
||||||
|
UpdatePriorityRequest,
|
||||||
|
GoodsFilter,
|
||||||
|
PaginatedResult,
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
|
export const goodsApi = {
|
||||||
|
// Get goods list
|
||||||
|
getGoodsList: (params: GoodsFilter) => {
|
||||||
|
return request.get<any, PaginatedResult<Good>>('/goods', { params })
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get good by id
|
||||||
|
getGoodById: (id: string) => {
|
||||||
|
return request.get<any, Good>(`/goods/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create good
|
||||||
|
createGood: (data: CreateGoodRequest) => {
|
||||||
|
return request.post<any, Good>('/goods', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update good
|
||||||
|
updateGood: (id: string, data: UpdateGoodRequest) => {
|
||||||
|
return request.patch<any, Good>(`/goods/${id}`, data)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete good
|
||||||
|
deleteGood: (id: string) => {
|
||||||
|
return request.delete(`/goods/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Batch create goods
|
||||||
|
batchCreateGoods: (data: BatchCreateGoodsRequest) => {
|
||||||
|
return request.post<any, Good[]>('/goods/batch', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update priority
|
||||||
|
updatePriority: (data: UpdatePriorityRequest) => {
|
||||||
|
return request.patch<any, Good[]>('/goods/priority', data)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import request from './request'
|
||||||
|
import type { OriginGood } from '@/types'
|
||||||
|
|
||||||
|
export const originGoodsApi = {
|
||||||
|
// Search origin goods
|
||||||
|
searchOriginGoods: (keyword: string) => {
|
||||||
|
return request.get<any, OriginGood[]>('/origin-goods/search', { params: { keyword } })
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get origin goods list
|
||||||
|
getOriginGoodsList: (page = 1, pageSize = 20) => {
|
||||||
|
return request.get<any, { data: OriginGood[]; total: number }>('/origin-goods', {
|
||||||
|
params: { page, pageSize },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import request from './request'
|
||||||
|
import type {
|
||||||
|
Position,
|
||||||
|
CreatePositionRequest,
|
||||||
|
UpdatePositionRequest,
|
||||||
|
PositionFilter,
|
||||||
|
PaginatedResult,
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
|
export const positionsApi = {
|
||||||
|
// Get positions list
|
||||||
|
getPositionsList: (params: PositionFilter) => {
|
||||||
|
return request.get<any, PaginatedResult<Position>>('/positions', { params })
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get position by id
|
||||||
|
getPositionById: (id: string) => {
|
||||||
|
return request.get<any, Position>(`/positions/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create position
|
||||||
|
createPosition: (data: CreatePositionRequest) => {
|
||||||
|
return request.post<any, Position>('/positions', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update position
|
||||||
|
updatePosition: (id: string, data: UpdatePositionRequest) => {
|
||||||
|
return request.patch<any, Position>(`/positions/${id}`, data)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete position
|
||||||
|
deletePosition: (id: string) => {
|
||||||
|
return request.delete(`/positions/${id}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import router from '@/router'
|
||||||
|
|
||||||
|
const request: AxiosInstance = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_BASE || '/api',
|
||||||
|
timeout: 30000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Request interceptor
|
||||||
|
request.interceptors.request.use(
|
||||||
|
(config: AxiosRequestConfig) => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (token && config.headers) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
(error: AxiosError) => {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Response interceptor
|
||||||
|
request.interceptors.response.use(
|
||||||
|
(response: AxiosResponse) => {
|
||||||
|
// Unwrap data.data
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
(error: AxiosError) => {
|
||||||
|
if (error.response) {
|
||||||
|
const { status, data } = error.response
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case 401:
|
||||||
|
ElMessage.error('Unauthorized, please login')
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
router.push('/login')
|
||||||
|
break
|
||||||
|
case 403:
|
||||||
|
ElMessage.error('Forbidden')
|
||||||
|
break
|
||||||
|
case 404:
|
||||||
|
ElMessage.error('Resource not found')
|
||||||
|
break
|
||||||
|
case 500:
|
||||||
|
ElMessage.error('Server error')
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
const errorMessage = (data as any)?.message || 'Request failed'
|
||||||
|
ElMessage.error(errorMessage)
|
||||||
|
}
|
||||||
|
} else if (error.request) {
|
||||||
|
ElMessage.error('Network error')
|
||||||
|
} else {
|
||||||
|
ElMessage.error('Request failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default request
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import request from './request'
|
||||||
|
import type { SyncLog, SyncStats, PaginatedResult } from '@/types'
|
||||||
|
|
||||||
|
export const syncApi = {
|
||||||
|
// Trigger category sync
|
||||||
|
syncCategories: () => {
|
||||||
|
return request.post<any, SyncLog>('/sync/categories')
|
||||||
|
},
|
||||||
|
|
||||||
|
// Trigger product sync
|
||||||
|
syncProducts: () => {
|
||||||
|
return request.post<any, SyncLog>('/sync/products')
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get sync logs
|
||||||
|
getSyncLogs: (params: { page?: number; pageSize?: number; type?: 'CATEGORY' | 'PRODUCT' }) => {
|
||||||
|
return request.get<any, PaginatedResult<SyncLog>>('/sync/logs', { params })
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get sync stats
|
||||||
|
getSyncStats: () => {
|
||||||
|
return request.get<any, SyncStats>('/sync/stats')
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import request from './request'
|
||||||
|
import type {
|
||||||
|
Tag,
|
||||||
|
CreateTagRequest,
|
||||||
|
UpdateTagRequest,
|
||||||
|
TagFilter,
|
||||||
|
PaginatedResult,
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
|
export const tagsApi = {
|
||||||
|
// Get tags list
|
||||||
|
getTagsList: (params: TagFilter) => {
|
||||||
|
return request.get<any, PaginatedResult<Tag>>('/tags', { params })
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get tag by id
|
||||||
|
getTagById: (id: string) => {
|
||||||
|
return request.get<any, Tag>(`/tags/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create tag
|
||||||
|
createTag: (data: CreateTagRequest) => {
|
||||||
|
return request.post<any, Tag>('/tags', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update tag
|
||||||
|
updateTag: (id: string, data: UpdateTagRequest) => {
|
||||||
|
return request.patch<any, Tag>(`/tags/${id}`, data)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete tag
|
||||||
|
deleteTag: (id: string) => {
|
||||||
|
return request.delete(`/tags/${id}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* prettier-ignore */
|
||||||
|
// @ts-nocheck
|
||||||
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
// Generated by unplugin-auto-import
|
||||||
|
// biome-ignore lint: disable
|
||||||
|
export {}
|
||||||
|
declare global {
|
||||||
|
const EffectScope: typeof import('vue').EffectScope
|
||||||
|
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
|
||||||
|
const computed: typeof import('vue').computed
|
||||||
|
const createApp: typeof import('vue').createApp
|
||||||
|
const createPinia: typeof import('pinia').createPinia
|
||||||
|
const customRef: typeof import('vue').customRef
|
||||||
|
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
|
||||||
|
const defineComponent: typeof import('vue').defineComponent
|
||||||
|
const defineStore: typeof import('pinia').defineStore
|
||||||
|
const effectScope: typeof import('vue').effectScope
|
||||||
|
const getActivePinia: typeof import('pinia').getActivePinia
|
||||||
|
const getCurrentInstance: typeof import('vue').getCurrentInstance
|
||||||
|
const getCurrentScope: typeof import('vue').getCurrentScope
|
||||||
|
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
|
||||||
|
const h: typeof import('vue').h
|
||||||
|
const inject: typeof import('vue').inject
|
||||||
|
const isProxy: typeof import('vue').isProxy
|
||||||
|
const isReactive: typeof import('vue').isReactive
|
||||||
|
const isReadonly: typeof import('vue').isReadonly
|
||||||
|
const isRef: typeof import('vue').isRef
|
||||||
|
const isShallow: typeof import('vue').isShallow
|
||||||
|
const mapActions: typeof import('pinia').mapActions
|
||||||
|
const mapGetters: typeof import('pinia').mapGetters
|
||||||
|
const mapState: typeof import('pinia').mapState
|
||||||
|
const mapStores: typeof import('pinia').mapStores
|
||||||
|
const mapWritableState: typeof import('pinia').mapWritableState
|
||||||
|
const markRaw: typeof import('vue').markRaw
|
||||||
|
const nextTick: typeof import('vue').nextTick
|
||||||
|
const onActivated: typeof import('vue').onActivated
|
||||||
|
const onBeforeMount: typeof import('vue').onBeforeMount
|
||||||
|
const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
|
||||||
|
const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
|
||||||
|
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
|
||||||
|
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
|
||||||
|
const onDeactivated: typeof import('vue').onDeactivated
|
||||||
|
const onErrorCaptured: typeof import('vue').onErrorCaptured
|
||||||
|
const onMounted: typeof import('vue').onMounted
|
||||||
|
const onRenderTracked: typeof import('vue').onRenderTracked
|
||||||
|
const onRenderTriggered: typeof import('vue').onRenderTriggered
|
||||||
|
const onScopeDispose: typeof import('vue').onScopeDispose
|
||||||
|
const onServerPrefetch: typeof import('vue').onServerPrefetch
|
||||||
|
const onUnmounted: typeof import('vue').onUnmounted
|
||||||
|
const onUpdated: typeof import('vue').onUpdated
|
||||||
|
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
|
||||||
|
const provide: typeof import('vue').provide
|
||||||
|
const reactive: typeof import('vue').reactive
|
||||||
|
const readonly: typeof import('vue').readonly
|
||||||
|
const ref: typeof import('vue').ref
|
||||||
|
const resolveComponent: typeof import('vue').resolveComponent
|
||||||
|
const setActivePinia: typeof import('pinia').setActivePinia
|
||||||
|
const setMapStoreSuffix: typeof import('pinia').setMapStoreSuffix
|
||||||
|
const shallowReactive: typeof import('vue').shallowReactive
|
||||||
|
const shallowReadonly: typeof import('vue').shallowReadonly
|
||||||
|
const shallowRef: typeof import('vue').shallowRef
|
||||||
|
const storeToRefs: typeof import('pinia').storeToRefs
|
||||||
|
const toRaw: typeof import('vue').toRaw
|
||||||
|
const toRef: typeof import('vue').toRef
|
||||||
|
const toRefs: typeof import('vue').toRefs
|
||||||
|
const toValue: typeof import('vue').toValue
|
||||||
|
const triggerRef: typeof import('vue').triggerRef
|
||||||
|
const unref: typeof import('vue').unref
|
||||||
|
const useAttrs: typeof import('vue').useAttrs
|
||||||
|
const useCssModule: typeof import('vue').useCssModule
|
||||||
|
const useCssVars: typeof import('vue').useCssVars
|
||||||
|
const useId: typeof import('vue').useId
|
||||||
|
const useLink: typeof import('vue-router').useLink
|
||||||
|
const useModel: typeof import('vue').useModel
|
||||||
|
const useRoute: typeof import('vue-router').useRoute
|
||||||
|
const useRouter: typeof import('vue-router').useRouter
|
||||||
|
const useSlots: typeof import('vue').useSlots
|
||||||
|
const useTemplateRef: typeof import('vue').useTemplateRef
|
||||||
|
const watch: typeof import('vue').watch
|
||||||
|
const watchEffect: typeof import('vue').watchEffect
|
||||||
|
const watchPostEffect: typeof import('vue').watchPostEffect
|
||||||
|
const watchSyncEffect: typeof import('vue').watchSyncEffect
|
||||||
|
}
|
||||||
|
// for type re-export
|
||||||
|
declare global {
|
||||||
|
// @ts-ignore
|
||||||
|
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||||
|
import('vue')
|
||||||
|
}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
// @ts-nocheck
|
||||||
|
// biome-ignore lint: disable
|
||||||
|
// oxlint-disable
|
||||||
|
// ------
|
||||||
|
// Generated by unplugin-vue-components
|
||||||
|
// Read more: https://github.com/vuejs/core/pull/3399
|
||||||
|
|
||||||
|
export {}
|
||||||
|
|
||||||
|
/* prettier-ignore */
|
||||||
|
declare module 'vue' {
|
||||||
|
export interface GlobalComponents {
|
||||||
|
ElAside: typeof import('element-plus/es')['ElAside']
|
||||||
|
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||||
|
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||||
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
|
ElCard: typeof import('element-plus/es')['ElCard']
|
||||||
|
ElCascader: typeof import('element-plus/es')['ElCascader']
|
||||||
|
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||||
|
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||||
|
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
|
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||||
|
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||||
|
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||||
|
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||||
|
ElForm: typeof import('element-plus/es')['ElForm']
|
||||||
|
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||||
|
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||||
|
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||||
|
ElImage: typeof import('element-plus/es')['ElImage']
|
||||||
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
|
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||||
|
ElMain: typeof import('element-plus/es')['ElMain']
|
||||||
|
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
|
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||||
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
|
ElTable: typeof import('element-plus/es')['ElTable']
|
||||||
|
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||||
|
ElTag: typeof import('element-plus/es')['ElTag']
|
||||||
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
}
|
||||||
|
export interface GlobalDirectives {
|
||||||
|
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
Goods,
|
||||||
|
Menu,
|
||||||
|
Location,
|
||||||
|
CollectionTag,
|
||||||
|
Sort,
|
||||||
|
Refresh,
|
||||||
|
Expand,
|
||||||
|
Fold,
|
||||||
|
ArrowDown,
|
||||||
|
User,
|
||||||
|
SwitchButton,
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
|
||||||
|
interface MenuItem {
|
||||||
|
index: string
|
||||||
|
title: string
|
||||||
|
icon: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuItems = ref<MenuItem[]>([
|
||||||
|
{ index: '/goods', title: 'Goods', icon: 'Goods' },
|
||||||
|
{ index: '/categories', title: 'Categories', icon: 'Menu' },
|
||||||
|
{ index: '/countries', title: 'Countries', icon: 'Location' },
|
||||||
|
{ index: '/tags', title: 'Tags', icon: 'CollectionTag' },
|
||||||
|
{ index: '/positions', title: 'Positions', icon: 'Sort' },
|
||||||
|
{ index: '/sync', title: 'Sync', icon: 'Refresh' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const iconMap: Record<string, unknown> = {
|
||||||
|
Goods,
|
||||||
|
Menu,
|
||||||
|
Location,
|
||||||
|
CollectionTag,
|
||||||
|
Sort,
|
||||||
|
Refresh,
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeMenu = computed(() => route.path)
|
||||||
|
const collapsed = computed(() => appStore.sidebarCollapsed)
|
||||||
|
const username = computed(() => authStore.user?.username || 'Admin')
|
||||||
|
|
||||||
|
const breadcrumb = computed(() => {
|
||||||
|
const meta = route.meta as { title?: string } | undefined
|
||||||
|
return meta?.title || 'Dashboard'
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
appStore.toggleSidebar()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('Are you sure to sign out?', 'Sign out', {
|
||||||
|
confirmButtonText: 'Sign out',
|
||||||
|
cancelButtonText: 'Cancel',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await authStore.logout()
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCommand(command: string) {
|
||||||
|
if (command === 'logout') {
|
||||||
|
handleLogout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-container class="layout-root">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<el-aside :width="collapsed ? '64px' : '240px'" class="layout-aside">
|
||||||
|
<div class="brand" :class="{ collapsed }">
|
||||||
|
<div class="brand-mark">IR</div>
|
||||||
|
<div v-if="!collapsed" class="brand-text">
|
||||||
|
<div class="brand-name">InkReach</div>
|
||||||
|
<div class="brand-tag">Product Center</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-menu
|
||||||
|
:default-active="activeMenu"
|
||||||
|
:collapse="collapsed"
|
||||||
|
:collapse-transition="false"
|
||||||
|
background-color="#1f1f24"
|
||||||
|
text-color="#d4d4d8"
|
||||||
|
active-text-color="#ff6800"
|
||||||
|
router
|
||||||
|
>
|
||||||
|
<el-menu-item v-for="item in menuItems" :key="item.index" :index="item.index">
|
||||||
|
<el-icon>
|
||||||
|
<component :is="iconMap[item.icon]" />
|
||||||
|
</el-icon>
|
||||||
|
<template #title>{{ item.title }}</template>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-menu>
|
||||||
|
</el-aside>
|
||||||
|
|
||||||
|
<el-container>
|
||||||
|
<!-- Header -->
|
||||||
|
<el-header class="layout-header" height="56px">
|
||||||
|
<div class="header-left">
|
||||||
|
<el-button
|
||||||
|
text
|
||||||
|
class="collapse-btn"
|
||||||
|
:title="collapsed ? 'Expand sidebar' : 'Collapse sidebar'"
|
||||||
|
@click="toggleSidebar"
|
||||||
|
>
|
||||||
|
<el-icon :size="20">
|
||||||
|
<component :is="collapsed ? Expand : Fold" />
|
||||||
|
</el-icon>
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<el-breadcrumb separator="/" class="breadcrumb">
|
||||||
|
<el-breadcrumb-item :to="{ path: '/' }">Home</el-breadcrumb-item>
|
||||||
|
<el-breadcrumb-item>{{ breadcrumb }}</el-breadcrumb-item>
|
||||||
|
</el-breadcrumb>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-right">
|
||||||
|
<el-dropdown trigger="click" @command="handleCommand">
|
||||||
|
<span class="user-trigger">
|
||||||
|
<el-icon><User /></el-icon>
|
||||||
|
<span class="user-name">{{ username }}</span>
|
||||||
|
<el-icon><ArrowDown /></el-icon>
|
||||||
|
</span>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item command="logout">
|
||||||
|
<el-icon><SwitchButton /></el-icon>
|
||||||
|
<span>Sign out</span>
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</div>
|
||||||
|
</el-header>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<el-main class="layout-main">
|
||||||
|
<router-view v-slot="{ Component }">
|
||||||
|
<transition name="fade-slide" mode="out-in">
|
||||||
|
<component :is="Component" />
|
||||||
|
</transition>
|
||||||
|
</router-view>
|
||||||
|
</el-main>
|
||||||
|
</el-container>
|
||||||
|
</el-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.layout-root {
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-aside {
|
||||||
|
background: #1f1f24;
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
height: 56px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-bottom: 1px solid #2b2b33;
|
||||||
|
background: #16161a;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand.collapsed {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--brand-color);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-tag {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-aside :deep(.el-menu) {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-aside :deep(.el-menu-item.is-active) {
|
||||||
|
background: rgba(255, 104, 0, 0.12) !important;
|
||||||
|
border-right: 3px solid var(--brand-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-aside :deep(.el-menu-item:hover) {
|
||||||
|
background: rgba(255, 255, 255, 0.04) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 16px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-btn {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-trigger:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-main {
|
||||||
|
background: #f5f7fa;
|
||||||
|
padding: 16px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* View transition */
|
||||||
|
.fade-slide-enter-active,
|
||||||
|
.fade-slide-leave-active {
|
||||||
|
transition:
|
||||||
|
opacity 0.18s ease,
|
||||||
|
transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-slide-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-slide-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import './style.css'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.use(ElementPlus)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'Login',
|
||||||
|
component: () => import('@/views/login/LoginView.vue'),
|
||||||
|
meta: { title: 'Login', public: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: () => import('@/layouts/DefaultLayout.vue'),
|
||||||
|
redirect: '/goods',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'goods',
|
||||||
|
name: 'Goods',
|
||||||
|
component: () => import('@/views/goods/GoodsView.vue'),
|
||||||
|
meta: { title: 'Goods', icon: 'Goods' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'categories',
|
||||||
|
name: 'Categories',
|
||||||
|
component: () => import('@/views/categories/CategoriesView.vue'),
|
||||||
|
meta: { title: 'Categories', icon: 'Menu' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'countries',
|
||||||
|
name: 'Countries',
|
||||||
|
component: () => import('@/views/countries/CountriesView.vue'),
|
||||||
|
meta: { title: 'Countries', icon: 'Location' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tags',
|
||||||
|
name: 'Tags',
|
||||||
|
component: () => import('@/views/tags/TagsView.vue'),
|
||||||
|
meta: { title: 'Tags', icon: 'CollectionTag' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'positions',
|
||||||
|
name: 'Positions',
|
||||||
|
component: () => import('@/views/positions/PositionsView.vue'),
|
||||||
|
meta: { title: 'Positions', icon: 'Sort' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sync',
|
||||||
|
name: 'Sync',
|
||||||
|
component: () => import('@/views/sync/SyncView.vue'),
|
||||||
|
meta: { title: 'Sync', icon: 'Refresh' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
redirect: '/goods',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const isPublic = to.meta?.public === true
|
||||||
|
|
||||||
|
if (!authStore.isLoggedIn && !isPublic) {
|
||||||
|
return { path: '/login', replace: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authStore.isLoggedIn && to.path === '/login') {
|
||||||
|
return { path: '/', replace: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
router.afterEach((to) => {
|
||||||
|
const title = (to.meta?.title as string) || 'InkReach Admin'
|
||||||
|
document.title = `${title} | InkReach Admin`
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const SIDEBAR_KEY = 'app:sidebarCollapsed'
|
||||||
|
|
||||||
|
export const useAppStore = defineStore('app', () => {
|
||||||
|
const sidebarCollapsed = ref<boolean>(
|
||||||
|
localStorage.getItem(SIDEBAR_KEY) === 'true'
|
||||||
|
)
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||||
|
localStorage.setItem(SIDEBAR_KEY, String(sidebarCollapsed.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSidebarCollapsed(value: boolean) {
|
||||||
|
sidebarCollapsed.value = value
|
||||||
|
localStorage.setItem(SIDEBAR_KEY, String(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sidebarCollapsed,
|
||||||
|
toggleSidebar,
|
||||||
|
setSidebarCollapsed,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import type { LoginRequest, User } from '@/types'
|
||||||
|
import { authApi } from '@/api/auth'
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
// Token persisted to localStorage
|
||||||
|
const token = ref<string>(localStorage.getItem('token') || '')
|
||||||
|
|
||||||
|
// User persisted to localStorage (parsed if available)
|
||||||
|
const user = ref<User | null>(loadUser())
|
||||||
|
|
||||||
|
const isLoggedIn = computed(() => !!token.value)
|
||||||
|
|
||||||
|
function loadUser(): User | null {
|
||||||
|
const raw = localStorage.getItem('user')
|
||||||
|
if (!raw) return null
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as User
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setToken(newToken: string) {
|
||||||
|
token.value = newToken
|
||||||
|
if (newToken) {
|
||||||
|
localStorage.setItem('token', newToken)
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUser(newUser: User | null) {
|
||||||
|
user.value = newUser
|
||||||
|
if (newUser) {
|
||||||
|
localStorage.setItem('user', JSON.stringify(newUser))
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(payload: LoginRequest) {
|
||||||
|
const res = await authApi.login(payload) as any;
|
||||||
|
const accessToken = res.data?.accessToken ?? res.accessToken ?? res.token;
|
||||||
|
const userData = res.data?.user ?? res.user;
|
||||||
|
setToken(accessToken);
|
||||||
|
setUser(userData);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCurrentUser() {
|
||||||
|
const current = await authApi.getCurrentUser()
|
||||||
|
setUser(current)
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
try {
|
||||||
|
await authApi.logout()
|
||||||
|
} catch {
|
||||||
|
// Ignore network errors during logout
|
||||||
|
}
|
||||||
|
setToken('')
|
||||||
|
setUser(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
user,
|
||||||
|
isLoggedIn,
|
||||||
|
login,
|
||||||
|
fetchCurrentUser,
|
||||||
|
logout,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
:root {
|
||||||
|
--brand-color: #ff6800;
|
||||||
|
--brand-color-hover: #ff7d1f;
|
||||||
|
--brand-color-active: #e65a00;
|
||||||
|
|
||||||
|
--sidebar-width: 240px;
|
||||||
|
--sidebar-width-collapsed: 64px;
|
||||||
|
--header-height: 56px;
|
||||||
|
|
||||||
|
font-family:
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
'Helvetica Neue',
|
||||||
|
Arial,
|
||||||
|
'PingFang SC',
|
||||||
|
'Hiragino Sans GB',
|
||||||
|
'Microsoft YaHei',
|
||||||
|
sans-serif;
|
||||||
|
color: #1f2937;
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Element Plus brand color overrides */
|
||||||
|
:root {
|
||||||
|
--el-color-primary: var(--brand-color);
|
||||||
|
--el-color-primary-light-3: #ff8f3d;
|
||||||
|
--el-color-primary-light-5: #ffb073;
|
||||||
|
--el-color-primary-light-7: #ffd1a8;
|
||||||
|
--el-color-primary-light-8: #ffe0c2;
|
||||||
|
--el-color-primary-light-9: #fff0e0;
|
||||||
|
--el-color-primary-dark-2: #e65a00;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Brand-colored buttons */
|
||||||
|
.el-button--primary {
|
||||||
|
--el-button-bg-color: var(--brand-color);
|
||||||
|
--el-button-border-color: var(--brand-color);
|
||||||
|
--el-button-hover-bg-color: var(--brand-color-hover);
|
||||||
|
--el-button-hover-border-color: var(--brand-color-hover);
|
||||||
|
--el-button-active-bg-color: var(--brand-color-active);
|
||||||
|
--el-button-active-border-color: var(--brand-color-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page wrapper helper */
|
||||||
|
.page-container {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-card {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact filter bar */
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar .el-input,
|
||||||
|
.filter-bar .el-select,
|
||||||
|
.filter-bar .el-cascader {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action cell padding */
|
||||||
|
.table-actions .el-button + .el-button {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Brand-colored dialog titles */
|
||||||
|
.brand-title {
|
||||||
|
color: var(--brand-color);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
// Common types
|
||||||
|
export interface PaginatedResult<T> {
|
||||||
|
data: T[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth types
|
||||||
|
export interface LoginRequest {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
token: string
|
||||||
|
user: User
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
email?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Good types
|
||||||
|
export interface Good {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
originGoodId: string
|
||||||
|
originGood?: OriginGood
|
||||||
|
countryId: string
|
||||||
|
country?: Country
|
||||||
|
categoryId: string
|
||||||
|
category?: Category
|
||||||
|
tagId: string
|
||||||
|
tag?: Tag
|
||||||
|
positionId?: string
|
||||||
|
position?: Position
|
||||||
|
priority: number
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateGoodRequest {
|
||||||
|
name: string
|
||||||
|
originGoodId: string
|
||||||
|
countryId: string
|
||||||
|
categoryId: string
|
||||||
|
tagId: string
|
||||||
|
positionId?: string
|
||||||
|
priority: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateGoodRequest {
|
||||||
|
name?: string
|
||||||
|
originGoodId?: string
|
||||||
|
countryId?: string
|
||||||
|
categoryId?: string
|
||||||
|
tagId?: string
|
||||||
|
positionId?: string
|
||||||
|
priority?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchCreateGoodsRequest {
|
||||||
|
originGoodIds: string[]
|
||||||
|
countryId: string
|
||||||
|
categoryId: string
|
||||||
|
tagId: string
|
||||||
|
positionId?: string
|
||||||
|
priority: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePriorityRequest {
|
||||||
|
goodIds: string[]
|
||||||
|
priority: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Country types
|
||||||
|
export interface Country {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
icon?: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCountryRequest {
|
||||||
|
name: string
|
||||||
|
icon?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCountryRequest {
|
||||||
|
name?: string
|
||||||
|
icon?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category types
|
||||||
|
export interface Category {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
icon?: string
|
||||||
|
parentId?: string
|
||||||
|
parent?: Category
|
||||||
|
children?: Category[]
|
||||||
|
_count?: {
|
||||||
|
children: number
|
||||||
|
}
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCategoryRequest {
|
||||||
|
name: string
|
||||||
|
icon?: string
|
||||||
|
parentId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCategoryRequest {
|
||||||
|
name?: string
|
||||||
|
icon?: string
|
||||||
|
parentId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryTree extends Category {
|
||||||
|
children?: CategoryTree[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag types
|
||||||
|
export interface Tag {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
color?: string
|
||||||
|
timing?: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTagRequest {
|
||||||
|
name: string
|
||||||
|
color?: string
|
||||||
|
timing?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTagRequest {
|
||||||
|
name?: string
|
||||||
|
color?: string
|
||||||
|
timing?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position types
|
||||||
|
export interface Position {
|
||||||
|
id: string
|
||||||
|
indexVal: number
|
||||||
|
countryId?: string
|
||||||
|
country?: Country
|
||||||
|
categoryId?: string
|
||||||
|
category?: Category
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePositionRequest {
|
||||||
|
indexVal: number
|
||||||
|
countryId?: string
|
||||||
|
categoryId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePositionRequest {
|
||||||
|
indexVal?: number
|
||||||
|
countryId?: string
|
||||||
|
categoryId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Origin Good types
|
||||||
|
export interface OriginGood {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
productId: string
|
||||||
|
categoryId?: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync types
|
||||||
|
export interface SyncLog {
|
||||||
|
id: string
|
||||||
|
type: 'CATEGORY' | 'PRODUCT'
|
||||||
|
status: 'SUCCESS' | 'FAILED'
|
||||||
|
message?: string
|
||||||
|
startTime: string
|
||||||
|
endTime?: string
|
||||||
|
errorCount?: number
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncStats {
|
||||||
|
lastSyncTime?: string
|
||||||
|
status?: 'IDLE' | 'SYNCING'
|
||||||
|
errorCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter types
|
||||||
|
export interface GoodsFilter {
|
||||||
|
countryId?: string
|
||||||
|
categoryId?: string
|
||||||
|
tagId?: string
|
||||||
|
keyword?: string
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryFilter {
|
||||||
|
parentId?: string
|
||||||
|
name?: string
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CountryFilter {
|
||||||
|
name?: string
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TagFilter {
|
||||||
|
name?: string
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PositionFilter {
|
||||||
|
countryId?: string
|
||||||
|
categoryId?: string
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Plus, Edit, Delete, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import type {
|
||||||
|
Category,
|
||||||
|
CategoryTree,
|
||||||
|
CreateCategoryRequest,
|
||||||
|
UpdateCategoryRequest,
|
||||||
|
} from '@/types'
|
||||||
|
import { categoriesApi } from '@/api/categories'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const tree = ref<CategoryTree[]>([])
|
||||||
|
|
||||||
|
async function fetchTree() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
tree.value = await categoriesApi.getCategoryTree()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Cascader ----------
|
||||||
|
interface CascaderNode {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
children?: CascaderNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const cascaderOptions = ref<CascaderNode[]>([])
|
||||||
|
|
||||||
|
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
|
||||||
|
return nodes.map((n) => ({
|
||||||
|
value: n.id,
|
||||||
|
label: n.name,
|
||||||
|
children: n.children?.length ? buildCascader(n.children) : undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildCascader() {
|
||||||
|
cascaderOptions.value = buildCascader(tree.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Dialog ----------
|
||||||
|
const dialogRef = ref()
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const dialogMode = ref<'create' | 'edit'>('create')
|
||||||
|
const dialogLoading = ref(false)
|
||||||
|
|
||||||
|
const dialogForm = reactive<CreateCategoryRequest & { id?: string }>({
|
||||||
|
name: '',
|
||||||
|
icon: '',
|
||||||
|
parentId: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const dialogRules = {
|
||||||
|
name: [{ required: true, message: 'Name is required', trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAddDialog() {
|
||||||
|
dialogMode.value = 'create'
|
||||||
|
Object.assign(dialogForm, { id: undefined, name: '', icon: '', parentId: '' })
|
||||||
|
rebuildCascader()
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEditDialog(c: Category) {
|
||||||
|
dialogMode.value = 'edit'
|
||||||
|
Object.assign(dialogForm, {
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
icon: c.icon || '',
|
||||||
|
parentId: c.parentId || '',
|
||||||
|
})
|
||||||
|
rebuildCascader()
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!dialogRef.value) return
|
||||||
|
await dialogRef.value.validate(async (valid: boolean) => {
|
||||||
|
if (!valid) return
|
||||||
|
dialogLoading.value = true
|
||||||
|
try {
|
||||||
|
const payload: CreateCategoryRequest = {
|
||||||
|
name: dialogForm.name,
|
||||||
|
icon: dialogForm.icon || undefined,
|
||||||
|
parentId: dialogForm.parentId || undefined,
|
||||||
|
}
|
||||||
|
if (dialogMode.value === 'create') {
|
||||||
|
await categoriesApi.createCategory(payload)
|
||||||
|
ElMessage.success('Category created')
|
||||||
|
} else {
|
||||||
|
await categoriesApi.updateCategory(dialogForm.id!, payload as UpdateCategoryRequest)
|
||||||
|
ElMessage.success('Category updated')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
fetchTree()
|
||||||
|
} finally {
|
||||||
|
dialogLoading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(c: Category) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`Delete "${c.name}"?`, 'Confirm', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: 'Delete',
|
||||||
|
cancelButtonText: 'Cancel',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await categoriesApi.deleteCategory(c.id)
|
||||||
|
ElMessage.success('Deleted')
|
||||||
|
fetchTree()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchTree)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-container">
|
||||||
|
<div class="page-card">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button type="primary" @click="openAddDialog">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
<span>Add Category</span>
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="fetchTree">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
<span>Refresh</span>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="tree"
|
||||||
|
border
|
||||||
|
row-key="id"
|
||||||
|
:tree-props="{ children: 'children' }"
|
||||||
|
default-expand-all
|
||||||
|
>
|
||||||
|
<el-table-column prop="name" label="Name" min-width="220" />
|
||||||
|
<el-table-column label="Icon" width="100">
|
||||||
|
<template #default="{ row }: { row: CategoryTree }">
|
||||||
|
<el-image
|
||||||
|
v-if="row.icon"
|
||||||
|
:src="row.icon"
|
||||||
|
:preview-src-list="[row.icon]"
|
||||||
|
fit="cover"
|
||||||
|
style="width: 32px; height: 32px; border-radius: 4px;"
|
||||||
|
/>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="Parent">
|
||||||
|
<template #default="{ row }: { row: CategoryTree }">
|
||||||
|
{{ row.parent?.name || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="Children" width="100">
|
||||||
|
<template #default="{ row }: { row: CategoryTree }">
|
||||||
|
{{ row._count?.children ?? row.children?.length ?? 0 }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="Actions" width="180" fixed="right">
|
||||||
|
<template #default="{ row }: { row: CategoryTree }">
|
||||||
|
<div class="table-actions">
|
||||||
|
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||||
|
<el-icon><Edit /></el-icon>
|
||||||
|
<span>Edit</span>
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
<span>Delete</span>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<el-empty description="No categories" />
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="dialogMode === 'create' ? 'Add Category' : 'Edit Category'"
|
||||||
|
width="520px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
ref="dialogRef"
|
||||||
|
:model="dialogForm"
|
||||||
|
:rules="dialogRules"
|
||||||
|
label-width="100px"
|
||||||
|
>
|
||||||
|
<el-form-item label="Name" prop="name">
|
||||||
|
<el-input v-model="dialogForm.name" placeholder="Category name" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="Parent">
|
||||||
|
<el-cascader
|
||||||
|
v-model="dialogForm.parentId"
|
||||||
|
:options="cascaderOptions"
|
||||||
|
:props="{
|
||||||
|
checkStrictly: true,
|
||||||
|
value: 'value',
|
||||||
|
label: 'label',
|
||||||
|
children: 'children',
|
||||||
|
emitPath: false,
|
||||||
|
}"
|
||||||
|
placeholder="Top-level (optional)"
|
||||||
|
clearable
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="Icon URL">
|
||||||
|
<el-input v-model="dialogForm.icon" placeholder="https://..." />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">Cancel</el-button>
|
||||||
|
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||||
|
{{ dialogMode === 'create' ? 'Create' : 'Save' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
margin-top: 16px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
|
||||||
|
import type {
|
||||||
|
Country,
|
||||||
|
CreateCountryRequest,
|
||||||
|
UpdateCountryRequest,
|
||||||
|
CountryFilter,
|
||||||
|
} from '@/types'
|
||||||
|
import { countriesApi } from '@/api/countries'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const list = ref<Country[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
const filter = reactive<Required<CountryFilter>>({
|
||||||
|
name: '',
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchList() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await countriesApi.getCountriesList(filter)
|
||||||
|
list.value = res.data
|
||||||
|
total.value = res.total
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
filter.page = 1
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
filter.name = ''
|
||||||
|
filter.page = 1
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialogRef = ref()
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const dialogMode = ref<'create' | 'edit'>('create')
|
||||||
|
const dialogLoading = ref(false)
|
||||||
|
|
||||||
|
const dialogForm = reactive<CreateCountryRequest & { id?: string }>({
|
||||||
|
name: '',
|
||||||
|
icon: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const dialogRules = {
|
||||||
|
name: [{ required: true, message: 'Name is required', trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddDialog() {
|
||||||
|
dialogMode.value = 'create'
|
||||||
|
Object.assign(dialogForm, { id: undefined, name: '', icon: '' })
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(c: Country) {
|
||||||
|
dialogMode.value = 'edit'
|
||||||
|
Object.assign(dialogForm, { id: c.id, name: c.name, icon: c.icon || '' })
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!dialogRef.value) return
|
||||||
|
await dialogRef.value.validate(async (valid: boolean) => {
|
||||||
|
if (!valid) return
|
||||||
|
dialogLoading.value = true
|
||||||
|
try {
|
||||||
|
const payload: CreateCountryRequest = {
|
||||||
|
name: dialogForm.name,
|
||||||
|
icon: dialogForm.icon || undefined,
|
||||||
|
}
|
||||||
|
if (dialogMode.value === 'create') {
|
||||||
|
await countriesApi.createCountry(payload)
|
||||||
|
ElMessage.success('Country created')
|
||||||
|
} else {
|
||||||
|
await countriesApi.updateCountry(dialogForm.id!, payload as UpdateCountryRequest)
|
||||||
|
ElMessage.success('Country updated')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
fetchList()
|
||||||
|
} finally {
|
||||||
|
dialogLoading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(c: Country) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`Delete "${c.name}"?`, 'Confirm', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: 'Delete',
|
||||||
|
cancelButtonText: 'Cancel',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await countriesApi.deleteCountry(c.id)
|
||||||
|
ElMessage.success('Deleted')
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchList)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-container">
|
||||||
|
<div class="page-card">
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-input
|
||||||
|
v-model="filter.name"
|
||||||
|
placeholder="Search by name"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
@clear="handleSearch"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-button type="primary" @click="handleSearch">
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
<span>Search</span>
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="handleReset">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
<span>Reset</span>
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<div class="filter-spacer" />
|
||||||
|
|
||||||
|
<el-button type="primary" @click="openAddDialog">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
<span>Add Country</span>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" :data="list" border stripe>
|
||||||
|
<el-table-column label="Icon" width="80">
|
||||||
|
<template #default="{ row }: { row: Country }">
|
||||||
|
<el-image
|
||||||
|
v-if="row.icon"
|
||||||
|
:src="row.icon"
|
||||||
|
:preview-src-list="[row.icon]"
|
||||||
|
fit="cover"
|
||||||
|
style="width: 32px; height: 32px; border-radius: 4px;"
|
||||||
|
/>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="name" label="Name" min-width="200" />
|
||||||
|
<el-table-column label="Created" width="180">
|
||||||
|
<template #default="{ row }: { row: Country }">
|
||||||
|
{{ new Date(row.createdAt).toLocaleString() }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="Actions" width="180" fixed="right">
|
||||||
|
<template #default="{ row }: { row: Country }">
|
||||||
|
<div class="table-actions">
|
||||||
|
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||||
|
<el-icon><Edit /></el-icon>
|
||||||
|
<span>Edit</span>
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
<span>Delete</span>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<el-empty description="No countries" />
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
class="pagination"
|
||||||
|
v-model:current-page="filter.page"
|
||||||
|
v-model:page-size="filter.pageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@current-change="(p: number) => { filter.page = p; fetchList() }"
|
||||||
|
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="dialogMode === 'create' ? 'Add Country' : 'Edit Country'"
|
||||||
|
width="480px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
||||||
|
<el-form-item label="Name" prop="name">
|
||||||
|
<el-input v-model="dialogForm.name" placeholder="Country name" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="Icon URL">
|
||||||
|
<el-input v-model="dialogForm.icon" placeholder="https://..." />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">Cancel</el-button>
|
||||||
|
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||||
|
{{ dialogMode === 'create' ? 'Create' : 'Save' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
margin-top: 16px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,667 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Search, Refresh, Plus, Delete, Edit, Upload } from '@element-plus/icons-vue'
|
||||||
|
import type {
|
||||||
|
Good,
|
||||||
|
CreateGoodRequest,
|
||||||
|
UpdateGoodRequest,
|
||||||
|
BatchCreateGoodsRequest,
|
||||||
|
GoodsFilter,
|
||||||
|
Country,
|
||||||
|
Category,
|
||||||
|
CategoryTree,
|
||||||
|
Tag,
|
||||||
|
Position,
|
||||||
|
OriginGood,
|
||||||
|
} from '@/types'
|
||||||
|
import { goodsApi } from '@/api/goods'
|
||||||
|
import { countriesApi } from '@/api/countries'
|
||||||
|
import { categoriesApi } from '@/api/categories'
|
||||||
|
import { tagsApi } from '@/api/tags'
|
||||||
|
import { positionsApi } from '@/api/positions'
|
||||||
|
import { originGoodsApi } from '@/api/origin-goods'
|
||||||
|
|
||||||
|
// ---------- List state ----------
|
||||||
|
const loading = ref(false)
|
||||||
|
const goods = ref<Good[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
const filter = reactive<Required<GoodsFilter>>({
|
||||||
|
countryId: '',
|
||||||
|
categoryId: '',
|
||||||
|
tagId: '',
|
||||||
|
keyword: '',
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------- Lookups ----------
|
||||||
|
const countries = ref<Country[]>([])
|
||||||
|
const categoriesTree = ref<CategoryTree[]>([])
|
||||||
|
const tags = ref<Tag[]>([])
|
||||||
|
const positions = ref<Position[]>([])
|
||||||
|
|
||||||
|
async function loadLookups() {
|
||||||
|
const [c, ct, t] = await Promise.all([
|
||||||
|
countriesApi.getCountriesList({ page: 1, pageSize: 500 }),
|
||||||
|
categoriesApi.getCategoryTree(),
|
||||||
|
tagsApi.getTagsList({ page: 1, pageSize: 500 }),
|
||||||
|
])
|
||||||
|
countries.value = c.data
|
||||||
|
categoriesTree.value = ct
|
||||||
|
tags.value = t.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPositions(good?: Good) {
|
||||||
|
// Positions filter requires country & category
|
||||||
|
const params = {
|
||||||
|
countryId: good?.countryId || filter.countryId || undefined,
|
||||||
|
categoryId: good?.categoryId || filter.categoryId || undefined,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 500,
|
||||||
|
}
|
||||||
|
const res = await positionsApi.getPositionsList(params)
|
||||||
|
positions.value = res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Cascader adapter ----------
|
||||||
|
interface CascaderNode {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
children?: CascaderNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCategoryCascader(tree: CategoryTree[]): CascaderNode[] {
|
||||||
|
return tree.map((node) => ({
|
||||||
|
value: node.id,
|
||||||
|
label: node.name,
|
||||||
|
children: node.children?.length ? buildCategoryCascader(node.children) : undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryCascaderOptions = ref<CascaderNode[]>([])
|
||||||
|
|
||||||
|
watch(categoriesTree, (val) => {
|
||||||
|
categoryCascaderOptions.value = buildCategoryCascader(val)
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// ---------- Origin good search (for add/edit/batch dialogs) ----------
|
||||||
|
const originGoodsList = ref<OriginGood[]>([])
|
||||||
|
const originGoodKeyword = ref('')
|
||||||
|
const originGoodSearching = ref(false)
|
||||||
|
|
||||||
|
async function searchOriginGoods(keyword: string) {
|
||||||
|
originGoodKeyword.value = keyword
|
||||||
|
originGoodSearching.value = true
|
||||||
|
try {
|
||||||
|
const res = await originGoodsApi.searchOriginGoods(keyword)
|
||||||
|
originGoodsList.value = res
|
||||||
|
} finally {
|
||||||
|
originGoodSearching.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- CRUD ----------
|
||||||
|
async function fetchGoods() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await goodsApi.getGoodsList(filter)
|
||||||
|
goods.value = res.data
|
||||||
|
total.value = res.total
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load goods', err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
filter.page = 1
|
||||||
|
fetchGoods()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
filter.countryId = ''
|
||||||
|
filter.categoryId = ''
|
||||||
|
filter.tagId = ''
|
||||||
|
filter.keyword = ''
|
||||||
|
filter.page = 1
|
||||||
|
fetchGoods()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageChange(page: number) {
|
||||||
|
filter.page = page
|
||||||
|
fetchGoods()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSizeChange(size: number) {
|
||||||
|
filter.pageSize = size
|
||||||
|
filter.page = 1
|
||||||
|
fetchGoods()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCategoryName(g: Good): string {
|
||||||
|
if (!g.category) return g.categoryId
|
||||||
|
return g.category.parent ? `${g.category.parent.name} / ${g.category.name}` : g.category.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Add / Edit dialog ----------
|
||||||
|
const dialogRef = ref()
|
||||||
|
const dialogMode = ref<'create' | 'edit'>('create')
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const dialogLoading = ref(false)
|
||||||
|
|
||||||
|
const dialogForm = reactive<CreateGoodRequest & { id?: string }>({
|
||||||
|
name: '',
|
||||||
|
originGoodId: '',
|
||||||
|
countryId: '',
|
||||||
|
categoryId: '',
|
||||||
|
tagId: '',
|
||||||
|
positionId: '',
|
||||||
|
priority: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const dialogRules = {
|
||||||
|
name: [{ required: true, message: 'Name is required', trigger: 'blur' }],
|
||||||
|
originGoodId: [{ required: true, message: 'Origin good is required', trigger: 'change' }],
|
||||||
|
countryId: [{ required: true, message: 'Country is required', trigger: 'change' }],
|
||||||
|
categoryId: [{ required: true, message: 'Category is required', trigger: 'change' }],
|
||||||
|
tagId: [{ required: true, message: 'Tag is required', trigger: 'change' }],
|
||||||
|
priority: [{ required: true, message: 'Priority is required', trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAddDialog() {
|
||||||
|
dialogMode.value = 'create'
|
||||||
|
Object.assign(dialogForm, {
|
||||||
|
id: undefined,
|
||||||
|
name: '',
|
||||||
|
originGoodId: '',
|
||||||
|
countryId: '',
|
||||||
|
categoryId: '',
|
||||||
|
tagId: '',
|
||||||
|
positionId: '',
|
||||||
|
priority: 0,
|
||||||
|
})
|
||||||
|
await loadPositions()
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEditDialog(g: Good) {
|
||||||
|
dialogMode.value = 'edit'
|
||||||
|
Object.assign(dialogForm, {
|
||||||
|
id: g.id,
|
||||||
|
name: g.name,
|
||||||
|
originGoodId: g.originGoodId,
|
||||||
|
countryId: g.countryId,
|
||||||
|
categoryId: g.categoryId,
|
||||||
|
tagId: g.tagId,
|
||||||
|
positionId: g.positionId || '',
|
||||||
|
priority: g.priority,
|
||||||
|
})
|
||||||
|
await loadPositions(g)
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDialogSubmit() {
|
||||||
|
if (!dialogRef.value) return
|
||||||
|
await dialogRef.value.validate(async (valid: boolean) => {
|
||||||
|
if (!valid) return
|
||||||
|
dialogLoading.value = true
|
||||||
|
try {
|
||||||
|
const payload: CreateGoodRequest = {
|
||||||
|
name: dialogForm.name,
|
||||||
|
originGoodId: dialogForm.originGoodId,
|
||||||
|
countryId: dialogForm.countryId,
|
||||||
|
categoryId: dialogForm.categoryId,
|
||||||
|
tagId: dialogForm.tagId,
|
||||||
|
positionId: dialogForm.positionId || undefined,
|
||||||
|
priority: Number(dialogForm.priority),
|
||||||
|
}
|
||||||
|
if (dialogMode.value === 'create') {
|
||||||
|
await goodsApi.createGood(payload)
|
||||||
|
ElMessage.success('Good created')
|
||||||
|
} else {
|
||||||
|
await goodsApi.updateGood(dialogForm.id!, payload as UpdateGoodRequest)
|
||||||
|
ElMessage.success('Good updated')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
fetchGoods()
|
||||||
|
} finally {
|
||||||
|
dialogLoading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(g: Good) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`Delete "${g.name}"?`, 'Confirm', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: 'Delete',
|
||||||
|
cancelButtonText: 'Cancel',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await goodsApi.deleteGood(g.id)
|
||||||
|
ElMessage.success('Deleted')
|
||||||
|
fetchGoods()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Batch add dialog ----------
|
||||||
|
const batchDialogRef = ref()
|
||||||
|
const batchDialogVisible = ref(false)
|
||||||
|
const batchDialogLoading = ref(false)
|
||||||
|
const batchOriginKeyword = ref('')
|
||||||
|
const batchOriginSearching = ref(false)
|
||||||
|
const batchOriginList = ref<OriginGood[]>([])
|
||||||
|
|
||||||
|
const batchForm = reactive<BatchCreateGoodsRequest>({
|
||||||
|
originGoodIds: [],
|
||||||
|
countryId: '',
|
||||||
|
categoryId: '',
|
||||||
|
tagId: '',
|
||||||
|
positionId: '',
|
||||||
|
priority: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const batchRules = {
|
||||||
|
originGoodIds: [{ type: 'array', required: true, message: 'Pick at least one origin good', trigger: 'change' }],
|
||||||
|
countryId: [{ required: true, message: 'Country is required', trigger: 'change' }],
|
||||||
|
categoryId: [{ required: true, message: 'Category is required', trigger: 'change' }],
|
||||||
|
tagId: [{ required: true, message: 'Tag is required', trigger: 'change' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openBatchDialog() {
|
||||||
|
Object.assign(batchForm, {
|
||||||
|
originGoodIds: [],
|
||||||
|
countryId: '',
|
||||||
|
categoryId: '',
|
||||||
|
tagId: '',
|
||||||
|
positionId: '',
|
||||||
|
priority: 0,
|
||||||
|
})
|
||||||
|
batchOriginKeyword.value = ''
|
||||||
|
batchOriginList.value = []
|
||||||
|
await loadPositions()
|
||||||
|
batchDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function batchSearchOrigin() {
|
||||||
|
if (!batchOriginKeyword.value) return
|
||||||
|
batchOriginSearching.value = true
|
||||||
|
try {
|
||||||
|
const res = await originGoodsApi.searchOriginGoods(batchOriginKeyword.value)
|
||||||
|
batchOriginList.value = res
|
||||||
|
} finally {
|
||||||
|
batchOriginSearching.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBatchSubmit() {
|
||||||
|
if (!batchDialogRef.value) return
|
||||||
|
await batchDialogRef.value.validate(async (valid: boolean) => {
|
||||||
|
if (!valid) return
|
||||||
|
batchDialogLoading.value = true
|
||||||
|
try {
|
||||||
|
const payload: BatchCreateGoodsRequest = {
|
||||||
|
originGoodIds: batchForm.originGoodIds,
|
||||||
|
countryId: batchForm.countryId,
|
||||||
|
categoryId: batchForm.categoryId,
|
||||||
|
tagId: batchForm.tagId,
|
||||||
|
positionId: batchForm.positionId || undefined,
|
||||||
|
priority: Number(batchForm.priority),
|
||||||
|
}
|
||||||
|
const created = await goodsApi.batchCreateGoods(payload)
|
||||||
|
ElMessage.success(`Created ${Array.isArray(created) ? created.length : payload.originGoodIds.length} goods`)
|
||||||
|
batchDialogVisible.value = false
|
||||||
|
fetchGoods()
|
||||||
|
} finally {
|
||||||
|
batchDialogLoading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Lifecycle ----------
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadLookups()
|
||||||
|
await fetchGoods()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-container">
|
||||||
|
<div class="page-card">
|
||||||
|
<!-- Filter bar -->
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-select
|
||||||
|
v-model="filter.countryId"
|
||||||
|
placeholder="Country"
|
||||||
|
clearable
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="c in countries"
|
||||||
|
:key="c.id"
|
||||||
|
:label="c.name"
|
||||||
|
:value="c.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<el-cascader
|
||||||
|
v-model="filter.categoryId"
|
||||||
|
:options="categoryCascaderOptions"
|
||||||
|
:props="{ checkStrictly: true, value: 'value', label: 'label', children: 'children' }"
|
||||||
|
placeholder="Category"
|
||||||
|
clearable
|
||||||
|
@change="handleSearch"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-select
|
||||||
|
v-model="filter.tagId"
|
||||||
|
placeholder="Tag"
|
||||||
|
clearable
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="t in tags"
|
||||||
|
:key="t.id"
|
||||||
|
:label="t.name"
|
||||||
|
:value="t.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<el-input
|
||||||
|
v-model="filter.keyword"
|
||||||
|
placeholder="Search by name"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
@clear="handleSearch"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
|
||||||
|
<el-button type="primary" @click="handleSearch">
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
<span>Search</span>
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="handleReset">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
<span>Reset</span>
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<div class="filter-spacer" />
|
||||||
|
|
||||||
|
<el-button type="primary" plain @click="openAddDialog">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
<span>Add Good</span>
|
||||||
|
</el-button>
|
||||||
|
<el-button type="success" plain @click="openBatchDialog">
|
||||||
|
<el-icon><Upload /></el-icon>
|
||||||
|
<span>Batch Add</span>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<el-table v-loading="loading" :data="goods" border stripe>
|
||||||
|
<el-table-column prop="name" label="Name" min-width="180" />
|
||||||
|
<el-table-column label="Country" min-width="120">
|
||||||
|
<template #default="{ row }: { row: Good }">
|
||||||
|
{{ row.country?.name || row.countryId }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="Category" min-width="160">
|
||||||
|
<template #default="{ row }: { row: Good }">
|
||||||
|
{{ getCategoryName(row) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="Tag" min-width="100">
|
||||||
|
<template #default="{ row }: { row: Good }">
|
||||||
|
<el-tag v-if="row.tag" :color="row.tag.color" effect="dark">
|
||||||
|
{{ row.tag.name }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="Priority" prop="priority" width="100" sortable />
|
||||||
|
<el-table-column label="Actions" width="180" fixed="right">
|
||||||
|
<template #default="{ row }: { row: Good }">
|
||||||
|
<div class="table-actions">
|
||||||
|
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||||
|
<el-icon><Edit /></el-icon>
|
||||||
|
<span>Edit</span>
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
<span>Delete</span>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<el-empty description="No goods found" />
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
class="pagination"
|
||||||
|
v-model:current-page="filter.page"
|
||||||
|
v-model:page-size="filter.pageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add / Edit dialog -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="dialogMode === 'create' ? 'Add Good' : 'Edit Good'"
|
||||||
|
width="640px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
ref="dialogRef"
|
||||||
|
:model="dialogForm"
|
||||||
|
:rules="dialogRules"
|
||||||
|
label-width="120px"
|
||||||
|
label-position="right"
|
||||||
|
>
|
||||||
|
<el-form-item label="Name" prop="name">
|
||||||
|
<el-input v-model="dialogForm.name" placeholder="Good name" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="Origin Good" prop="originGoodId">
|
||||||
|
<el-select
|
||||||
|
v-model="dialogForm.originGoodId"
|
||||||
|
filterable
|
||||||
|
remote
|
||||||
|
:remote-method="searchOriginGoods"
|
||||||
|
:loading="originGoodSearching"
|
||||||
|
placeholder="Search origin good by name"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="og in originGoodsList"
|
||||||
|
:key="og.id"
|
||||||
|
:label="`${og.name} (${og.productId})`"
|
||||||
|
:value="og.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="Country" prop="countryId">
|
||||||
|
<el-select v-model="dialogForm.countryId" placeholder="Country" style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="c in countries"
|
||||||
|
:key="c.id"
|
||||||
|
:label="c.name"
|
||||||
|
:value="c.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="Category" prop="categoryId">
|
||||||
|
<el-cascader
|
||||||
|
v-model="dialogForm.categoryId"
|
||||||
|
:options="categoryCascaderOptions"
|
||||||
|
:props="{ checkStrictly: true, value: 'value', label: 'label', children: 'children' }"
|
||||||
|
placeholder="Category"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="Tag" prop="tagId">
|
||||||
|
<el-select v-model="dialogForm.tagId" placeholder="Tag" style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="t in tags"
|
||||||
|
:key="t.id"
|
||||||
|
:label="t.name"
|
||||||
|
:value="t.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="Position">
|
||||||
|
<el-select v-model="dialogForm.positionId" placeholder="Optional" clearable style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="p in positions"
|
||||||
|
:key="p.id"
|
||||||
|
:label="`#${p.indexVal}`"
|
||||||
|
:value="p.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="Priority" prop="priority">
|
||||||
|
<el-input-number v-model="dialogForm.priority" :min="0" :max="9999" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">Cancel</el-button>
|
||||||
|
<el-button type="primary" :loading="dialogLoading" @click="handleDialogSubmit">
|
||||||
|
{{ dialogMode === 'create' ? 'Create' : 'Save' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- Batch add dialog -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="batchDialogVisible"
|
||||||
|
title="Batch Add Goods"
|
||||||
|
width="720px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
ref="batchDialogRef"
|
||||||
|
:model="batchForm"
|
||||||
|
:rules="batchRules"
|
||||||
|
label-width="120px"
|
||||||
|
label-position="right"
|
||||||
|
>
|
||||||
|
<el-form-item label="Origin Goods" prop="originGoodIds">
|
||||||
|
<div class="batch-search">
|
||||||
|
<el-input
|
||||||
|
v-model="batchOriginKeyword"
|
||||||
|
placeholder="Search origin good by name"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="batchSearchOrigin"
|
||||||
|
>
|
||||||
|
<template #append>
|
||||||
|
<el-button @click="batchSearchOrigin" :loading="batchOriginSearching">
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
<el-select
|
||||||
|
v-model="batchForm.originGoodIds"
|
||||||
|
multiple
|
||||||
|
filterable
|
||||||
|
placeholder="Select origin goods"
|
||||||
|
style="width: 100%; margin-top: 8px;"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="og in batchOriginList"
|
||||||
|
:key="og.id"
|
||||||
|
:label="`${og.name} (${og.productId})`"
|
||||||
|
:value="og.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="Country" prop="countryId">
|
||||||
|
<el-select v-model="batchForm.countryId" placeholder="Country" style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="c in countries"
|
||||||
|
:key="c.id"
|
||||||
|
:label="c.name"
|
||||||
|
:value="c.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="Category" prop="categoryId">
|
||||||
|
<el-cascader
|
||||||
|
v-model="batchForm.categoryId"
|
||||||
|
:options="categoryCascaderOptions"
|
||||||
|
:props="{ checkStrictly: true, value: 'value', label: 'label', children: 'children' }"
|
||||||
|
placeholder="Category"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="Tag" prop="tagId">
|
||||||
|
<el-select v-model="batchForm.tagId" placeholder="Tag" style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="t in tags"
|
||||||
|
:key="t.id"
|
||||||
|
:label="t.name"
|
||||||
|
:value="t.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="Position">
|
||||||
|
<el-select v-model="batchForm.positionId" placeholder="Optional" clearable style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="p in positions"
|
||||||
|
:key="p.id"
|
||||||
|
:label="`#${p.indexVal}`"
|
||||||
|
:value="p.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="Priority">
|
||||||
|
<el-input-number v-model="batchForm.priority" :min="0" :max="9999" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="batchDialogVisible = false">Cancel</el-button>
|
||||||
|
<el-button type="primary" :loading="batchDialogLoading" @click="handleBatchSubmit">
|
||||||
|
Create {{ batchForm.originGoodIds.length }} Goods
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
margin-top: 16px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-search {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules: FormRules = {
|
||||||
|
username: [
|
||||||
|
{ required: true, message: 'Please enter your username', trigger: 'blur' },
|
||||||
|
{ min: 2, max: 64, message: 'Length 2-64', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
password: [
|
||||||
|
{ required: true, message: 'Please enter your password', trigger: 'blur' },
|
||||||
|
{ min: 4, max: 64, message: 'Length 4-64', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!formRef.value) return
|
||||||
|
await formRef.value.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await authStore.login({ username: form.username, password: form.password })
|
||||||
|
ElMessage.success('Login successful')
|
||||||
|
router.push('/')
|
||||||
|
} catch (err) {
|
||||||
|
// Error toast is shown by axios response interceptor
|
||||||
|
console.error('Login failed', err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="login-bg" />
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-brand">
|
||||||
|
<div class="brand-logo">InkReach</div>
|
||||||
|
<div class="brand-subtitle">Product Center · Admin Console</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="form"
|
||||||
|
:rules="rules"
|
||||||
|
size="large"
|
||||||
|
label-position="top"
|
||||||
|
@submit.prevent="handleSubmit"
|
||||||
|
>
|
||||||
|
<el-form-item label="Username" prop="username">
|
||||||
|
<el-input
|
||||||
|
v-model="form.username"
|
||||||
|
placeholder="Enter your username"
|
||||||
|
clearable
|
||||||
|
autocomplete="username"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><User /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="Password" prop="password">
|
||||||
|
<el-input
|
||||||
|
v-model="form.password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
show-password
|
||||||
|
autocomplete="current-password"
|
||||||
|
@keyup.enter="handleSubmit"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Lock /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
class="login-button"
|
||||||
|
:loading="loading"
|
||||||
|
native-type="submit"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div class="login-footer">
|
||||||
|
<span>© {{ new Date().getFullYear() }} InkReach</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-page {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 20% 20%, rgba(255, 104, 0, 0.18), transparent 60%),
|
||||||
|
radial-gradient(circle at 80% 80%, rgba(255, 141, 31, 0.15), transparent 60%),
|
||||||
|
linear-gradient(135deg, #1f1f24 0%, #2b2b33 100%);
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 420px;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
padding: 40px 36px 28px;
|
||||||
|
background: rgba(255, 255, 255, 0.97);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--brand-color);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer {
|
||||||
|
text-align: center;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-form-item__label) {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
|
||||||
|
import type {
|
||||||
|
Position,
|
||||||
|
CreatePositionRequest,
|
||||||
|
UpdatePositionRequest,
|
||||||
|
PositionFilter,
|
||||||
|
Country,
|
||||||
|
Category,
|
||||||
|
CategoryTree,
|
||||||
|
} from '@/types'
|
||||||
|
import { positionsApi } from '@/api/positions'
|
||||||
|
import { countriesApi } from '@/api/countries'
|
||||||
|
import { categoriesApi } from '@/api/categories'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const list = ref<Position[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
const countries = ref<Country[]>([])
|
||||||
|
const categoriesTree = ref<CategoryTree[]>([])
|
||||||
|
|
||||||
|
const filter = reactive<Required<PositionFilter>>({
|
||||||
|
countryId: '',
|
||||||
|
categoryId: '',
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadLookups() {
|
||||||
|
const [c, ct] = await Promise.all([
|
||||||
|
countriesApi.getCountriesList({ page: 1, pageSize: 500 }),
|
||||||
|
categoriesApi.getCategoryTree(),
|
||||||
|
])
|
||||||
|
countries.value = c.data
|
||||||
|
categoriesTree.value = ct
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchList() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await positionsApi.getPositionsList(filter)
|
||||||
|
list.value = res.data
|
||||||
|
total.value = res.total
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
filter.page = 1
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
filter.countryId = ''
|
||||||
|
filter.categoryId = ''
|
||||||
|
filter.page = 1
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Cascader ----------
|
||||||
|
interface CascaderNode {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
children?: CascaderNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const cascaderOptions = ref<CascaderNode[]>([])
|
||||||
|
|
||||||
|
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
|
||||||
|
return nodes.map((n) => ({
|
||||||
|
value: n.id,
|
||||||
|
label: n.name,
|
||||||
|
children: n.children?.length ? buildCascader(n.children) : undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Dialog ----------
|
||||||
|
const dialogRef = ref()
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const dialogMode = ref<'create' | 'edit'>('create')
|
||||||
|
const dialogLoading = ref(false)
|
||||||
|
|
||||||
|
const dialogForm = reactive<CreatePositionRequest & { id?: string }>({
|
||||||
|
indexVal: 0,
|
||||||
|
countryId: '',
|
||||||
|
categoryId: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const dialogRules = {
|
||||||
|
indexVal: [{ required: true, message: 'Index is required', trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildCascader() {
|
||||||
|
cascaderOptions.value = buildCascader(categoriesTree.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddDialog() {
|
||||||
|
dialogMode.value = 'create'
|
||||||
|
Object.assign(dialogForm, { id: undefined, indexVal: 0, countryId: '', categoryId: '' })
|
||||||
|
rebuildCascader()
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(p: Position) {
|
||||||
|
dialogMode.value = 'edit'
|
||||||
|
Object.assign(dialogForm, {
|
||||||
|
id: p.id,
|
||||||
|
indexVal: p.indexVal,
|
||||||
|
countryId: p.countryId || '',
|
||||||
|
categoryId: p.categoryId || '',
|
||||||
|
})
|
||||||
|
rebuildCascader()
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!dialogRef.value) return
|
||||||
|
await dialogRef.value.validate(async (valid: boolean) => {
|
||||||
|
if (!valid) return
|
||||||
|
dialogLoading.value = true
|
||||||
|
try {
|
||||||
|
const payload: CreatePositionRequest = {
|
||||||
|
indexVal: Number(dialogForm.indexVal),
|
||||||
|
countryId: dialogForm.countryId || undefined,
|
||||||
|
categoryId: dialogForm.categoryId || undefined,
|
||||||
|
}
|
||||||
|
if (dialogMode.value === 'create') {
|
||||||
|
await positionsApi.createPosition(payload)
|
||||||
|
ElMessage.success('Position created')
|
||||||
|
} else {
|
||||||
|
await positionsApi.updatePosition(dialogForm.id!, payload as UpdatePositionRequest)
|
||||||
|
ElMessage.success('Position updated')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
fetchList()
|
||||||
|
} finally {
|
||||||
|
dialogLoading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(p: Position) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`Delete position #${p.indexVal}?`, 'Confirm', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: 'Delete',
|
||||||
|
cancelButtonText: 'Cancel',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await positionsApi.deletePosition(p.id)
|
||||||
|
ElMessage.success('Deleted')
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCategoryName(p: Position): string {
|
||||||
|
if (!p.category) return p.categoryId || '-'
|
||||||
|
return p.category.parent ? `${p.category.parent.name} / ${p.category.name}` : p.category.name
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadLookups()
|
||||||
|
await fetchList()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-container">
|
||||||
|
<div class="page-card">
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-select
|
||||||
|
v-model="filter.countryId"
|
||||||
|
placeholder="Country"
|
||||||
|
clearable
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="c in countries"
|
||||||
|
:key="c.id"
|
||||||
|
:label="c.name"
|
||||||
|
:value="c.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<el-cascader
|
||||||
|
v-model="filter.categoryId"
|
||||||
|
:options="cascaderOptions"
|
||||||
|
:props="{ checkStrictly: true, value: 'value', label: 'label', children: 'children', emitPath: false }"
|
||||||
|
placeholder="Category"
|
||||||
|
clearable
|
||||||
|
@change="handleSearch"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-button type="primary" @click="handleSearch">
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
<span>Search</span>
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="handleReset">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
<span>Reset</span>
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<div class="filter-spacer" />
|
||||||
|
|
||||||
|
<el-button type="primary" @click="openAddDialog">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
<span>Add Position</span>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" :data="list" border stripe>
|
||||||
|
<el-table-column label="Index" prop="indexVal" width="100" sortable />
|
||||||
|
<el-table-column label="Country" min-width="160">
|
||||||
|
<template #default="{ row }: { row: Position }">
|
||||||
|
{{ row.country?.name || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="Category" min-width="200">
|
||||||
|
<template #default="{ row }: { row: Position }">
|
||||||
|
{{ getCategoryName(row) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="Actions" width="180" fixed="right">
|
||||||
|
<template #default="{ row }: { row: Position }">
|
||||||
|
<div class="table-actions">
|
||||||
|
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||||
|
<el-icon><Edit /></el-icon>
|
||||||
|
<span>Edit</span>
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
<span>Delete</span>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<el-empty description="No positions" />
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
class="pagination"
|
||||||
|
v-model:current-page="filter.page"
|
||||||
|
v-model:page-size="filter.pageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@current-change="(p: number) => { filter.page = p; fetchList() }"
|
||||||
|
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="dialogMode === 'create' ? 'Add Position' : 'Edit Position'"
|
||||||
|
width="520px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
||||||
|
<el-form-item label="Index" prop="indexVal">
|
||||||
|
<el-input-number v-model="dialogForm.indexVal" :min="0" :max="9999" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="Country">
|
||||||
|
<el-select v-model="dialogForm.countryId" placeholder="Optional" clearable style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="c in countries"
|
||||||
|
:key="c.id"
|
||||||
|
:label="c.name"
|
||||||
|
:value="c.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="Category">
|
||||||
|
<el-cascader
|
||||||
|
v-model="dialogForm.categoryId"
|
||||||
|
:options="cascaderOptions"
|
||||||
|
:props="{
|
||||||
|
checkStrictly: true,
|
||||||
|
value: 'value',
|
||||||
|
label: 'label',
|
||||||
|
children: 'children',
|
||||||
|
emitPath: false,
|
||||||
|
}"
|
||||||
|
placeholder="Optional"
|
||||||
|
clearable
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">Cancel</el-button>
|
||||||
|
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||||
|
{{ dialogMode === 'create' ? 'Create' : 'Save' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
margin-top: 16px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Refresh, Folder, Box } from '@element-plus/icons-vue'
|
||||||
|
import type { SyncLog, SyncStats } from '@/types'
|
||||||
|
import { syncApi } from '@/api/sync'
|
||||||
|
|
||||||
|
const stats = ref<SyncStats | null>(null)
|
||||||
|
const logs = ref<SyncLog[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
const syncingCategories = ref(false)
|
||||||
|
const syncingProducts = ref(false)
|
||||||
|
|
||||||
|
const filter = reactive<{ page: number; pageSize: number; type: '' | 'CATEGORY' | 'PRODUCT' }>({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
type: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
async function refreshStats() {
|
||||||
|
try {
|
||||||
|
stats.value = await syncApi.getSyncStats()
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to fetch sync stats', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshLogs() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await syncApi.getSyncLogs({
|
||||||
|
page: filter.page,
|
||||||
|
pageSize: filter.pageSize,
|
||||||
|
type: filter.type || undefined,
|
||||||
|
})
|
||||||
|
logs.value = res.data
|
||||||
|
total.value = res.total
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to fetch sync logs', err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
await Promise.all([refreshStats(), refreshLogs()])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSyncCategories() {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'Trigger category sync now? This may take a while.',
|
||||||
|
'Confirm',
|
||||||
|
{
|
||||||
|
type: 'info',
|
||||||
|
confirmButtonText: 'Run',
|
||||||
|
cancelButtonText: 'Cancel',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
syncingCategories.value = true
|
||||||
|
try {
|
||||||
|
const log = await syncApi.syncCategories()
|
||||||
|
ElMessage.success(`Category sync started (${log.id})`)
|
||||||
|
await refreshAll()
|
||||||
|
} finally {
|
||||||
|
syncingCategories.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSyncProducts() {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'Trigger product sync now? This may take a while.',
|
||||||
|
'Confirm',
|
||||||
|
{
|
||||||
|
type: 'info',
|
||||||
|
confirmButtonText: 'Run',
|
||||||
|
cancelButtonText: 'Cancel',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
syncingProducts.value = true
|
||||||
|
try {
|
||||||
|
const log = await syncApi.syncProducts()
|
||||||
|
ElMessage.success(`Product sync started (${log.id})`)
|
||||||
|
await refreshAll()
|
||||||
|
} finally {
|
||||||
|
syncingProducts.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(s?: string): string {
|
||||||
|
if (!s) return '-'
|
||||||
|
return new Date(s).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusType(status: SyncLog['status']) {
|
||||||
|
return status === 'SUCCESS' ? 'success' : 'danger'
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeLabel(type: SyncLog['type']) {
|
||||||
|
return type === 'CATEGORY' ? 'Category' : 'Product'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
refreshAll()
|
||||||
|
timer = setInterval(refreshAll, 30_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timer) clearInterval(timer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-container">
|
||||||
|
<div class="page-card sync-status">
|
||||||
|
<div class="status-item">
|
||||||
|
<div class="status-label">Last Sync</div>
|
||||||
|
<div class="status-value">{{ formatDate(stats?.lastSyncTime) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<div class="status-label">Current Status</div>
|
||||||
|
<div class="status-value">
|
||||||
|
<el-tag :type="stats?.status === 'SYNCING' ? 'warning' : 'success'">
|
||||||
|
{{ stats?.status || 'IDLE' }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<div class="status-label">Error Count</div>
|
||||||
|
<div class="status-value">{{ stats?.errorCount ?? 0 }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sync-cards">
|
||||||
|
<el-card class="sync-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="sync-card-header">
|
||||||
|
<div class="sync-card-title">
|
||||||
|
<el-icon><Folder /></el-icon>
|
||||||
|
<span>Category Sync</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p class="sync-card-desc">
|
||||||
|
Pull latest categories from upstream and reconcile local database.
|
||||||
|
</p>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="syncingCategories"
|
||||||
|
@click="handleSyncCategories"
|
||||||
|
>
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
<span>Run Category Sync</span>
|
||||||
|
</el-button>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="sync-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="sync-card-header">
|
||||||
|
<div class="sync-card-title">
|
||||||
|
<el-icon><Box /></el-icon>
|
||||||
|
<span>Product Sync</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p class="sync-card-desc">
|
||||||
|
Pull latest products/origin goods from upstream and reconcile local database.
|
||||||
|
</p>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="syncingProducts"
|
||||||
|
@click="handleSyncProducts"
|
||||||
|
>
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
<span>Run Product Sync</span>
|
||||||
|
</el-button>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-card logs-card">
|
||||||
|
<div class="logs-toolbar">
|
||||||
|
<h3 class="logs-title">Sync Logs</h3>
|
||||||
|
<div class="logs-filter">
|
||||||
|
<el-select v-model="filter.type" placeholder="All types" clearable style="width: 160px;">
|
||||||
|
<el-option label="Category" value="CATEGORY" />
|
||||||
|
<el-option label="Product" value="PRODUCT" />
|
||||||
|
</el-select>
|
||||||
|
<el-button @click="refreshLogs">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
<span>Refresh</span>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" :data="logs" border stripe>
|
||||||
|
<el-table-column label="Type" width="120">
|
||||||
|
<template #default="{ row }: { row: SyncLog }">
|
||||||
|
<el-tag :type="row.type === 'CATEGORY' ? 'warning' : 'primary'">
|
||||||
|
{{ typeLabel(row.type) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="Status" width="120">
|
||||||
|
<template #default="{ row }: { row: SyncLog }">
|
||||||
|
<el-tag :type="statusType(row.status)">{{ row.status }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="Started" width="180">
|
||||||
|
<template #default="{ row }: { row: SyncLog }">
|
||||||
|
{{ formatDate(row.startTime) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="Ended" width="180">
|
||||||
|
<template #default="{ row }: { row: SyncLog }">
|
||||||
|
{{ formatDate(row.endTime) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="Errors" prop="errorCount" width="90" />
|
||||||
|
<el-table-column prop="message" label="Message" min-width="220" />
|
||||||
|
<template #empty>
|
||||||
|
<el-empty description="No sync logs" />
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
class="pagination"
|
||||||
|
v-model:current-page="filter.page"
|
||||||
|
v-model:page-size="filter.pageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@current-change="(p: number) => { filter.page = p; refreshLogs() }"
|
||||||
|
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; refreshLogs() }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sync-status {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-item {
|
||||||
|
flex: 1;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-card-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-card-title :deep(.el-icon) {
|
||||||
|
color: var(--brand-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-card-desc {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-filter {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
margin-top: 16px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
|
||||||
|
import type {
|
||||||
|
Tag,
|
||||||
|
CreateTagRequest,
|
||||||
|
UpdateTagRequest,
|
||||||
|
TagFilter,
|
||||||
|
} from '@/types'
|
||||||
|
import { tagsApi } from '@/api/tags'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const list = ref<Tag[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
const filter = reactive<Required<TagFilter>>({
|
||||||
|
name: '',
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchList() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await tagsApi.getTagsList(filter)
|
||||||
|
list.value = res.data
|
||||||
|
total.value = res.total
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
filter.page = 1
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
filter.name = ''
|
||||||
|
filter.page = 1
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialogRef = ref()
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const dialogMode = ref<'create' | 'edit'>('create')
|
||||||
|
const dialogLoading = ref(false)
|
||||||
|
|
||||||
|
const dialogForm = reactive<CreateTagRequest & { id?: string }>({
|
||||||
|
name: '',
|
||||||
|
color: '#ff6800',
|
||||||
|
timing: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const dialogRules = {
|
||||||
|
name: [{ required: true, message: 'Name is required', trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddDialog() {
|
||||||
|
dialogMode.value = 'create'
|
||||||
|
Object.assign(dialogForm, { id: undefined, name: '', color: '#ff6800', timing: '' })
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(t: Tag) {
|
||||||
|
dialogMode.value = 'edit'
|
||||||
|
Object.assign(dialogForm, {
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
color: t.color || '#ff6800',
|
||||||
|
timing: t.timing || '',
|
||||||
|
})
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!dialogRef.value) return
|
||||||
|
await dialogRef.value.validate(async (valid: boolean) => {
|
||||||
|
if (!valid) return
|
||||||
|
dialogLoading.value = true
|
||||||
|
try {
|
||||||
|
const payload: CreateTagRequest = {
|
||||||
|
name: dialogForm.name,
|
||||||
|
color: dialogForm.color || undefined,
|
||||||
|
timing: dialogForm.timing || undefined,
|
||||||
|
}
|
||||||
|
if (dialogMode.value === 'create') {
|
||||||
|
await tagsApi.createTag(payload)
|
||||||
|
ElMessage.success('Tag created')
|
||||||
|
} else {
|
||||||
|
await tagsApi.updateTag(dialogForm.id!, payload as UpdateTagRequest)
|
||||||
|
ElMessage.success('Tag updated')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
fetchList()
|
||||||
|
} finally {
|
||||||
|
dialogLoading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(t: Tag) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`Delete "${t.name}"?`, 'Confirm', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: 'Delete',
|
||||||
|
cancelButtonText: 'Cancel',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await tagsApi.deleteTag(t.id)
|
||||||
|
ElMessage.success('Deleted')
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchList)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-container">
|
||||||
|
<div class="page-card">
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-input
|
||||||
|
v-model="filter.name"
|
||||||
|
placeholder="Search by name"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
@clear="handleSearch"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-button type="primary" @click="handleSearch">
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
<span>Search</span>
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="handleReset">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
<span>Reset</span>
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<div class="filter-spacer" />
|
||||||
|
|
||||||
|
<el-button type="primary" @click="openAddDialog">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
<span>Add Tag</span>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" :data="list" border stripe>
|
||||||
|
<el-table-column label="Preview" width="120">
|
||||||
|
<template #default="{ row }: { row: Tag }">
|
||||||
|
<el-tag v-if="row.color" :color="row.color" effect="dark">
|
||||||
|
{{ row.name }}
|
||||||
|
</el-tag>
|
||||||
|
<el-tag v-else effect="plain">{{ row.name }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="name" label="Name" min-width="200" />
|
||||||
|
<el-table-column label="Color" width="140">
|
||||||
|
<template #default="{ row }: { row: Tag }">
|
||||||
|
<div class="color-cell">
|
||||||
|
<span class="color-swatch" :style="{ background: row.color || '#d1d5db' }" />
|
||||||
|
<span class="color-hex">{{ row.color || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="timing" label="Timing" min-width="120" />
|
||||||
|
<el-table-column label="Actions" width="180" fixed="right">
|
||||||
|
<template #default="{ row }: { row: Tag }">
|
||||||
|
<div class="table-actions">
|
||||||
|
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||||
|
<el-icon><Edit /></el-icon>
|
||||||
|
<span>Edit</span>
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
<span>Delete</span>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<el-empty description="No tags" />
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
class="pagination"
|
||||||
|
v-model:current-page="filter.page"
|
||||||
|
v-model:page-size="filter.pageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@current-change="(p: number) => { filter.page = p; fetchList() }"
|
||||||
|
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="dialogMode === 'create' ? 'Add Tag' : 'Edit Tag'"
|
||||||
|
width="480px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
||||||
|
<el-form-item label="Name" prop="name">
|
||||||
|
<el-input v-model="dialogForm.name" placeholder="Tag name" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="Color">
|
||||||
|
<el-color-picker v-model="dialogForm.color" />
|
||||||
|
<span class="color-readout">{{ dialogForm.color }}</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="Timing">
|
||||||
|
<el-input v-model="dialogForm.timing" placeholder="e.g. 9:00-12:00" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">Cancel</el-button>
|
||||||
|
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||||
|
{{ dialogMode === 'create' ? 'Create' : 'Save' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
margin-top: 16px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch {
|
||||||
|
display: inline-block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-hex {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-readout {
|
||||||
|
margin-left: 12px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"types": ["vite/client", "node"],
|
||||||
|
|
||||||
|
/* Path aliases */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts",
|
||||||
|
"src/**/*.tsx",
|
||||||
|
"src/**/*.vue",
|
||||||
|
"src/auto-imports.d.ts",
|
||||||
|
"src/components.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import AutoImport from 'unplugin-auto-import/vite'
|
||||||
|
import Components from 'unplugin-vue-components/vite'
|
||||||
|
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
AutoImport({
|
||||||
|
resolvers: [ElementPlusResolver()],
|
||||||
|
imports: ['vue', 'vue-router', 'pinia'],
|
||||||
|
dts: 'src/auto-imports.d.ts',
|
||||||
|
}),
|
||||||
|
Components({
|
||||||
|
resolvers: [ElementPlusResolver()],
|
||||||
|
dts: 'src/components.d.ts',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
module.exports = {
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
parserOptions: {
|
||||||
|
project: 'tsconfig.json',
|
||||||
|
tsconfigRootDir: __dirname,
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||||
|
extends: [
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
'plugin:prettier/recommended',
|
||||||
|
],
|
||||||
|
root: true,
|
||||||
|
env: {
|
||||||
|
node: true,
|
||||||
|
jest: true,
|
||||||
|
},
|
||||||
|
ignorePatterns: ['.eslintrc.js'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/interface-name-prefix': 'off',
|
||||||
|
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||||
|
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# compiled output
|
||||||
|
/dist
|
||||||
|
/node_modules
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
/.idea
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# IDE - VSCode
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Prisma
|
||||||
|
# prisma/migrations (keep migrations in VCS)
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
|
*.tsbuildinfo
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"tabWidth": 2,
|
||||||
|
"semi": true,
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
module.exports = {
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^@/(.*)$': '<rootDir>/src/$1',
|
||||||
|
},
|
||||||
|
testEnvironment: 'node',
|
||||||
|
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||||
|
rootDir: 'src',
|
||||||
|
testRegex: '.*\\.spec\\.ts$',
|
||||||
|
transform: {
|
||||||
|
'^.+\\.(t|j)s$': 'ts-jest',
|
||||||
|
},
|
||||||
|
collectCoverageFrom: ['**/*.(t|j)s'],
|
||||||
|
coverageDirectory: '../coverage',
|
||||||
|
coveragePathIgnorePatterns: ['/node_modules/', '/test/'],
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true,
|
||||||
|
"webpack": false
|
||||||
|
}
|
||||||
|
}
|
||||||
+10473
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"name": "inkreach-official-nestjs",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "NestJS backend for InkReach Product Center",
|
||||||
|
"main": "dist/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\"",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage",
|
||||||
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:migrate": "prisma migrate dev",
|
||||||
|
"prisma:studio": "prisma studio"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/axios": "^3.0.1",
|
||||||
|
"@nestjs/common": "^10.3.0",
|
||||||
|
"@nestjs/config": "^3.1.1",
|
||||||
|
"@nestjs/core": "^10.3.0",
|
||||||
|
"@nestjs/jwt": "^10.2.0",
|
||||||
|
"@nestjs/passport": "^10.0.3",
|
||||||
|
"@nestjs/platform-express": "^10.3.0",
|
||||||
|
"@nestjs/schedule": "^4.0.0",
|
||||||
|
"@nestjs/swagger": "^7.1.17",
|
||||||
|
"@prisma/client": "^5.8.0",
|
||||||
|
"axios": "^1.6.5",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.0",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"reflect-metadata": "^0.2.1",
|
||||||
|
"rxjs": "^7.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^10.2.1",
|
||||||
|
"@nestjs/schematics": "^10.0.3",
|
||||||
|
"@nestjs/testing": "^10.3.0",
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jest": "^29.5.11",
|
||||||
|
"@types/node": "^20.10.6",
|
||||||
|
"@types/passport-jwt": "^4.0.0",
|
||||||
|
"@types/supertest": "^6.0.2",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
||||||
|
"@typescript-eslint/parser": "^6.17.0",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-plugin-prettier": "^5.1.2",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"prettier": "^3.1.1",
|
||||||
|
"prisma": "^5.8.0",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"supertest": "^6.3.3",
|
||||||
|
"ts-jest": "^29.1.1",
|
||||||
|
"ts-loader": "^9.5.1",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "SyncType" AS ENUM ('CATEGORIES', 'PRODUCTS');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "SyncStatus" AS ENUM ('RUNNING', 'SUCCESS', 'FAILED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "origin_goods" (
|
||||||
|
"origin_good_id" BIGSERIAL NOT NULL,
|
||||||
|
"sds_good_id" TEXT NOT NULL,
|
||||||
|
"sds_category_id" TEXT,
|
||||||
|
"good_name" TEXT,
|
||||||
|
"good_image" TEXT,
|
||||||
|
"good_price" DECIMAL(12,2),
|
||||||
|
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "origin_goods_pkey" PRIMARY KEY ("origin_good_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "countries" (
|
||||||
|
"country_id" BIGSERIAL NOT NULL,
|
||||||
|
"country_name" TEXT NOT NULL,
|
||||||
|
"country_icon" TEXT,
|
||||||
|
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "countries_pkey" PRIMARY KEY ("country_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "categories" (
|
||||||
|
"category_id" BIGSERIAL NOT NULL,
|
||||||
|
"parent_category_id" BIGINT,
|
||||||
|
"category_name" TEXT NOT NULL,
|
||||||
|
"category_icon" TEXT,
|
||||||
|
"sds_category_id" TEXT,
|
||||||
|
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "categories_pkey" PRIMARY KEY ("category_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "tags" (
|
||||||
|
"tag_id" BIGSERIAL NOT NULL,
|
||||||
|
"tag_name" TEXT NOT NULL,
|
||||||
|
"tag_color" TEXT,
|
||||||
|
"timing" TEXT,
|
||||||
|
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "tags_pkey" PRIMARY KEY ("tag_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "positions" (
|
||||||
|
"position_id" BIGSERIAL NOT NULL,
|
||||||
|
"index_val" INTEGER NOT NULL,
|
||||||
|
"country_id" BIGINT,
|
||||||
|
"category_id" BIGINT,
|
||||||
|
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "positions_pkey" PRIMARY KEY ("position_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "goods" (
|
||||||
|
"good_id" BIGSERIAL NOT NULL,
|
||||||
|
"origin_good_id" BIGINT NOT NULL,
|
||||||
|
"country_id" BIGINT NOT NULL,
|
||||||
|
"category_id" BIGINT NOT NULL,
|
||||||
|
"tag_id" BIGINT,
|
||||||
|
"position_id" BIGINT,
|
||||||
|
"good_name" TEXT NOT NULL,
|
||||||
|
"good_priority" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "goods_pkey" PRIMARY KEY ("good_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" BIGSERIAL NOT NULL,
|
||||||
|
"username" TEXT NOT NULL,
|
||||||
|
"password_hash" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "sync_logs" (
|
||||||
|
"id" BIGSERIAL NOT NULL,
|
||||||
|
"type" "SyncType" NOT NULL,
|
||||||
|
"status" "SyncStatus" NOT NULL,
|
||||||
|
"message" TEXT,
|
||||||
|
"started_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"finished_at" TIMESTAMPTZ(6),
|
||||||
|
|
||||||
|
CONSTRAINT "sync_logs_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "origin_goods_sds_good_id_key" ON "origin_goods"("sds_good_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "origin_goods_sds_category_id_idx" ON "origin_goods"("sds_category_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "countries_country_name_key" ON "countries"("country_name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "categories_sds_category_id_key" ON "categories"("sds_category_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "categories_parent_category_id_idx" ON "categories"("parent_category_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "tags_tag_name_key" ON "tags"("tag_name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "positions_country_id_idx" ON "positions"("country_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "positions_category_id_idx" ON "positions"("category_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "positions_country_id_category_id_idx" ON "positions"("country_id", "category_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "goods_origin_good_id_idx" ON "goods"("origin_good_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "goods_country_id_idx" ON "goods"("country_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "goods_category_id_idx" ON "goods"("category_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "goods_tag_id_idx" ON "goods"("tag_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "goods_position_id_idx" ON "goods"("position_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "goods_good_priority_idx" ON "goods"("good_priority" DESC);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "goods_country_id_good_priority_idx" ON "goods"("country_id", "good_priority" DESC);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "goods_category_id_country_id_idx" ON "goods"("category_id", "country_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "sync_logs_type_started_at_idx" ON "sync_logs"("type", "started_at");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "categories" ADD CONSTRAINT "categories_parent_category_id_fkey" FOREIGN KEY ("parent_category_id") REFERENCES "categories"("category_id") ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "positions" ADD CONSTRAINT "positions_country_id_fkey" FOREIGN KEY ("country_id") REFERENCES "countries"("country_id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "positions" ADD CONSTRAINT "positions_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("category_id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "goods" ADD CONSTRAINT "goods_origin_good_id_fkey" FOREIGN KEY ("origin_good_id") REFERENCES "origin_goods"("origin_good_id") ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "goods" ADD CONSTRAINT "goods_country_id_fkey" FOREIGN KEY ("country_id") REFERENCES "countries"("country_id") ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "goods" ADD CONSTRAINT "goods_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("category_id") ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "goods" ADD CONSTRAINT "goods_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "tags"("tag_id") ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "goods" ADD CONSTRAINT "goods_position_id_fkey" FOREIGN KEY ("position_id") REFERENCES "positions"("position_id") ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (i.e. Git)
|
||||||
|
provider = "postgresql"
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// InkReach Product Center Prisma Schema
|
||||||
|
// Generated based on docs/dev/database-table-design.md
|
||||||
|
// All TIMESTAMPTZ columns use DateTime @db.Timestamptz(6)
|
||||||
|
// All BIGINT columns use BigInt
|
||||||
|
// snake_case table & column names via @@map / @map
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Origin Goods ----------
|
||||||
|
model OriginGood {
|
||||||
|
id BigInt @id @default(autoincrement()) @map("origin_good_id")
|
||||||
|
sdsGoodId String @unique @map("sds_good_id")
|
||||||
|
// Cached SDS product metadata (filled during sync)
|
||||||
|
sdsCategoryId String? @map("sds_category_id")
|
||||||
|
goodName String? @map("good_name")
|
||||||
|
goodImage String? @map("good_image")
|
||||||
|
goodPrice Decimal? @map("good_price") @db.Decimal(12, 2)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
|
goods Good[]
|
||||||
|
|
||||||
|
@@index([sdsCategoryId])
|
||||||
|
@@map("origin_goods")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Countries ----------
|
||||||
|
model Country {
|
||||||
|
id BigInt @id @default(autoincrement()) @map("country_id")
|
||||||
|
countryName String @unique @map("country_name")
|
||||||
|
countryIcon String? @map("country_icon")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
|
goods Good[]
|
||||||
|
positions Position[]
|
||||||
|
|
||||||
|
@@map("countries")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Categories (self-referential tree) ----------
|
||||||
|
model Category {
|
||||||
|
id BigInt @id @default(autoincrement()) @map("category_id")
|
||||||
|
parentCategoryId BigInt? @map("parent_category_id")
|
||||||
|
categoryName String @map("category_name")
|
||||||
|
categoryIcon String? @map("category_icon")
|
||||||
|
sdsCategoryId String? @unique @map("sds_category_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
|
parent Category? @relation("CategoryToCategory", fields: [parentCategoryId], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||||
|
children Category[] @relation("CategoryToCategory")
|
||||||
|
goods Good[]
|
||||||
|
positions Position[]
|
||||||
|
|
||||||
|
@@index([parentCategoryId])
|
||||||
|
@@map("categories")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Tags ----------
|
||||||
|
model Tag {
|
||||||
|
id BigInt @id @default(autoincrement()) @map("tag_id")
|
||||||
|
tagName String @unique @map("tag_name")
|
||||||
|
tagColor String? @map("tag_color")
|
||||||
|
timing String?
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
|
goods Good[]
|
||||||
|
|
||||||
|
@@map("tags")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Positions ----------
|
||||||
|
model Position {
|
||||||
|
id BigInt @id @default(autoincrement()) @map("position_id")
|
||||||
|
indexVal Int @map("index_val")
|
||||||
|
countryId BigInt? @map("country_id")
|
||||||
|
categoryId BigInt? @map("category_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
|
country Country? @relation(fields: [countryId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
category Category? @relation(fields: [categoryId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
goods Good[]
|
||||||
|
|
||||||
|
@@index([countryId])
|
||||||
|
@@index([categoryId])
|
||||||
|
@@index([countryId, categoryId])
|
||||||
|
@@map("positions")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Goods ----------
|
||||||
|
model Good {
|
||||||
|
id BigInt @id @default(autoincrement()) @map("good_id")
|
||||||
|
originGoodId BigInt @map("origin_good_id")
|
||||||
|
countryId BigInt @map("country_id")
|
||||||
|
categoryId BigInt @map("category_id")
|
||||||
|
tagId BigInt? @map("tag_id")
|
||||||
|
positionId BigInt? @map("position_id")
|
||||||
|
goodName String @map("good_name")
|
||||||
|
goodPriority Int @default(0) @map("good_priority")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
|
originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||||
|
country Country @relation(fields: [countryId], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||||
|
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||||
|
tag Tag? @relation(fields: [tagId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||||
|
position Position? @relation(fields: [positionId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@index([originGoodId])
|
||||||
|
@@index([countryId])
|
||||||
|
@@index([categoryId])
|
||||||
|
@@index([tagId])
|
||||||
|
@@index([positionId])
|
||||||
|
@@index([goodPriority(sort: Desc)])
|
||||||
|
@@index([countryId, goodPriority(sort: Desc)])
|
||||||
|
@@index([categoryId, countryId])
|
||||||
|
@@map("goods")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Users (admin authentication) ----------
|
||||||
|
model User {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
username String @unique
|
||||||
|
passwordHash String @map("password_hash")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Sync Logs ----------
|
||||||
|
enum SyncType {
|
||||||
|
CATEGORIES
|
||||||
|
PRODUCTS
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SyncStatus {
|
||||||
|
RUNNING
|
||||||
|
SUCCESS
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
model SyncLog {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
type SyncType
|
||||||
|
status SyncStatus
|
||||||
|
message String?
|
||||||
|
startedAt DateTime @default(now()) @map("started_at") @db.Timestamptz(6)
|
||||||
|
finishedAt DateTime? @map("finished_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
|
@@index([type, startedAt])
|
||||||
|
@@map("sync_logs")
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { CountriesModule } from './countries/countries.module';
|
||||||
|
import { CategoriesModule } from './categories/categories.module';
|
||||||
|
import { TagsModule } from './tags/tags.module';
|
||||||
|
import { PositionsModule } from './positions/positions.module';
|
||||||
|
import { OriginGoodsModule } from './origin-goods/origin-goods.module';
|
||||||
|
import { GoodsModule } from './goods/goods.module';
|
||||||
|
import { SyncModule } from './sync/sync.module';
|
||||||
|
import { PublicModule } from './public/public.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
}),
|
||||||
|
PrismaModule,
|
||||||
|
AuthModule,
|
||||||
|
CountriesModule,
|
||||||
|
CategoriesModule,
|
||||||
|
TagsModule,
|
||||||
|
PositionsModule,
|
||||||
|
OriginGoodsModule,
|
||||||
|
GoodsModule,
|
||||||
|
SyncModule,
|
||||||
|
PublicModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
import { RegisterDto } from './dto/register.dto';
|
||||||
|
import {
|
||||||
|
LoginResponseDto,
|
||||||
|
UserPublicDto,
|
||||||
|
} from './dto/auth-response.dto';
|
||||||
|
|
||||||
|
@ApiTags('auth')
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
@Post('register')
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({ summary: 'Register a new admin user' })
|
||||||
|
@ApiResponse({ status: 201, type: UserPublicDto })
|
||||||
|
@ApiResponse({ status: 409, description: 'Username already exists' })
|
||||||
|
register(@Body() dto: RegisterDto): Promise<UserPublicDto> {
|
||||||
|
return this.authService.register(dto) as unknown as Promise<UserPublicDto>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('login')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Login and obtain a JWT' })
|
||||||
|
@ApiResponse({ status: 200, type: LoginResponseDto })
|
||||||
|
@ApiResponse({ status: 401, description: 'Invalid credentials' })
|
||||||
|
login(@Body() dto: LoginDto): Promise<LoginResponseDto> {
|
||||||
|
return this.authService.login(dto) as unknown as Promise<LoginResponseDto>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { PassportModule } from '@nestjs/passport';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => {
|
||||||
|
const secret = config.get<string>('JWT_SECRET');
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error('JWT_SECRET must be configured');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
secret,
|
||||||
|
signOptions: { expiresIn: '7d' },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService, JwtStrategy],
|
||||||
|
exports: [AuthService, JwtModule],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { ConflictException, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
describe('AuthService', () => {
|
||||||
|
let service: AuthService;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
const createdUsernames: string[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
|
JwtModule.register({
|
||||||
|
secret: 'test-secret',
|
||||||
|
signOptions: { expiresIn: '1h' },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
providers: [AuthService, PrismaService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = moduleRef.get(AuthService);
|
||||||
|
prisma = moduleRef.get(PrismaService);
|
||||||
|
await prisma.onModuleInit();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Cleanup created test users
|
||||||
|
if (createdUsernames.length) {
|
||||||
|
await prisma.user.deleteMany({
|
||||||
|
where: { username: { in: createdUsernames } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await prisma.onModuleDestroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('register', () => {
|
||||||
|
it('creates a new user and stores a hashed password', async () => {
|
||||||
|
const username = `test_reg_${Date.now()}`;
|
||||||
|
createdUsernames.push(username);
|
||||||
|
|
||||||
|
const user = await service.register({ username, password: 'plain-pwd' });
|
||||||
|
|
||||||
|
expect(user.username).toBe(username);
|
||||||
|
expect(user.id).toBeTruthy();
|
||||||
|
|
||||||
|
const stored = await prisma.user.findUnique({ where: { username } });
|
||||||
|
expect(stored).not.toBeNull();
|
||||||
|
expect(stored?.passwordHash).not.toBe('plain-pwd');
|
||||||
|
const matches = await bcrypt.compare('plain-pwd', stored!.passwordHash);
|
||||||
|
expect(matches).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ConflictException for duplicate usernames', async () => {
|
||||||
|
const username = `test_dup_${Date.now()}`;
|
||||||
|
createdUsernames.push(username);
|
||||||
|
|
||||||
|
await service.register({ username, password: 'pwd1234' });
|
||||||
|
await expect(
|
||||||
|
service.register({ username, password: 'pwd5678' }),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('login', () => {
|
||||||
|
it('returns an access token for valid credentials', async () => {
|
||||||
|
const username = `test_login_${Date.now()}`;
|
||||||
|
createdUsernames.push(username);
|
||||||
|
await service.register({ username, password: 'correct-pwd' });
|
||||||
|
|
||||||
|
const result = await service.login({ username, password: 'correct-pwd' });
|
||||||
|
expect(result.accessToken).toEqual(expect.any(String));
|
||||||
|
const parts = result.accessToken.split('.');
|
||||||
|
expect(parts.length).toBe(3);
|
||||||
|
expect(result.user.username).toBe(username);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws UnauthorizedException for wrong password', async () => {
|
||||||
|
const username = `test_wrong_${Date.now()}`;
|
||||||
|
createdUsernames.push(username);
|
||||||
|
await service.register({ username, password: 'right-pwd' });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.login({ username, password: 'wrong-pwd' }),
|
||||||
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws UnauthorizedException for unknown user', async () => {
|
||||||
|
await expect(
|
||||||
|
service.login({ username: 'no-such-user-xyz', password: 'whatever' }),
|
||||||
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
import { RegisterDto } from './dto/register.dto';
|
||||||
|
import type { JwtPayload } from './strategies/jwt.strategy';
|
||||||
|
|
||||||
|
export interface PublicUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResult {
|
||||||
|
accessToken: string;
|
||||||
|
user: PublicUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BCRYPT_ROUNDS = 10;
|
||||||
|
const TOKEN_EXPIRES_IN = '7d';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly jwt: JwtService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a brand-new admin user. Throws {@link ConflictException}
|
||||||
|
* if the username is already taken.
|
||||||
|
*/
|
||||||
|
async register(dto: RegisterDto): Promise<PublicUser> {
|
||||||
|
const existing = await this.prisma.user.findUnique({
|
||||||
|
where: { username: dto.username },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException('Username already exists');
|
||||||
|
}
|
||||||
|
const passwordHash = await bcrypt.hash(dto.password, BCRYPT_ROUNDS);
|
||||||
|
const created = await this.prisma.user.create({
|
||||||
|
data: { username: dto.username, passwordHash },
|
||||||
|
});
|
||||||
|
return this.toPublic(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies credentials and returns a signed JWT.
|
||||||
|
*/
|
||||||
|
async login(dto: LoginDto): Promise<LoginResult> {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { username: dto.username },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
|
}
|
||||||
|
const ok = await bcrypt.compare(dto.password, user.passwordHash);
|
||||||
|
if (!ok) {
|
||||||
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
|
}
|
||||||
|
const payload: JwtPayload = {
|
||||||
|
sub: user.id.toString(),
|
||||||
|
username: user.username,
|
||||||
|
};
|
||||||
|
const accessToken = await this.jwt.signAsync(payload, {
|
||||||
|
expiresIn: TOKEN_EXPIRES_IN,
|
||||||
|
});
|
||||||
|
return { accessToken, user: this.toPublic(user) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private toPublic(user: {
|
||||||
|
id: bigint;
|
||||||
|
username: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}): PublicUser {
|
||||||
|
return {
|
||||||
|
id: user.id.toString(),
|
||||||
|
username: user.username,
|
||||||
|
createdAt: user.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class UserPublicDto {
|
||||||
|
@ApiProperty({ description: 'User ID (bigint serialized as string)' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
username!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
createdAt!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LoginResponseDto {
|
||||||
|
@ApiProperty()
|
||||||
|
accessToken!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: UserPublicDto })
|
||||||
|
user!: UserPublicDto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload accepted by `POST /auth/login`.
|
||||||
|
*/
|
||||||
|
export class LoginDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
username!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload accepted by `POST /auth/register`.
|
||||||
|
*
|
||||||
|
* Username must be unique (enforced by DB); password is hashed with bcrypt.
|
||||||
|
*/
|
||||||
|
export class RegisterDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
username!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(6)
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default guard for every admin route. Delegates to passport-jwt.
|
||||||
|
*
|
||||||
|
* Apply with `@UseGuards(JwtAuthGuard)`.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shape of the JWT we issue.
|
||||||
|
*
|
||||||
|
* `sub` is the user ID as a string (bigints are serialized to strings in JSON).
|
||||||
|
*/
|
||||||
|
export interface JwtPayload {
|
||||||
|
sub: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
|
constructor(config: ConfigService) {
|
||||||
|
const secret = config.get<string>('JWT_SECRET');
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error('JWT_SECRET is not configured');
|
||||||
|
}
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: secret,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs on every authenticated request. The returned object becomes
|
||||||
|
* `request.user` for downstream controllers.
|
||||||
|
*/
|
||||||
|
validate(payload: JwtPayload): { id: bigint; username: string } {
|
||||||
|
if (!payload?.sub || !payload.username) {
|
||||||
|
throw new UnauthorizedException('Invalid token payload');
|
||||||
|
}
|
||||||
|
return { id: BigInt(payload.sub), username: payload.username };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { CategoriesService } from './categories.service';
|
||||||
|
import { CreateCategoryDto } from './dto/create-category.dto';
|
||||||
|
import { UpdateCategoryDto } from './dto/update-category.dto';
|
||||||
|
|
||||||
|
@ApiTags('categories')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('categories')
|
||||||
|
export class CategoriesController {
|
||||||
|
constructor(private readonly service: CategoriesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Return categories as a tree' })
|
||||||
|
findAll() {
|
||||||
|
return this.service.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('flat')
|
||||||
|
@ApiOperation({ summary: 'Return categories as a flat list' })
|
||||||
|
findFlat() {
|
||||||
|
return this.service.findFlat();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get one category' })
|
||||||
|
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||||
|
return this.service.findOne(BigInt(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Create a category' })
|
||||||
|
create(@Body() dto: CreateCategoryDto) {
|
||||||
|
return this.service.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@ApiOperation({ summary: 'Update a category' })
|
||||||
|
update(
|
||||||
|
@Param('id', ParseIntPipe) id: string,
|
||||||
|
@Body() dto: UpdateCategoryDto,
|
||||||
|
) {
|
||||||
|
return this.service.update(BigInt(id), dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiOperation({ summary: 'Delete a category' })
|
||||||
|
remove(@Param('id', ParseIntPipe) id: string) {
|
||||||
|
return this.service.remove(BigInt(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CategoriesController } from './categories.controller';
|
||||||
|
import { CategoriesService } from './categories.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [CategoriesController],
|
||||||
|
providers: [CategoriesService],
|
||||||
|
exports: [CategoriesService],
|
||||||
|
})
|
||||||
|
export class CategoriesModule {}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { CategoriesService } from './categories.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
describe('CategoriesService', () => {
|
||||||
|
let service: CategoriesService;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
const createdNames: string[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
providers: [CategoriesService, PrismaService],
|
||||||
|
}).compile();
|
||||||
|
service = moduleRef.get(CategoriesService);
|
||||||
|
prisma = moduleRef.get(PrismaService);
|
||||||
|
await prisma.onModuleInit();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (createdNames.length) {
|
||||||
|
await prisma.category.deleteMany({
|
||||||
|
where: { categoryName: { in: createdNames } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await prisma.onModuleDestroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates root + child + grandchild and assembles them into a tree', async () => {
|
||||||
|
const stamp = Date.now();
|
||||||
|
const rootName = `Root ${stamp}`;
|
||||||
|
const childName = `Child ${stamp}`;
|
||||||
|
const leafName = `Leaf ${stamp}`;
|
||||||
|
createdNames.push(rootName, childName, leafName);
|
||||||
|
|
||||||
|
const root = await service.create({ categoryName: rootName });
|
||||||
|
const child = await service.create({
|
||||||
|
categoryName: childName,
|
||||||
|
parentCategoryId: Number(root.id),
|
||||||
|
});
|
||||||
|
const leaf = await service.create({
|
||||||
|
categoryName: leafName,
|
||||||
|
parentCategoryId: Number(child.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tree = await service.findAll();
|
||||||
|
const findNode = (
|
||||||
|
list: Array<{ id: string; children: Array<{ id: string }> }>,
|
||||||
|
id: string,
|
||||||
|
): { id: string; children: Array<{ id: string }> } | undefined => {
|
||||||
|
for (const n of list) {
|
||||||
|
if (n.id === id) return n;
|
||||||
|
const inner = findNode(n.children as any, id);
|
||||||
|
if (inner) return inner;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
const rootNode = findNode(tree as any, root.id.toString());
|
||||||
|
expect(rootNode).toBeDefined();
|
||||||
|
const childNode = findNode(rootNode!.children as any, child.id.toString());
|
||||||
|
expect(childNode).toBeDefined();
|
||||||
|
const leafNode = findNode(childNode!.children as any, leaf.id.toString());
|
||||||
|
expect(leafNode).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects deletion when children exist', async () => {
|
||||||
|
const stamp = Date.now();
|
||||||
|
const parent = await service.create({ categoryName: `Parent ${stamp}` });
|
||||||
|
createdNames.push(parent.categoryName);
|
||||||
|
const child = await service.create({
|
||||||
|
categoryName: `Child of ${stamp}`,
|
||||||
|
parentCategoryId: Number(parent.id),
|
||||||
|
});
|
||||||
|
createdNames.push(child.categoryName);
|
||||||
|
|
||||||
|
await expect(service.remove(parent.id)).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundException for unknown id', async () => {
|
||||||
|
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a flat list and a single node', async () => {
|
||||||
|
const stamp = Date.now();
|
||||||
|
const c = await service.create({ categoryName: `Flat ${stamp}` });
|
||||||
|
createdNames.push(c.categoryName);
|
||||||
|
const flat = await service.findFlat();
|
||||||
|
expect(flat.some((row) => row.id === c.id)).toBe(true);
|
||||||
|
const single = await service.findOne(c.id);
|
||||||
|
expect(single.categoryName).toBe(c.categoryName);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { Category as PrismaCategory, Prisma } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CreateCategoryDto } from './dto/create-category.dto';
|
||||||
|
import { UpdateCategoryDto } from './dto/update-category.dto';
|
||||||
|
import { CategoryNodeDto } from './dto/category-node.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CategoriesService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async findAll(): Promise<CategoryNodeDto[]> {
|
||||||
|
const all = await this.prisma.category.findMany({
|
||||||
|
orderBy: [{ id: 'asc' }],
|
||||||
|
});
|
||||||
|
return this.buildTree(all);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findFlat() {
|
||||||
|
return this.prisma.category.findMany({
|
||||||
|
orderBy: [{ id: 'asc' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: bigint) {
|
||||||
|
const c = await this.prisma.category.findUnique({ where: { id } });
|
||||||
|
if (!c) throw new NotFoundException(`Category ${id} not found`);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateCategoryDto) {
|
||||||
|
if (dto.parentCategoryId !== undefined && dto.parentCategoryId !== null) {
|
||||||
|
// Validate the parent exists to produce a clean 404 instead of FK error.
|
||||||
|
await this.findOne(BigInt(dto.parentCategoryId));
|
||||||
|
}
|
||||||
|
return this.prisma.category.create({
|
||||||
|
data: {
|
||||||
|
categoryName: dto.categoryName,
|
||||||
|
categoryIcon: dto.categoryIcon ?? null,
|
||||||
|
parentCategoryId:
|
||||||
|
dto.parentCategoryId === undefined || dto.parentCategoryId === null
|
||||||
|
? null
|
||||||
|
: BigInt(dto.parentCategoryId),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: bigint, dto: UpdateCategoryDto) {
|
||||||
|
await this.findOne(id);
|
||||||
|
if (dto.parentCategoryId !== undefined && dto.parentCategoryId !== null) {
|
||||||
|
// Prevent self-parenting & cycles.
|
||||||
|
if (BigInt(dto.parentCategoryId) === id) {
|
||||||
|
throw new BadRequestException('A category cannot be its own parent');
|
||||||
|
}
|
||||||
|
await this.findOne(BigInt(dto.parentCategoryId));
|
||||||
|
}
|
||||||
|
const data: Prisma.CategoryUpdateInput = {};
|
||||||
|
if (dto.categoryName !== undefined) data.categoryName = dto.categoryName;
|
||||||
|
if (dto.categoryIcon !== undefined) data.categoryIcon = dto.categoryIcon;
|
||||||
|
if (dto.parentCategoryId !== undefined) {
|
||||||
|
data.parent = dto.parentCategoryId === null
|
||||||
|
? { disconnect: true }
|
||||||
|
: { connect: { id: BigInt(dto.parentCategoryId) } };
|
||||||
|
}
|
||||||
|
return this.prisma.category.update({ where: { id }, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: bigint) {
|
||||||
|
await this.findOne(id);
|
||||||
|
const childCount = await this.prisma.category.count({
|
||||||
|
where: { parentCategoryId: id },
|
||||||
|
});
|
||||||
|
if (childCount > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Category has children and cannot be deleted',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await this.prisma.category.delete({ where: { id } });
|
||||||
|
} catch (err) {
|
||||||
|
if (this.isForeignKeyViolation(err)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Category is referenced by goods or positions and cannot be deleted',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isForeignKeyViolation(err: unknown): boolean {
|
||||||
|
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
return err.code === 'P2003';
|
||||||
|
}
|
||||||
|
if (err instanceof Prisma.PrismaClientUnknownRequestError) {
|
||||||
|
const msg = err.message ?? '';
|
||||||
|
return (
|
||||||
|
msg.includes('foreign key constraint') ||
|
||||||
|
msg.includes('RESTRICT') ||
|
||||||
|
msg.includes('violates')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a tree in-memory from a flat list. Top-level nodes have
|
||||||
|
* `parentCategoryId = null`.
|
||||||
|
*/
|
||||||
|
private buildTree(
|
||||||
|
rows: PrismaCategory[],
|
||||||
|
): CategoryNodeDto[] {
|
||||||
|
const byId = new Map<bigint, CategoryNodeDto>();
|
||||||
|
for (const row of rows) {
|
||||||
|
byId.set(row.id, CategoryNodeDto.from(row, []));
|
||||||
|
}
|
||||||
|
const roots: CategoryNodeDto[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const node = byId.get(row.id)!;
|
||||||
|
if (row.parentCategoryId === null) {
|
||||||
|
roots.push(node);
|
||||||
|
} else {
|
||||||
|
const parent = byId.get(row.parentCategoryId);
|
||||||
|
if (parent) {
|
||||||
|
parent.children.push(node);
|
||||||
|
} else {
|
||||||
|
// Orphan (parent row missing) — surface as a root.
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import type { Category as PrismaCategory } from '@prisma/client';
|
||||||
|
|
||||||
|
export class CategoryNodeDto {
|
||||||
|
@ApiProperty({ description: 'Category ID (bigint serialized as string)' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
categoryName!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
categoryIcon!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true, description: 'Parent category ID' })
|
||||||
|
parentCategoryId!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [CategoryNodeDto] })
|
||||||
|
children!: CategoryNodeDto[];
|
||||||
|
|
||||||
|
static from(category: PrismaCategory, children: CategoryNodeDto[] = []): CategoryNodeDto {
|
||||||
|
return {
|
||||||
|
id: category.id.toString(),
|
||||||
|
categoryName: category.categoryName,
|
||||||
|
categoryIcon: category.categoryIcon,
|
||||||
|
parentCategoryId: category.parentCategoryId
|
||||||
|
? category.parentCategoryId.toString()
|
||||||
|
: null,
|
||||||
|
children,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsInt,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateCategoryDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
categoryName!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
categoryIcon?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true, description: 'Parent category ID' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
parentCategoryId?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateCategoryDto {
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
categoryName?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
categoryIcon?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
parentCategoryId?: number | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulls the authenticated user out of the request, as populated by
|
||||||
|
* the JWT strategy.
|
||||||
|
*/
|
||||||
|
export const CurrentUser = createParamDecorator(
|
||||||
|
(data: keyof { id: bigint; username: string } | undefined, ctx: ExecutionContext) => {
|
||||||
|
const request = ctx.switchToHttp().getRequest<{ user?: { id: bigint; username: string } }>();
|
||||||
|
const user = request.user;
|
||||||
|
return data ? user?.[data] : user;
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
ArgumentsHost,
|
||||||
|
Catch,
|
||||||
|
ExceptionFilter,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global HTTP exception filter.
|
||||||
|
*
|
||||||
|
* Normalizes the error envelope to:
|
||||||
|
* ```json
|
||||||
|
* {
|
||||||
|
* "statusCode": 400,
|
||||||
|
* "message": "...",
|
||||||
|
* "error": "...",
|
||||||
|
* "timestamp": "ISO-8601",
|
||||||
|
* "path": "..."
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Catch()
|
||||||
|
export class HttpExceptionFilter implements ExceptionFilter {
|
||||||
|
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||||
|
|
||||||
|
catch(exception: unknown, host: ArgumentsHost): void {
|
||||||
|
const ctx = host.switchToHttp();
|
||||||
|
const response = ctx.getResponse<Response>();
|
||||||
|
const request = ctx.getRequest<Request>();
|
||||||
|
|
||||||
|
const status =
|
||||||
|
exception instanceof HttpException
|
||||||
|
? exception.getStatus()
|
||||||
|
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
|
||||||
|
let message: string | string[] = 'Internal server error';
|
||||||
|
let error = 'InternalServerError';
|
||||||
|
|
||||||
|
if (exception instanceof HttpException) {
|
||||||
|
const resp = exception.getResponse();
|
||||||
|
if (typeof resp === 'string') {
|
||||||
|
message = resp;
|
||||||
|
} else if (typeof resp === 'object' && resp !== null) {
|
||||||
|
const obj = resp as Record<string, unknown>;
|
||||||
|
message = (obj.message as string | string[]) ?? exception.message;
|
||||||
|
error = (obj.error as string) ?? exception.name;
|
||||||
|
} else {
|
||||||
|
message = exception.message;
|
||||||
|
}
|
||||||
|
} else if (exception instanceof Error) {
|
||||||
|
message = exception.message;
|
||||||
|
error = exception.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status >= 500) {
|
||||||
|
this.logger.error(
|
||||||
|
`${request.method} ${request.url} -> ${status} ${message}`,
|
||||||
|
exception instanceof Error ? exception.stack : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
response.status(status).json({
|
||||||
|
statusCode: status,
|
||||||
|
message,
|
||||||
|
error,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
path: request.url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import {
|
||||||
|
CallHandler,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Observable, map } from 'rxjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps every successful response into `{ data, success: true }`.
|
||||||
|
*
|
||||||
|
* Exceptions still flow through the global filter and are not wrapped.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class TransformInterceptor implements NestInterceptor {
|
||||||
|
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||||
|
return next.handle().pipe(map((data) => ({ data, success: true })));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { CountriesService } from './countries.service';
|
||||||
|
import { CreateCountryDto } from './dto/create-country.dto';
|
||||||
|
import { UpdateCountryDto } from './dto/update-country.dto';
|
||||||
|
|
||||||
|
@ApiTags('countries')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('countries')
|
||||||
|
export class CountriesController {
|
||||||
|
constructor(private readonly service: CountriesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List all countries' })
|
||||||
|
findAll() {
|
||||||
|
return this.service.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get one country' })
|
||||||
|
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||||
|
return this.service.findOne(BigInt(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Create a country' })
|
||||||
|
create(@Body() dto: CreateCountryDto) {
|
||||||
|
return this.service.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@ApiOperation({ summary: 'Update a country' })
|
||||||
|
update(
|
||||||
|
@Param('id', ParseIntPipe) id: string,
|
||||||
|
@Body() dto: UpdateCountryDto,
|
||||||
|
) {
|
||||||
|
return this.service.update(BigInt(id), dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiOperation({ summary: 'Delete a country' })
|
||||||
|
remove(@Param('id', ParseIntPipe) id: string) {
|
||||||
|
return this.service.remove(BigInt(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CountriesController } from './countries.controller';
|
||||||
|
import { CountriesService } from './countries.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [CountriesController],
|
||||||
|
providers: [CountriesService],
|
||||||
|
exports: [CountriesService],
|
||||||
|
})
|
||||||
|
export class CountriesModule {}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { CountriesService } from './countries.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
describe('CountriesService', () => {
|
||||||
|
let service: CountriesService;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
const created: string[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
providers: [CountriesService, PrismaService],
|
||||||
|
}).compile();
|
||||||
|
service = moduleRef.get(CountriesService);
|
||||||
|
prisma = moduleRef.get(PrismaService);
|
||||||
|
await prisma.onModuleInit();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (created.length) {
|
||||||
|
// Delete goods/origin_goods first so we can remove the countries.
|
||||||
|
await prisma.good.deleteMany({
|
||||||
|
where: { country: { countryName: { in: created } } },
|
||||||
|
});
|
||||||
|
await prisma.position.deleteMany({
|
||||||
|
where: { country: { countryName: { in: created } } },
|
||||||
|
});
|
||||||
|
await prisma.country.deleteMany({
|
||||||
|
where: { countryName: { in: created } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await prisma.onModuleDestroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates and reads back a country', async () => {
|
||||||
|
const name = `Test Country ${Date.now()}`;
|
||||||
|
created.push(name);
|
||||||
|
|
||||||
|
const createdRow = await service.create({ countryName: name });
|
||||||
|
expect(createdRow.countryName).toBe(name);
|
||||||
|
|
||||||
|
const fetched = await service.findOne(createdRow.id);
|
||||||
|
expect(fetched.countryName).toBe(name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects duplicate names with ConflictException', async () => {
|
||||||
|
const name = `Dup Country ${Date.now()}`;
|
||||||
|
created.push(name);
|
||||||
|
await service.create({ countryName: name });
|
||||||
|
await expect(service.create({ countryName: name })).rejects.toBeInstanceOf(
|
||||||
|
ConflictException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundException for unknown id', async () => {
|
||||||
|
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws BadRequestException when deleting a country referenced by goods', async () => {
|
||||||
|
const name = `Ref Country ${Date.now()}`;
|
||||||
|
created.push(name);
|
||||||
|
const country = await service.create({ countryName: name });
|
||||||
|
|
||||||
|
// Need a category + origin good to satisfy the foreign keys before
|
||||||
|
// we can attach a good that references the country.
|
||||||
|
const category = await prisma.category.create({
|
||||||
|
data: { categoryName: `Cat ${Date.now()}` },
|
||||||
|
});
|
||||||
|
const originGood = await prisma.originGood.create({
|
||||||
|
data: { sdsGoodId: `sds-${Date.now()}-${Math.random()}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.good.create({
|
||||||
|
data: {
|
||||||
|
originGoodId: originGood.id,
|
||||||
|
countryId: country.id,
|
||||||
|
categoryId: category.id,
|
||||||
|
goodName: 'sample',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.remove(country.id)).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
|
||||||
|
// cleanup
|
||||||
|
await prisma.good.deleteMany({ where: { countryId: country.id } });
|
||||||
|
await prisma.originGood.delete({ where: { id: originGood.id } });
|
||||||
|
await prisma.category.delete({ where: { id: category.id } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates fields and deletes when unreferenced', async () => {
|
||||||
|
const name = `Upd Country ${Date.now()}`;
|
||||||
|
created.push(name);
|
||||||
|
const c = await service.create({ countryName: name });
|
||||||
|
const updated = await service.update(c.id, { countryName: `${name}-v2` });
|
||||||
|
expect(updated.countryName).toBe(`${name}-v2`);
|
||||||
|
created[created.indexOf(name)] = `${name}-v2`;
|
||||||
|
|
||||||
|
await service.remove(c.id);
|
||||||
|
const idx = created.indexOf(`${name}-v2`);
|
||||||
|
if (idx !== -1) created.splice(idx, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CreateCountryDto } from './dto/create-country.dto';
|
||||||
|
import { UpdateCountryDto } from './dto/update-country.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CountriesService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
findAll() {
|
||||||
|
return this.prisma.country.findMany({ orderBy: { id: 'asc' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: bigint) {
|
||||||
|
const country = await this.prisma.country.findUnique({ where: { id } });
|
||||||
|
if (!country) {
|
||||||
|
throw new NotFoundException(`Country ${id} not found`);
|
||||||
|
}
|
||||||
|
return country;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateCountryDto) {
|
||||||
|
try {
|
||||||
|
return await this.prisma.country.create({
|
||||||
|
data: {
|
||||||
|
countryName: dto.countryName,
|
||||||
|
countryIcon: dto.countryIcon ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (
|
||||||
|
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
err.code === 'P2002'
|
||||||
|
) {
|
||||||
|
throw new ConflictException('Country name already exists');
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: bigint, dto: UpdateCountryDto) {
|
||||||
|
await this.findOne(id);
|
||||||
|
try {
|
||||||
|
return await this.prisma.country.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
countryName: dto.countryName,
|
||||||
|
countryIcon: dto.countryIcon === undefined ? undefined : dto.countryIcon,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (
|
||||||
|
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
err.code === 'P2002'
|
||||||
|
) {
|
||||||
|
throw new ConflictException('Country name already exists');
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: bigint) {
|
||||||
|
await this.findOne(id);
|
||||||
|
try {
|
||||||
|
return await this.prisma.country.delete({ where: { id } });
|
||||||
|
} catch (err) {
|
||||||
|
if (this.isForeignKeyViolation(err)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Country is referenced by goods or positions and cannot be deleted',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isForeignKeyViolation(err: unknown): boolean {
|
||||||
|
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
// P2003 = FK constraint violation
|
||||||
|
return err.code === 'P2003';
|
||||||
|
}
|
||||||
|
// Fallback: Prisma sometimes surfaces FK violations as UnknownRequestError
|
||||||
|
// when the constraint check happens server-side before the typed error
|
||||||
|
// is mapped (e.g. cascading RESTRICT).
|
||||||
|
if (err instanceof Prisma.PrismaClientUnknownRequestError) {
|
||||||
|
const msg = err.message ?? '';
|
||||||
|
return (
|
||||||
|
msg.includes('foreign key constraint') ||
|
||||||
|
msg.includes('RESTRICT') ||
|
||||||
|
msg.includes('violates')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateCountryDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
countryName!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
countryIcon?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateCountryDto {
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
countryName?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
countryIcon?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
ArrayMinSize,
|
||||||
|
IsArray,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
Min,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class BatchCreateItemDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
originGoodId!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
priority?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BatchCreateGoodDto {
|
||||||
|
@ApiProperty({ type: [BatchCreateItemDto] })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => BatchCreateItemDto)
|
||||||
|
items!: BatchCreateItemDto[];
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
countryId!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
categoryId!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
tagId?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
positionId?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, default: 0 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
defaultPriority?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
ArrayMinSize,
|
||||||
|
IsArray,
|
||||||
|
IsInt,
|
||||||
|
Min,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class PriorityItemDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
id!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
priority!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BatchPriorityDto {
|
||||||
|
@ApiProperty({ type: [PriorityItemDto] })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => PriorityItemDto)
|
||||||
|
items!: PriorityItemDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsInt,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateGoodDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
goodName!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
originGoodId!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
countryId!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
categoryId!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
tagId?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
positionId?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, default: 0 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
goodPriority?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import type { Good as PrismaGood } from '@prisma/client';
|
||||||
|
|
||||||
|
export interface GoodRelations {
|
||||||
|
country?: { id: bigint; countryName: string; countryIcon: string | null } | null;
|
||||||
|
category?: { id: bigint; categoryName: string; categoryIcon: string | null } | null;
|
||||||
|
tag?: { id: bigint; tagName: string; tagColor: string | null } | null;
|
||||||
|
position?: { id: bigint; indexVal: number } | null;
|
||||||
|
originGood?: {
|
||||||
|
id: bigint;
|
||||||
|
sdsGoodId: string;
|
||||||
|
goodName: string | null;
|
||||||
|
goodImage: string | null;
|
||||||
|
goodPrice: unknown;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GoodDto {
|
||||||
|
@ApiProperty()
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
goodName!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
goodPriority!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
countryId!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
categoryId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
tagId!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
positionId!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
originGoodId!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
createdAt!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
updatedAt!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
country?: { id: string; countryName: string; countryIcon: string | null } | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
category?: { id: string; categoryName: string; categoryIcon: string | null } | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
tag?: { id: string; tagName: string; tagColor: string | null } | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
position?: { id: string; indexVal: number } | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
originGood?: {
|
||||||
|
id: string;
|
||||||
|
sdsGoodId: string;
|
||||||
|
goodName: string | null;
|
||||||
|
goodImage: string | null;
|
||||||
|
goodPrice: string | null;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
static from(
|
||||||
|
good: PrismaGood,
|
||||||
|
rel: GoodRelations = {},
|
||||||
|
): GoodDto {
|
||||||
|
return {
|
||||||
|
id: good.id.toString(),
|
||||||
|
goodName: good.goodName,
|
||||||
|
goodPriority: good.goodPriority,
|
||||||
|
countryId: good.countryId.toString(),
|
||||||
|
categoryId: good.categoryId.toString(),
|
||||||
|
tagId: good.tagId === null || good.tagId === undefined ? null : good.tagId.toString(),
|
||||||
|
positionId: good.positionId === null || good.positionId === undefined ? null : good.positionId.toString(),
|
||||||
|
originGoodId: good.originGoodId.toString(),
|
||||||
|
createdAt: good.createdAt.toISOString(),
|
||||||
|
updatedAt: good.updatedAt.toISOString(),
|
||||||
|
country: rel.country
|
||||||
|
? {
|
||||||
|
id: rel.country.id.toString(),
|
||||||
|
countryName: rel.country.countryName,
|
||||||
|
countryIcon: rel.country.countryIcon,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
category: rel.category
|
||||||
|
? {
|
||||||
|
id: rel.category.id.toString(),
|
||||||
|
categoryName: rel.category.categoryName,
|
||||||
|
categoryIcon: rel.category.categoryIcon,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
tag: rel.tag
|
||||||
|
? {
|
||||||
|
id: rel.tag.id.toString(),
|
||||||
|
tagName: rel.tag.tagName,
|
||||||
|
tagColor: rel.tag.tagColor,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
position: rel.position
|
||||||
|
? {
|
||||||
|
id: rel.position.id.toString(),
|
||||||
|
indexVal: rel.position.indexVal,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
originGood: rel.originGood
|
||||||
|
? {
|
||||||
|
id: rel.originGood.id.toString(),
|
||||||
|
sdsGoodId: rel.originGood.sdsGoodId,
|
||||||
|
goodName: rel.originGood.goodName,
|
||||||
|
goodImage: rel.originGood.goodImage,
|
||||||
|
goodPrice:
|
||||||
|
rel.originGood.goodPrice === null ||
|
||||||
|
rel.originGood.goodPrice === undefined
|
||||||
|
? null
|
||||||
|
: (rel.originGood.goodPrice as { toString(): string }).toString(),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedGoods {
|
||||||
|
items: GoodDto[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class QueryGoodDto {
|
||||||
|
@ApiProperty({ required: false, default: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page: number = 1;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, default: 20 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(200)
|
||||||
|
pageSize: number = 20;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
countryId?: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
required: false,
|
||||||
|
description: 'Includes all descendants of this category recursively',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
categoryId?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
tagId?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
positionId?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
keyword?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateGoodDto {
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
goodName?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
originGoodId?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
countryId?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
categoryId?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
tagId?: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
positionId?: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
goodPriority?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { GoodsService } from './goods.service';
|
||||||
|
import { CreateGoodDto } from './dto/create-good.dto';
|
||||||
|
import { UpdateGoodDto } from './dto/update-good.dto';
|
||||||
|
import { QueryGoodDto } from './dto/query-good.dto';
|
||||||
|
import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
|
||||||
|
import { BatchPriorityDto } from './dto/batch-priority.dto';
|
||||||
|
|
||||||
|
@ApiTags('goods')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('goods')
|
||||||
|
export class GoodsController {
|
||||||
|
constructor(private readonly service: GoodsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List goods with filters & pagination' })
|
||||||
|
findAll(@Query() query: QueryGoodDto) {
|
||||||
|
return this.service.findAll(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get one good with relations' })
|
||||||
|
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||||
|
return this.service.findOne(BigInt(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Create a good' })
|
||||||
|
create(@Body() dto: CreateGoodDto) {
|
||||||
|
return this.service.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@ApiOperation({ summary: 'Update a good' })
|
||||||
|
update(
|
||||||
|
@Param('id', ParseIntPipe) id: string,
|
||||||
|
@Body() dto: UpdateGoodDto,
|
||||||
|
) {
|
||||||
|
return this.service.update(BigInt(id), dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiOperation({ summary: 'Delete a good' })
|
||||||
|
remove(@Param('id', ParseIntPipe) id: string) {
|
||||||
|
return this.service.remove(BigInt(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('batch-priority')
|
||||||
|
@ApiOperation({ summary: 'Batch update good priorities (transaction)' })
|
||||||
|
batchPriority(@Body() dto: BatchPriorityDto) {
|
||||||
|
return this.service.batchUpdatePriority(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('batch')
|
||||||
|
@ApiOperation({ summary: 'Batch create goods from origin goods (transaction)' })
|
||||||
|
batchCreate(@Body() dto: BatchCreateGoodDto) {
|
||||||
|
return this.service.batchCreate(dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { GoodsController } from './goods.controller';
|
||||||
|
import { GoodsService } from './goods.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [GoodsController],
|
||||||
|
providers: [GoodsService],
|
||||||
|
exports: [GoodsService],
|
||||||
|
})
|
||||||
|
export class GoodsModule {}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { GoodsService } from './goods.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
describe('GoodsService', () => {
|
||||||
|
let service: GoodsService;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
const stamp = Date.now();
|
||||||
|
|
||||||
|
// Fixtures
|
||||||
|
let countryId: bigint;
|
||||||
|
let country2Id: bigint;
|
||||||
|
let categoryId: bigint;
|
||||||
|
let childCategoryId: bigint;
|
||||||
|
let tagId: bigint;
|
||||||
|
let positionId: bigint;
|
||||||
|
let originGoodIds: bigint[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
providers: [GoodsService, PrismaService],
|
||||||
|
}).compile();
|
||||||
|
service = moduleRef.get(GoodsService);
|
||||||
|
prisma = moduleRef.get(PrismaService);
|
||||||
|
await prisma.onModuleInit();
|
||||||
|
|
||||||
|
const country = await prisma.country.create({
|
||||||
|
data: { countryName: `Goods Country ${stamp}` },
|
||||||
|
});
|
||||||
|
countryId = country.id;
|
||||||
|
const country2 = await prisma.country.create({
|
||||||
|
data: { countryName: `Goods Country 2 ${stamp}` },
|
||||||
|
});
|
||||||
|
country2Id = country2.id;
|
||||||
|
|
||||||
|
const cat = await prisma.category.create({
|
||||||
|
data: { categoryName: `Goods Cat ${stamp}` },
|
||||||
|
});
|
||||||
|
categoryId = cat.id;
|
||||||
|
const child = await prisma.category.create({
|
||||||
|
data: { categoryName: `Goods Child ${stamp}`, parentCategoryId: cat.id },
|
||||||
|
});
|
||||||
|
childCategoryId = child.id;
|
||||||
|
|
||||||
|
const tag = await prisma.tag.create({
|
||||||
|
data: { tagName: `Goods Tag ${stamp}`, tagColor: '#00FF00' },
|
||||||
|
});
|
||||||
|
tagId = tag.id;
|
||||||
|
|
||||||
|
const pos = await prisma.position.create({
|
||||||
|
data: { indexVal: 1, countryId, categoryId },
|
||||||
|
});
|
||||||
|
positionId = pos.id;
|
||||||
|
|
||||||
|
const originGoods = await Promise.all(
|
||||||
|
Array.from({ length: 5 }).map((_, i) =>
|
||||||
|
prisma.originGood.create({
|
||||||
|
data: {
|
||||||
|
sdsGoodId: `sds-goods-${stamp}-${i}`,
|
||||||
|
goodName: `Goods Origin ${stamp} ${i}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
originGoodIds = originGoods.map((og) => og.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Wipe all goods first so origin_goods/category can be removed.
|
||||||
|
await prisma.good.deleteMany({
|
||||||
|
where: { goodName: { contains: `Goods Test ${stamp}` } },
|
||||||
|
});
|
||||||
|
await prisma.good.deleteMany({
|
||||||
|
where: { goodName: { contains: `Origin ${stamp}` } },
|
||||||
|
});
|
||||||
|
await prisma.originGood.deleteMany({
|
||||||
|
where: { id: { in: originGoodIds } },
|
||||||
|
});
|
||||||
|
await prisma.position.delete({ where: { id: positionId } });
|
||||||
|
await prisma.tag.delete({ where: { id: tagId } });
|
||||||
|
await prisma.category.delete({ where: { id: childCategoryId } });
|
||||||
|
await prisma.category.delete({ where: { id: categoryId } });
|
||||||
|
await prisma.country.delete({ where: { id: countryId } });
|
||||||
|
await prisma.country.delete({ where: { id: country2Id } });
|
||||||
|
await prisma.onModuleDestroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates and reads back a good', async () => {
|
||||||
|
const created = await service.create({
|
||||||
|
goodName: `Goods Test ${stamp} basic`,
|
||||||
|
originGoodId: Number(originGoodIds[0]),
|
||||||
|
countryId: Number(countryId),
|
||||||
|
categoryId: Number(categoryId),
|
||||||
|
tagId: Number(tagId),
|
||||||
|
positionId: Number(positionId),
|
||||||
|
goodPriority: 3,
|
||||||
|
});
|
||||||
|
expect(created.id).toBeTruthy();
|
||||||
|
expect(created.country?.countryName).toBeTruthy();
|
||||||
|
expect(created.tag?.tagColor).toBe('#00FF00');
|
||||||
|
|
||||||
|
const fetched = await service.findOne(BigInt(created.id));
|
||||||
|
expect(fetched.goodName).toBe(`Goods Test ${stamp} basic`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by countryId, tagId, positionId and keyword', async () => {
|
||||||
|
const result = await service.findAll({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
countryId: Number(countryId),
|
||||||
|
tagId: Number(tagId),
|
||||||
|
keyword: `Goods Test ${stamp}`,
|
||||||
|
});
|
||||||
|
expect(result.items.length).toBeGreaterThan(0);
|
||||||
|
expect(result.items.every((g) => g.countryId === countryId.toString())).toBe(true);
|
||||||
|
expect(result.items.every((g) => g.tagId === tagId.toString())).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('categoryId filter includes descendants recursively', async () => {
|
||||||
|
const inChild = await service.create({
|
||||||
|
goodName: `Goods Test ${stamp} child`,
|
||||||
|
originGoodId: Number(originGoodIds[1]),
|
||||||
|
countryId: Number(countryId),
|
||||||
|
categoryId: Number(childCategoryId),
|
||||||
|
});
|
||||||
|
const result = await service.findAll({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
categoryId: Number(categoryId),
|
||||||
|
keyword: `Goods Test ${stamp}`,
|
||||||
|
});
|
||||||
|
const ids = result.items.map((g) => g.id);
|
||||||
|
expect(ids).toContain(inChild.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('batch update priority is atomic', async () => {
|
||||||
|
const created = await service.create({
|
||||||
|
goodName: `Goods Test ${stamp} prio`,
|
||||||
|
originGoodId: Number(originGoodIds[2]),
|
||||||
|
countryId: Number(countryId),
|
||||||
|
categoryId: Number(categoryId),
|
||||||
|
});
|
||||||
|
const result = await service.batchUpdatePriority({
|
||||||
|
items: [{ id: Number(created.id), priority: 42 }],
|
||||||
|
});
|
||||||
|
expect(result.count).toBe(1);
|
||||||
|
const after = await service.findOne(BigInt(created.id));
|
||||||
|
expect(after.goodPriority).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('batch create creates all rows or none', async () => {
|
||||||
|
// Count of goods whose originGoodId is one of the two fixture ids,
|
||||||
|
// so we are independent of goodName (which batch derives from origin).
|
||||||
|
const before = await service.findAll({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
keyword: `Goods Origin ${stamp}`,
|
||||||
|
});
|
||||||
|
const created = await service.batchCreate({
|
||||||
|
countryId: Number(countryId),
|
||||||
|
categoryId: Number(categoryId),
|
||||||
|
defaultPriority: 1,
|
||||||
|
items: [
|
||||||
|
{ originGoodId: Number(originGoodIds[3]) },
|
||||||
|
{ originGoodId: Number(originGoodIds[4]) },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(created.length).toBe(2);
|
||||||
|
const after = await service.findAll({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
keyword: `Goods Origin ${stamp}`,
|
||||||
|
});
|
||||||
|
expect(after.total).toBe(before.total + 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('batch create rolls back on failure', async () => {
|
||||||
|
const before = await service.findAll({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
keyword: `Goods Origin ${stamp}`,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
service.batchCreate({
|
||||||
|
countryId: Number(countryId),
|
||||||
|
categoryId: Number(categoryId),
|
||||||
|
items: [
|
||||||
|
{ originGoodId: Number(originGoodIds[0]) },
|
||||||
|
{ originGoodId: 99999999 }, // missing -> failure
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
const after = await service.findAll({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
keyword: `Goods Origin ${stamp}`,
|
||||||
|
});
|
||||||
|
expect(after.total).toBe(before.total);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundException for unknown id', async () => {
|
||||||
|
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CreateGoodDto } from './dto/create-good.dto';
|
||||||
|
import { UpdateGoodDto } from './dto/update-good.dto';
|
||||||
|
import { QueryGoodDto } from './dto/query-good.dto';
|
||||||
|
import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
|
||||||
|
import { BatchPriorityDto } from './dto/batch-priority.dto';
|
||||||
|
import { GoodDto, PaginatedGoods } from './dto/good.dto';
|
||||||
|
|
||||||
|
const GOOD_INCLUDE = {
|
||||||
|
country: true,
|
||||||
|
category: true,
|
||||||
|
tag: true,
|
||||||
|
position: true,
|
||||||
|
originGood: true,
|
||||||
|
} satisfies Prisma.GoodInclude;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GoodsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async findAll(query: QueryGoodDto): Promise<PaginatedGoods> {
|
||||||
|
const { page, pageSize, countryId, categoryId, tagId, positionId, keyword } = query;
|
||||||
|
const where: Prisma.GoodWhereInput = {};
|
||||||
|
if (countryId !== undefined) where.countryId = BigInt(countryId);
|
||||||
|
if (tagId !== undefined) where.tagId = BigInt(tagId);
|
||||||
|
if (positionId !== undefined) where.positionId = BigInt(positionId);
|
||||||
|
if (keyword) {
|
||||||
|
where.goodName = { contains: keyword, mode: 'insensitive' };
|
||||||
|
}
|
||||||
|
if (categoryId !== undefined) {
|
||||||
|
const ids = await this.collectCategoryDescendants(BigInt(categoryId));
|
||||||
|
where.categoryId = { in: ids };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [total, rows] = await this.prisma.$transaction([
|
||||||
|
this.prisma.good.count({ where }),
|
||||||
|
this.prisma.good.findMany({
|
||||||
|
where,
|
||||||
|
include: GOOD_INCLUDE,
|
||||||
|
orderBy: [{ goodPriority: 'desc' }, { createdAt: 'desc' }],
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: rows.map((g) => GoodDto.from(g, {
|
||||||
|
country: g.country,
|
||||||
|
category: g.category,
|
||||||
|
tag: g.tag,
|
||||||
|
position: g.position,
|
||||||
|
originGood: g.originGood,
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: bigint): Promise<GoodDto> {
|
||||||
|
const good = await this.prisma.good.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: GOOD_INCLUDE,
|
||||||
|
});
|
||||||
|
if (!good) throw new NotFoundException(`Good ${id} not found`);
|
||||||
|
return GoodDto.from(good, {
|
||||||
|
country: good.country,
|
||||||
|
category: good.category,
|
||||||
|
tag: good.tag,
|
||||||
|
position: good.position,
|
||||||
|
originGood: good.originGood,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateGoodDto): Promise<GoodDto> {
|
||||||
|
await this.ensureReferences(dto);
|
||||||
|
const created = await this.prisma.good.create({
|
||||||
|
data: {
|
||||||
|
goodName: dto.goodName,
|
||||||
|
originGoodId: BigInt(dto.originGoodId),
|
||||||
|
countryId: BigInt(dto.countryId),
|
||||||
|
categoryId: BigInt(dto.categoryId),
|
||||||
|
tagId: dto.tagId === undefined ? null : BigInt(dto.tagId),
|
||||||
|
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
|
||||||
|
goodPriority: dto.goodPriority ?? 0,
|
||||||
|
},
|
||||||
|
include: GOOD_INCLUDE,
|
||||||
|
});
|
||||||
|
return GoodDto.from(created, {
|
||||||
|
country: created.country,
|
||||||
|
category: created.category,
|
||||||
|
tag: created.tag,
|
||||||
|
position: created.position,
|
||||||
|
originGood: created.originGood,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: bigint, dto: UpdateGoodDto): Promise<GoodDto> {
|
||||||
|
await this.findOne(id);
|
||||||
|
const data: Prisma.GoodUpdateInput = {};
|
||||||
|
if (dto.goodName !== undefined) data.goodName = dto.goodName;
|
||||||
|
if (dto.originGoodId !== undefined) {
|
||||||
|
await this.ensureOriginGood(dto.originGoodId);
|
||||||
|
data.originGood = { connect: { id: BigInt(dto.originGoodId) } };
|
||||||
|
}
|
||||||
|
if (dto.countryId !== undefined) {
|
||||||
|
await this.ensureCountry(dto.countryId);
|
||||||
|
data.country = { connect: { id: BigInt(dto.countryId) } };
|
||||||
|
}
|
||||||
|
if (dto.categoryId !== undefined) {
|
||||||
|
await this.ensureCategory(dto.categoryId);
|
||||||
|
data.category = { connect: { id: BigInt(dto.categoryId) } };
|
||||||
|
}
|
||||||
|
if (dto.tagId !== undefined) {
|
||||||
|
data.tag =
|
||||||
|
dto.tagId === null
|
||||||
|
? { disconnect: true }
|
||||||
|
: { connect: { id: BigInt(dto.tagId) } };
|
||||||
|
}
|
||||||
|
if (dto.positionId !== undefined) {
|
||||||
|
data.position =
|
||||||
|
dto.positionId === null
|
||||||
|
? { disconnect: true }
|
||||||
|
: { connect: { id: BigInt(dto.positionId) } };
|
||||||
|
}
|
||||||
|
if (dto.goodPriority !== undefined) data.goodPriority = dto.goodPriority;
|
||||||
|
const updated = await this.prisma.good.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
include: GOOD_INCLUDE,
|
||||||
|
});
|
||||||
|
return GoodDto.from(updated, {
|
||||||
|
country: updated.country,
|
||||||
|
category: updated.category,
|
||||||
|
tag: updated.tag,
|
||||||
|
position: updated.position,
|
||||||
|
originGood: updated.originGood,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: bigint): Promise<{ id: string }> {
|
||||||
|
await this.findOne(id);
|
||||||
|
await this.prisma.good.delete({ where: { id } });
|
||||||
|
return { id: id.toString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates priorities in a single transaction; either all rows update
|
||||||
|
* or none do.
|
||||||
|
*/
|
||||||
|
async batchUpdatePriority(dto: BatchPriorityDto): Promise<{ count: number }> {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
for (const item of dto.items) {
|
||||||
|
await tx.good.update({
|
||||||
|
where: { id: BigInt(item.id) },
|
||||||
|
data: { goodPriority: item.priority },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { count: dto.items.length };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates multiple goods atomically, sharing countryId/categoryId/tagId/positionId
|
||||||
|
* and a default priority that may be overridden per item.
|
||||||
|
*/
|
||||||
|
async batchCreate(dto: BatchCreateGoodDto): Promise<GoodDto[]> {
|
||||||
|
const defaultPriority = dto.defaultPriority ?? 0;
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const created: GoodDto[] = [];
|
||||||
|
for (const item of dto.items) {
|
||||||
|
const og = await tx.originGood.findUnique({
|
||||||
|
where: { id: BigInt(item.originGoodId) },
|
||||||
|
});
|
||||||
|
if (!og) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Origin good ${item.originGoodId} not found`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const row = await tx.good.create({
|
||||||
|
data: {
|
||||||
|
goodName: og.goodName ?? `Origin Good ${og.sdsGoodId}`,
|
||||||
|
originGoodId: og.id,
|
||||||
|
countryId: BigInt(dto.countryId),
|
||||||
|
categoryId: BigInt(dto.categoryId),
|
||||||
|
tagId: dto.tagId === undefined ? null : BigInt(dto.tagId),
|
||||||
|
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
|
||||||
|
goodPriority: item.priority ?? defaultPriority,
|
||||||
|
},
|
||||||
|
include: GOOD_INCLUDE,
|
||||||
|
});
|
||||||
|
created.push(GoodDto.from(row, {
|
||||||
|
country: row.country,
|
||||||
|
category: row.category,
|
||||||
|
tag: row.tag,
|
||||||
|
position: row.position,
|
||||||
|
originGood: row.originGood,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk the category tree and return the requested id + all of its
|
||||||
|
* descendants. We use a level-by-level BFS to keep the queries small
|
||||||
|
* for the typical tree sizes we expect.
|
||||||
|
*/
|
||||||
|
private async collectCategoryDescendants(rootId: bigint): Promise<bigint[]> {
|
||||||
|
const ids: bigint[] = [rootId];
|
||||||
|
let frontier: bigint[] = [rootId];
|
||||||
|
while (frontier.length > 0) {
|
||||||
|
const children = await this.prisma.category.findMany({
|
||||||
|
where: { parentCategoryId: { in: frontier } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (children.length === 0) break;
|
||||||
|
const childIds = children.map((c) => c.id);
|
||||||
|
ids.push(...childIds);
|
||||||
|
frontier = childIds;
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureOriginGood(id: number) {
|
||||||
|
const og = await this.prisma.originGood.findUnique({
|
||||||
|
where: { id: BigInt(id) },
|
||||||
|
});
|
||||||
|
if (!og) throw new BadRequestException(`Origin good ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureCountry(id: number) {
|
||||||
|
const c = await this.prisma.country.findUnique({ where: { id: BigInt(id) } });
|
||||||
|
if (!c) throw new BadRequestException(`Country ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureCategory(id: number) {
|
||||||
|
const c = await this.prisma.category.findUnique({ where: { id: BigInt(id) } });
|
||||||
|
if (!c) throw new BadRequestException(`Category ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureReferences(dto: CreateGoodDto) {
|
||||||
|
await this.ensureOriginGood(dto.originGoodId);
|
||||||
|
await this.ensureCountry(dto.countryId);
|
||||||
|
await this.ensureCategory(dto.categoryId);
|
||||||
|
if (dto.tagId !== undefined) {
|
||||||
|
const t = await this.prisma.tag.findUnique({ where: { id: BigInt(dto.tagId) } });
|
||||||
|
if (!t) throw new BadRequestException(`Tag ${dto.tagId} not found`);
|
||||||
|
}
|
||||||
|
if (dto.positionId !== undefined) {
|
||||||
|
const p = await this.prisma.position.findUnique({ where: { id: BigInt(dto.positionId) } });
|
||||||
|
if (!p) throw new BadRequestException(`Position ${dto.positionId} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
|
import { json } from 'express';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||||
|
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule, { bodyParser: false });
|
||||||
|
|
||||||
|
// Replace Express's JSON parser with one that stringifies BigInt.
|
||||||
|
// Express's default `json()` throws "Do not know how to serialize a BigInt".
|
||||||
|
app.use(
|
||||||
|
json({
|
||||||
|
limit: '2mb',
|
||||||
|
reviver: (_key: string, value: unknown) => {
|
||||||
|
// If a numeric string would overflow the JS number range we keep
|
||||||
|
// it as a string; the JWT strategy / services expect bigints.
|
||||||
|
if (typeof value === 'string' && /^-?\d{16,}$/.test(value)) {
|
||||||
|
// Leave as string — services parse with BigInt().
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// CORS
|
||||||
|
app.enableCors({
|
||||||
|
origin: ['http://localhost:5173', 'http://localhost:3000'],
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global pipes
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
transform: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Global filters and interceptors
|
||||||
|
app.useGlobalFilters(new HttpExceptionFilter());
|
||||||
|
app.useGlobalInterceptors(new TransformInterceptor());
|
||||||
|
|
||||||
|
// Swagger
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle('InkReach Product Center API')
|
||||||
|
.setDescription('Backend API for InkReach Product Center')
|
||||||
|
.setVersion('1.0')
|
||||||
|
.addBearerAuth()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
|
SwaggerModule.setup('api/docs', app, document);
|
||||||
|
|
||||||
|
const port = process.env.PORT ?? 3001;
|
||||||
|
await app.listen(port);
|
||||||
|
console.log(`🚀 Application is running on: http://localhost:${port}`);
|
||||||
|
console.log(`📚 Swagger documentation: http://localhost:${port}/api/docs`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make JSON.stringify aware of BigInt so outgoing responses containing
|
||||||
|
// primary keys (`BigInt` columns) don't blow up. BigInts are serialized
|
||||||
|
// as their decimal string — clients should parse them with BigInt().
|
||||||
|
(BigInt.prototype as unknown as { toJSON: () => string }).toJSON = function () {
|
||||||
|
return this.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class QueryOriginGoodDto {
|
||||||
|
@ApiProperty({ required: false, default: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page: number = 1;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, default: 20 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(200)
|
||||||
|
pageSize: number = 20;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, description: 'Search by goodName' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
keyword?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { OriginGoodsService } from './origin-goods.service';
|
||||||
|
import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
|
||||||
|
|
||||||
|
@ApiTags('origin-goods')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('origin-goods')
|
||||||
|
export class OriginGoodsController {
|
||||||
|
constructor(private readonly service: OriginGoodsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Paginated list of origin goods (read-only)' })
|
||||||
|
findAll(@Query() query: QueryOriginGoodDto) {
|
||||||
|
return this.service.findAll(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { OriginGoodsController } from './origin-goods.controller';
|
||||||
|
import { OriginGoodsService } from './origin-goods.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [OriginGoodsController],
|
||||||
|
providers: [OriginGoodsService],
|
||||||
|
})
|
||||||
|
export class OriginGoodsModule {}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { OriginGoodsService } from './origin-goods.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
describe('OriginGoodsService', () => {
|
||||||
|
let service: OriginGoodsService;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
const stamp = Date.now();
|
||||||
|
const createdSds: string[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
providers: [OriginGoodsService, PrismaService],
|
||||||
|
}).compile();
|
||||||
|
service = moduleRef.get(OriginGoodsService);
|
||||||
|
prisma = moduleRef.get(PrismaService);
|
||||||
|
await prisma.onModuleInit();
|
||||||
|
|
||||||
|
// Seed 25 rows with sequential goodNames so we can paginate/filter.
|
||||||
|
const rows = Array.from({ length: 25 }).map((_, i) => ({
|
||||||
|
sdsGoodId: `sds-${stamp}-${i}`,
|
||||||
|
goodName: `Origin Good ${stamp} ${i.toString().padStart(2, '0')}`,
|
||||||
|
sdsCategoryId: `cat-${stamp}-${i % 3}`,
|
||||||
|
}));
|
||||||
|
await prisma.originGood.createMany({ data: rows });
|
||||||
|
createdSds.push(...rows.map((r) => r.sdsGoodId));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (createdSds.length) {
|
||||||
|
await prisma.originGood.deleteMany({
|
||||||
|
where: { sdsGoodId: { in: createdSds } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await prisma.onModuleDestroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns paginated results', async () => {
|
||||||
|
const page1 = await service.findAll({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
keyword: `Origin Good ${stamp}`,
|
||||||
|
});
|
||||||
|
expect(page1.total).toBe(25);
|
||||||
|
expect(page1.items.length).toBe(10);
|
||||||
|
expect(page1.page).toBe(1);
|
||||||
|
expect(page1.pageSize).toBe(10);
|
||||||
|
|
||||||
|
const page3 = await service.findAll({
|
||||||
|
page: 3,
|
||||||
|
pageSize: 10,
|
||||||
|
keyword: `Origin Good ${stamp}`,
|
||||||
|
});
|
||||||
|
expect(page3.items.length).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('searches by keyword (case insensitive)', async () => {
|
||||||
|
const result = await service.findAll({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 5,
|
||||||
|
keyword: `origin good ${stamp} 05`,
|
||||||
|
});
|
||||||
|
expect(result.items.length).toBe(1);
|
||||||
|
expect(result.items[0].goodName).toContain('05');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty page when no matches', async () => {
|
||||||
|
const result = await service.findAll({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 5,
|
||||||
|
keyword: 'definitely-does-not-exist',
|
||||||
|
});
|
||||||
|
expect(result.total).toBe(0);
|
||||||
|
expect(result.items.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
|
||||||
|
|
||||||
|
export interface PaginatedOriginGoods {
|
||||||
|
items: Array<{
|
||||||
|
id: string;
|
||||||
|
sdsGoodId: string;
|
||||||
|
goodName: string | null;
|
||||||
|
goodImage: string | null;
|
||||||
|
goodPrice: string | null;
|
||||||
|
sdsCategoryId: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}>;
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OriginGoodsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async findAll(query: QueryOriginGoodDto): Promise<PaginatedOriginGoods> {
|
||||||
|
const { page, pageSize, keyword } = query;
|
||||||
|
const where: Prisma.OriginGoodWhereInput = keyword
|
||||||
|
? { goodName: { contains: keyword, mode: 'insensitive' } }
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const [total, rows] = await this.prisma.$transaction([
|
||||||
|
this.prisma.originGood.count({ where }),
|
||||||
|
this.prisma.originGood.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: rows.map((r) => ({
|
||||||
|
id: r.id.toString(),
|
||||||
|
sdsGoodId: r.sdsGoodId,
|
||||||
|
goodName: r.goodName,
|
||||||
|
goodImage: r.goodImage,
|
||||||
|
goodPrice: r.goodPrice === null || r.goodPrice === undefined ? null : r.goodPrice.toString(),
|
||||||
|
sdsCategoryId: r.sdsCategoryId,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
updatedAt: r.updatedAt.toISOString(),
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreatePositionDto {
|
||||||
|
@ApiProperty({ description: 'Sort order / weight' })
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
indexVal!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
countryId?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
categoryId?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdatePositionDto {
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
indexVal?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
countryId?: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
categoryId?: number | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOperation,
|
||||||
|
ApiQuery,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { PositionsService } from './positions.service';
|
||||||
|
import { CreatePositionDto } from './dto/create-position.dto';
|
||||||
|
import { UpdatePositionDto } from './dto/update-position.dto';
|
||||||
|
|
||||||
|
@ApiTags('positions')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('positions')
|
||||||
|
export class PositionsController {
|
||||||
|
constructor(private readonly service: PositionsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List positions (optionally filtered)' })
|
||||||
|
@ApiQuery({ name: 'countryId', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'categoryId', required: false, type: Number })
|
||||||
|
findAll(
|
||||||
|
@Query('countryId') countryId?: string,
|
||||||
|
@Query('categoryId') categoryId?: string,
|
||||||
|
) {
|
||||||
|
return this.service.findAll({
|
||||||
|
countryId: countryId ? BigInt(countryId) : undefined,
|
||||||
|
categoryId: categoryId ? BigInt(categoryId) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get one position' })
|
||||||
|
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||||
|
return this.service.findOne(BigInt(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Create a position' })
|
||||||
|
create(@Body() dto: CreatePositionDto) {
|
||||||
|
return this.service.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@ApiOperation({ summary: 'Update a position' })
|
||||||
|
update(
|
||||||
|
@Param('id', ParseIntPipe) id: string,
|
||||||
|
@Body() dto: UpdatePositionDto,
|
||||||
|
) {
|
||||||
|
return this.service.update(BigInt(id), dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiOperation({ summary: 'Delete a position' })
|
||||||
|
remove(@Param('id', ParseIntPipe) id: string) {
|
||||||
|
return this.service.remove(BigInt(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PositionsController } from './positions.controller';
|
||||||
|
import { PositionsService } from './positions.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [PositionsController],
|
||||||
|
providers: [PositionsService],
|
||||||
|
exports: [PositionsService],
|
||||||
|
})
|
||||||
|
export class PositionsModule {}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { NotFoundException } from '@nestjs/common';
|
||||||
|
import { PositionsService } from './positions.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
describe('PositionsService', () => {
|
||||||
|
let service: PositionsService;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
let countryId: bigint;
|
||||||
|
let categoryId: bigint;
|
||||||
|
const createdIds: bigint[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
providers: [PositionsService, PrismaService],
|
||||||
|
}).compile();
|
||||||
|
service = moduleRef.get(PositionsService);
|
||||||
|
prisma = moduleRef.get(PrismaService);
|
||||||
|
await prisma.onModuleInit();
|
||||||
|
|
||||||
|
const stamp = Date.now();
|
||||||
|
const c = await prisma.country.create({
|
||||||
|
data: { countryName: `Pos Country ${stamp}` },
|
||||||
|
});
|
||||||
|
countryId = c.id;
|
||||||
|
const cat = await prisma.category.create({
|
||||||
|
data: { categoryName: `Pos Cat ${stamp}` },
|
||||||
|
});
|
||||||
|
categoryId = cat.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (createdIds.length) {
|
||||||
|
await prisma.position.deleteMany({ where: { id: { in: createdIds } } });
|
||||||
|
}
|
||||||
|
await prisma.country.delete({ where: { id: countryId } });
|
||||||
|
await prisma.category.delete({ where: { id: categoryId } });
|
||||||
|
await prisma.onModuleDestroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a position linked to country + category and returns joined names', async () => {
|
||||||
|
const created = await service.create({
|
||||||
|
indexVal: 1,
|
||||||
|
countryId: Number(countryId),
|
||||||
|
categoryId: Number(categoryId),
|
||||||
|
});
|
||||||
|
createdIds.push(created.id);
|
||||||
|
|
||||||
|
expect(created.country?.countryName).toBeTruthy();
|
||||||
|
expect(created.category?.categoryName).toBeTruthy();
|
||||||
|
|
||||||
|
const fetched = await service.findOne(created.id);
|
||||||
|
expect(fetched.country?.id).toBe(countryId);
|
||||||
|
expect(fetched.category?.id).toBe(categoryId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by countryId and categoryId', async () => {
|
||||||
|
const list = await service.findAll({
|
||||||
|
countryId,
|
||||||
|
categoryId,
|
||||||
|
});
|
||||||
|
expect(list.length).toBeGreaterThan(0);
|
||||||
|
expect(list.every((p) => p.countryId === countryId)).toBe(true);
|
||||||
|
expect(list.every((p) => p.categoryId === categoryId)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates indexVal and disconnects country', async () => {
|
||||||
|
const created = await service.create({
|
||||||
|
indexVal: 5,
|
||||||
|
countryId: Number(countryId),
|
||||||
|
});
|
||||||
|
createdIds.push(created.id);
|
||||||
|
const updated = await service.update(created.id, {
|
||||||
|
indexVal: 9,
|
||||||
|
countryId: null,
|
||||||
|
});
|
||||||
|
expect(updated.indexVal).toBe(9);
|
||||||
|
expect(updated.countryId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundException for unknown country during create', async () => {
|
||||||
|
await expect(
|
||||||
|
service.create({ indexVal: 1, countryId: 99999999 }),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes a position', async () => {
|
||||||
|
const created = await service.create({ indexVal: 7 });
|
||||||
|
await service.remove(created.id);
|
||||||
|
const list = await service.findAll();
|
||||||
|
expect(list.find((p) => p.id === created.id)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CreatePositionDto } from './dto/create-position.dto';
|
||||||
|
import { UpdatePositionDto } from './dto/update-position.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PositionsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
findAll(filters?: { countryId?: bigint; categoryId?: bigint }) {
|
||||||
|
const where: { countryId?: bigint; categoryId?: bigint } = {};
|
||||||
|
if (filters?.countryId !== undefined) where.countryId = filters.countryId;
|
||||||
|
if (filters?.categoryId !== undefined) where.categoryId = filters.categoryId;
|
||||||
|
return this.prisma.position.findMany({
|
||||||
|
where,
|
||||||
|
include: { country: true, category: true },
|
||||||
|
orderBy: { indexVal: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: bigint) {
|
||||||
|
const p = await this.prisma.position.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { country: true, category: true },
|
||||||
|
});
|
||||||
|
if (!p) throw new NotFoundException(`Position ${id} not found`);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreatePositionDto) {
|
||||||
|
if (dto.countryId !== undefined) {
|
||||||
|
await this.ensureCountry(dto.countryId);
|
||||||
|
}
|
||||||
|
if (dto.categoryId !== undefined) {
|
||||||
|
await this.ensureCategory(dto.categoryId);
|
||||||
|
}
|
||||||
|
return this.prisma.position.create({
|
||||||
|
data: {
|
||||||
|
indexVal: dto.indexVal,
|
||||||
|
countryId: dto.countryId === undefined ? null : BigInt(dto.countryId),
|
||||||
|
categoryId: dto.categoryId === undefined ? null : BigInt(dto.categoryId),
|
||||||
|
},
|
||||||
|
include: { country: true, category: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: bigint, dto: UpdatePositionDto) {
|
||||||
|
await this.findOne(id);
|
||||||
|
if (dto.countryId !== undefined && dto.countryId !== null) {
|
||||||
|
await this.ensureCountry(dto.countryId);
|
||||||
|
}
|
||||||
|
if (dto.categoryId !== undefined && dto.categoryId !== null) {
|
||||||
|
await this.ensureCategory(dto.categoryId);
|
||||||
|
}
|
||||||
|
return this.prisma.position.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
indexVal: dto.indexVal,
|
||||||
|
country:
|
||||||
|
dto.countryId === undefined
|
||||||
|
? undefined
|
||||||
|
: dto.countryId === null
|
||||||
|
? { disconnect: true }
|
||||||
|
: { connect: { id: BigInt(dto.countryId) } },
|
||||||
|
category:
|
||||||
|
dto.categoryId === undefined
|
||||||
|
? undefined
|
||||||
|
: dto.categoryId === null
|
||||||
|
? { disconnect: true }
|
||||||
|
: { connect: { id: BigInt(dto.categoryId) } },
|
||||||
|
},
|
||||||
|
include: { country: true, category: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: bigint) {
|
||||||
|
await this.findOne(id);
|
||||||
|
return this.prisma.position.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureCountry(id: number) {
|
||||||
|
const c = await this.prisma.country.findUnique({ where: { id: BigInt(id) } });
|
||||||
|
if (!c) throw new NotFoundException(`Country ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureCategory(id: number) {
|
||||||
|
const c = await this.prisma.category.findUnique({ where: { id: BigInt(id) } });
|
||||||
|
if (!c) throw new NotFoundException(`Category ${id} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user