Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bebb9e4b2 | ||
|
|
ac29a0c7bf | ||
|
|
ac3a39f40d | ||
|
|
491e74b905 | ||
|
|
0aa6cbf1d9 | ||
|
|
697b82a64b | ||
|
|
f06dfffbda | ||
|
|
d9ecd04747 | ||
|
|
9c279ae393 | ||
|
|
848eed0b6f | ||
|
|
043d2463a6 | ||
|
|
005ab5b585 | ||
|
|
6c61a4e871 | ||
|
|
be0b90e68f | ||
|
|
755b40aded | ||
|
|
9c1106586a | ||
|
|
9ed569f5bc | ||
|
|
b04623ebdd | ||
|
|
a4151607c5 | ||
|
|
4cc99f3f23 | ||
|
|
8b38d6fef7 |
@@ -1,46 +1,10 @@
|
|||||||
# Dependencies
|
node_modules/
|
||||||
node_modules
|
dist/
|
||||||
|
.turbo/
|
||||||
# Build outputs
|
|
||||||
dist
|
|
||||||
.output
|
|
||||||
.nuxt
|
|
||||||
.nitro
|
|
||||||
.data
|
|
||||||
.cache
|
|
||||||
|
|
||||||
# TypeScript
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
# Environment
|
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.*
|
||||||
.env.development
|
# Deployment runtime data (certs, ACME state, data dumps)
|
||||||
.env.*.local
|
deploy/certbot/
|
||||||
|
deploy/data-dump.json
|
||||||
# OS
|
uploads/
|
||||||
.DS_Store
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.idea
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/settings.json
|
|
||||||
!.vscode/extensions.json
|
|
||||||
|
|
||||||
# Coverage
|
|
||||||
coverage
|
|
||||||
|
|
||||||
# Nuxt
|
|
||||||
.output
|
|
||||||
.nuxt
|
|
||||||
.nitro
|
|
||||||
.cache
|
|
||||||
|
|||||||
@@ -1,91 +1,91 @@
|
|||||||
# AGENT.md - AI Agent 开发规范
|
# AGENT.md - AI Agent 开发规范
|
||||||
|
|
||||||
## 开发流程
|
## 开发流程
|
||||||
|
|
||||||
### 1. 梳理需求
|
### 1. 梳理需求
|
||||||
|
|
||||||
使用 `brainstorming` 进行头脑风暴, 文档存放与命名规则如下:
|
使用 `brainstorming` 进行头脑风暴, 文档存放与命名规则如下:
|
||||||
|
|
||||||
| 需求类型 | 计划目录 | 命名规则 | 示例 |
|
| 需求类型 | 计划目录 | 命名规则 | 示例 |
|
||||||
| -------- | ----------------- | --------------------- | ----------------------- |
|
| -------- | ----------------- | --------------------- | ----------------------- |
|
||||||
| 新功能 | `plans/feature/` | `xxx-xxx-feature.md` | `smart-view-feature.md` |
|
| 新功能 | `plans/feature/` | `xxx-xxx-feature.md` | `smart-view-feature.md` |
|
||||||
| 修 Bug | `plans/fix/` | `xxx-xxx-fix.md` | `token-refresh-fix.md` |
|
| 修 Bug | `plans/fix/` | `xxx-xxx-fix.md` | `token-refresh-fix.md` |
|
||||||
| 重构 | `plans/refactor/` | `xxx-xxx-refactor.md` | `api-split-refactor.md` |
|
| 重构 | `plans/refactor/` | `xxx-xxx-refactor.md` | `api-split-refactor.md` |
|
||||||
|
|
||||||
使用 writing-plans 技能制定实施计划
|
使用 writing-plans 技能制定实施计划
|
||||||
|
|
||||||
**开发新功能时必须评估权限需求**:每个新功能/新操作都需要考虑是否需要纳入权限控制。具体评估方式见下方「权限系统」章节。
|
**开发新功能时必须评估权限需求**:每个新功能/新操作都需要考虑是否需要纳入权限控制。具体评估方式见下方「权限系统」章节。
|
||||||
|
|
||||||
可参考的开发文档位于 `docs/dev/*`
|
可参考的开发文档位于 `docs/dev/*`
|
||||||
|
|
||||||
### 2. 建立开发分支
|
### 2. 建立开发分支
|
||||||
|
|
||||||
确定好需求之后,就使用 `enterprise-git-spec` 技能来建立分支。
|
确定好需求之后,就使用 `enterprise-git-spec` 技能来建立分支。
|
||||||
|
|
||||||
### 3. 按照需求开发
|
### 3. 按照需求开发
|
||||||
|
|
||||||
按照当前功能的计划文档的任务项及其任务细节来逐步实现功能, 全程严格地遵循技能 test-driven-development 进行 TDD 开发流程, 并遵循 executing-plans 技能来执行开发计划。
|
按照当前功能的计划文档的任务项及其任务细节来逐步实现功能, 全程严格地遵循技能 test-driven-development 进行 TDD 开发流程, 并遵循 executing-plans 技能来执行开发计划。
|
||||||
|
|
||||||
**重要原则:所有测试必须通过**
|
**重要原则:所有测试必须通过**
|
||||||
|
|
||||||
- 开发过程中,必须确保所有测试用例都通过,包括新功能的测试和现有功能的测试
|
- 开发过程中,必须确保所有测试用例都通过,包括新功能的测试和现有功能的测试
|
||||||
- 如果发现现有测试失败,必须立即修复,确保新功能不会破坏旧功能
|
- 如果发现现有测试失败,必须立即修复,确保新功能不会破坏旧功能
|
||||||
- 只有在所有测试都通过的情况下,才能认为开发完成
|
- 只有在所有测试都通过的情况下,才能认为开发完成
|
||||||
|
|
||||||
所有任务完成后请遵循 verification-before-completion 技能完成验证
|
所有任务完成后请遵循 verification-before-completion 技能完成验证
|
||||||
|
|
||||||
### 4. 合并到开发分支
|
### 4. 合并到开发分支
|
||||||
|
|
||||||
所有测试通过后,使用 `enterprise-git-spec` 技能提交分支,然后合并回 `develop` 分支。
|
所有测试通过后,使用 `enterprise-git-spec` 技能提交分支,然后合并回 `develop` 分支。
|
||||||
|
|
||||||
### 5. 沉淀开发经验
|
### 5. 沉淀开发经验
|
||||||
|
|
||||||
在实现功能过程中,将适用于任何项目的编码好想法、好思想、注意点等有助于项目推进的内容,及时追加到 `AGENT.md` 中。这些经验是跨功能、跨项目的通用知识,帮助后续开发少走弯路。
|
在实现功能过程中,将适用于任何项目的编码好想法、好思想、注意点等有助于项目推进的内容,及时追加到 `AGENT.md` 中。这些经验是跨功能、跨项目的通用知识,帮助后续开发少走弯路。
|
||||||
|
|
||||||
### 6. 更新项目结构文档
|
### 6. 更新项目结构文档
|
||||||
|
|
||||||
每次功能开发完成后,更新 `docs/references/structs.md` 文件。该文件记录整个项目的目录结构和每个模块的简要功能描述,便于每次迭代功能时快速理解整个项目全貌。
|
每次功能开发完成后,更新 `docs/references/structs.md` 文件。该文件记录整个项目的目录结构和每个模块的简要功能描述,便于每次迭代功能时快速理解整个项目全貌。
|
||||||
|
|
||||||
### 7. 更新使用文档
|
### 7. 更新使用文档
|
||||||
|
|
||||||
使用文档分为:
|
使用文档分为:
|
||||||
|
|
||||||
1. `README.md` - 面向人类使用者,帮助其快速上手项目。仅在新功能开发完成后更新。内容保持简洁,聚焦于让用户快速跑通基础流程。
|
1. `README.md` - 面向人类使用者,帮助其快速上手项目。仅在新功能开发完成后更新。内容保持简洁,聚焦于让用户快速跑通基础流程。
|
||||||
2. `docs/references/*` - 面向人类使用者,提供项目的全面使用指南,支持深度探索模块与命令行细节。每次新功能开发完成或已有功能调整后必须更新。示例要丰富,帮助使用者理解各项功能的具体用法。
|
2. `docs/references/*` - 面向人类使用者,提供项目的全面使用指南,支持深度探索模块与命令行细节。每次新功能开发完成或已有功能调整后必须更新。示例要丰富,帮助使用者理解各项功能的具体用法。
|
||||||
3. `skills/[项目名]/SKILL.md` - 面向 LLM / AI Agent,指导大模型如何使用本项目。
|
3. `skills/[项目名]/SKILL.md` - 面向 LLM / AI Agent,指导大模型如何使用本项目。
|
||||||
|
|
||||||
**快速开始文档**每次新功能开发完成后才更新,具体更新 `README.md` 文件。确保文档包含新增功能的使用示例、命令说明和注意事项,帮助用户快速上手。
|
**快速开始文档**每次新功能开发完成后才更新,具体更新 `README.md` 文件。确保文档包含新增功能的使用示例、命令说明和注意事项,帮助用户快速上手。
|
||||||
|
|
||||||
**详细使用文档**位于 `docs/references/*`,目录文件为 `docs/references/index.md`,请按照功能划分参考文档,并填充详细的使用说明,帮助用户深度探索模块/命令行的使用。每次新功能或旧功能调整了请更新最新的使用方法,例子应该丰富一些,便于使用者理解功能使用。
|
**详细使用文档**位于 `docs/references/*`,目录文件为 `docs/references/index.md`,请按照功能划分参考文档,并填充详细的使用说明,帮助用户深度探索模块/命令行的使用。每次新功能或旧功能调整了请更新最新的使用方法,例子应该丰富一些,便于使用者理解功能使用。
|
||||||
|
|
||||||
**AGENT使用技能文档**位于 `skills/[项目名]/*`,入口文件为 `skills/[项目名]/SKILL.md`,更新时机和要求与详细使用文档一致。
|
**AGENT使用技能文档**位于 `skills/[项目名]/*`,入口文件为 `skills/[项目名]/SKILL.md`,更新时机和要求与详细使用文档一致。
|
||||||
|
|
||||||
## 开发规范
|
## 开发规范
|
||||||
|
|
||||||
### 代码优化
|
### 代码优化
|
||||||
|
|
||||||
遵循 improve-codebase-architecture
|
遵循 improve-codebase-architecture
|
||||||
|
|
||||||
### 类型规范
|
### 类型规范
|
||||||
|
|
||||||
参考技能 typescript-advanced-types
|
参考技能 typescript-advanced-types
|
||||||
|
|
||||||
### 测试规范
|
### 测试规范
|
||||||
|
|
||||||
参考技能 javascript-testing-patterns
|
参考技能 javascript-testing-patterns
|
||||||
|
|
||||||
### 测试
|
### 测试
|
||||||
|
|
||||||
每个任务目录应有对应的测试文件 `test/sync/tasks/<task-name>/handler.test.ts`。
|
每个任务目录应有对应的测试文件 `test/sync/tasks/<task-name>/handler.test.ts`。
|
||||||
|
|
||||||
## 数据库设计
|
## 数据库设计
|
||||||
|
|
||||||
PostgreSQL 最佳设计实践: postgresql-table-design 技能
|
PostgreSQL 最佳设计实践: postgresql-table-design 技能
|
||||||
PostgreSQL 最佳性能调优实践: supabase-postgres-best-practices 技能
|
PostgreSQL 最佳性能调优实践: supabase-postgres-best-practices 技能
|
||||||
Prisma Postgres 最佳实践: prisma-postgres 技能
|
Prisma Postgres 最佳实践: prisma-postgres 技能
|
||||||
|
|
||||||
### 后端规范
|
### 后端规范
|
||||||
|
|
||||||
参考 nestjs-best-practices 技能
|
参考 nestjs-best-practices 技能
|
||||||
|
|
||||||
> 你可以自己增加规范
|
> 你可以自己增加规范
|
||||||
|
|||||||
@@ -1,33 +1,33 @@
|
|||||||
# Product
|
# Product
|
||||||
|
|
||||||
## Register
|
## Register
|
||||||
|
|
||||||
brand
|
brand
|
||||||
|
|
||||||
## Users
|
## Users
|
||||||
|
|
||||||
跨境电商、POD 卖家和需要小批量定制履约能力的商家。他们通过官网了解 InkReach 的商品、生产、设计与全球配送能力,并进入 InkPOD 完成实际业务操作。
|
跨境电商、POD 卖家和需要小批量定制履约能力的商家。他们通过官网了解 InkReach 的商品、生产、设计与全球配送能力,并进入 InkPOD 完成实际业务操作。
|
||||||
|
|
||||||
## Product Purpose
|
## Product Purpose
|
||||||
|
|
||||||
官网用于清晰展示 InkReach 的一站式 POD 能力,让访客快速浏览货盘和案例,并以最短路径进入 InkPOD 登录及商品详情页面。
|
官网用于清晰展示 InkReach 的一站式 POD 能力,让访客快速浏览货盘和案例,并以最短路径进入 InkPOD 登录及商品详情页面。
|
||||||
|
|
||||||
## Brand Personality
|
## Brand Personality
|
||||||
|
|
||||||
直接、可靠、务实。页面应保留鲜明的橙色品牌识别,同时让商品和真实业务数据成为视觉重点。
|
直接、可靠、务实。页面应保留鲜明的橙色品牌识别,同时让商品和真实业务数据成为视觉重点。
|
||||||
|
|
||||||
## Anti-references
|
## Anti-references
|
||||||
|
|
||||||
避免与 Figma 设计稿无关的通用 SaaS 模板、纯装饰性渐变、过度卡片化、被拉伸模糊的位图,以及拥挤到妨碍浏览的桌面布局。
|
避免与 Figma 设计稿无关的通用 SaaS 模板、纯装饰性渐变、过度卡片化、被拉伸模糊的位图,以及拥挤到妨碍浏览的桌面布局。
|
||||||
|
|
||||||
## Design Principles
|
## Design Principles
|
||||||
|
|
||||||
- 商品与业务信息优先于装饰。
|
- 商品与业务信息优先于装饰。
|
||||||
- 关键操作即时、明确,并保持当前页面跳转语义一致。
|
- 关键操作即时、明确,并保持当前页面跳转语义一致。
|
||||||
- 宽屏增加呼吸空间,不通过无限放大素材填满画面。
|
- 宽屏增加呼吸空间,不通过无限放大素材填满画面。
|
||||||
- 动效用于展示连续内容,不影响用户主动操作。
|
- 动效用于展示连续内容,不影响用户主动操作。
|
||||||
- 后台配置是商品、国家、品类和标签的唯一数据来源。
|
- 后台配置是商品、国家、品类和标签的唯一数据来源。
|
||||||
|
|
||||||
## Accessibility & Inclusion
|
## Accessibility & Inclusion
|
||||||
|
|
||||||
以 WCAG 2.1 AA 为基准,维持键盘可操作性、足够对比度,并为持续动画提供 `prefers-reduced-motion` 降级。
|
以 WCAG 2.1 AA 为基准,维持键盘可操作性、足够对比度,并为持续动画提供 `prefers-reduced-motion` 降级。
|
||||||
|
|||||||
@@ -1,126 +1,126 @@
|
|||||||
# Inkreach
|
# Inkreach
|
||||||
|
|
||||||
pnpm workspaces + Turborepo monorepo,包含三个协同工作的子项目和共享包:
|
pnpm workspaces + Turborepo monorepo,包含三个协同工作的子项目和共享包:
|
||||||
|
|
||||||
| 子项目 | 包名 | 技术栈 | 端口 | 角色 |
|
| 子项目 | 包名 | 技术栈 | 端口 | 角色 |
|
||||||
|--------|------|--------|------|------|
|
|--------|------|--------|------|------|
|
||||||
| `apps/api` | `@inkreach/api` | NestJS 10 + Prisma 5 + PostgreSQL | 3001 | 后端 API(鉴权 / CRUD / SDS 同步 / 公开 API / Swagger) |
|
| `apps/api` | `@inkreach/api` | NestJS 10 + Prisma 5 + PostgreSQL | 3001 | 后端 API(鉴权 / CRUD / SDS 同步 / 公开 API / Swagger) |
|
||||||
| `apps/admin` | `@inkreach/admin` | Vue 3 + Vite + Element Plus + Pinia | 5173 | 后台管理(登录 / 商品 / 品类 / 国家 / 标签 / 坑位 / 同步) |
|
| `apps/admin` | `@inkreach/admin` | Vue 3 + Vite + Element Plus + Pinia | 5173 | 后台管理(登录 / 商品 / 品类 / 国家 / 标签 / 坑位 / 同步) |
|
||||||
| `apps/website` | `@inkreach/website` | Nuxt 4 + Vue 3 + Tailwind v4 | 3000 | 官方展示站 + 产品中心页 |
|
| `apps/website` | `@inkreach/website` | Nuxt 4 + Vue 3 + Tailwind v4 | 3000 | 官方展示站 + 产品中心页 |
|
||||||
|
|
||||||
| 共享包 | 说明 |
|
| 共享包 | 说明 |
|
||||||
|--------|------|
|
|--------|------|
|
||||||
| `packages/tsconfig` | 共享 TypeScript 配置(base / api / vue) |
|
| `packages/tsconfig` | 共享 TypeScript 配置(base / api / vue) |
|
||||||
| `packages/shared-types` | 共享类型定义(PaginatedResult, BackendEnvelope, 实体接口) |
|
| `packages/shared-types` | 共享类型定义(PaginatedResult, BackendEnvelope, 实体接口) |
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 1. 安装依赖
|
### 1. 安装依赖
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 环境变量
|
### 2. 环境变量
|
||||||
|
|
||||||
在仓库根目录的 `.env` 中配置数据库:
|
在仓库根目录的 `.env` 中配置数据库:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
DATABASE_URL=postgresql://postgres:<password>@localhost:5432/inkreach-official
|
DATABASE_URL=postgresql://postgres:<password>@localhost:5432/inkreach-official
|
||||||
```
|
```
|
||||||
|
|
||||||
后端额外需要的变量(在 `apps/api/.env` 中):
|
后端额外需要的变量(在 `apps/api/.env` 中):
|
||||||
|
|
||||||
```env
|
```env
|
||||||
JWT_SECRET=<your-secret>
|
JWT_SECRET=<your-secret>
|
||||||
SDS_API_BASE=https://api.sds.example.com
|
SDS_API_BASE=https://api.sds.example.com
|
||||||
SDS_API_KEY=<your-key>
|
SDS_API_KEY=<your-key>
|
||||||
PORT=3001
|
PORT=3001
|
||||||
```
|
```
|
||||||
|
|
||||||
官网通过 `apps/website/.env` 的 `NUXT_PUBLIC_BACKEND_URL=http://localhost:3001` 指向后端。
|
官网通过 `apps/website/.env` 的 `NUXT_PUBLIC_BACKEND_URL=http://localhost:3001` 指向后端。
|
||||||
|
|
||||||
后台无需额外环境变量;Vite 代理已把 `/api/*` 转给 `http://localhost:3001`。
|
后台无需额外环境变量;Vite 代理已把 `/api/*` 转给 `http://localhost:3001`。
|
||||||
|
|
||||||
### 3. 部署
|
### 3. 部署
|
||||||
|
|
||||||
启动顺序:先启动后端,再启动另两个。
|
启动顺序:先启动后端,再启动另两个。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Terminal 1:后端(需要 PostgreSQL)
|
# Terminal 1:后端(需要 PostgreSQL)
|
||||||
cd apps/api
|
cd apps/api
|
||||||
pnpm prisma:generate
|
pnpm prisma:generate
|
||||||
pnpm prisma:migrate
|
pnpm prisma:migrate
|
||||||
pnpm --filter @inkreach/api dev
|
pnpm --filter @inkreach/api dev
|
||||||
# → http://localhost:3001 · Swagger: http://localhost:3001/api/docs
|
# → http://localhost:3001 · Swagger: http://localhost:3001/api/docs
|
||||||
|
|
||||||
# Terminal 2:官网
|
# Terminal 2:官网
|
||||||
pnpm --filter @inkreach/website dev
|
pnpm --filter @inkreach/website dev
|
||||||
# → http://localhost:3000
|
# → http://localhost:3000
|
||||||
|
|
||||||
# Terminal 3:后台
|
# Terminal 3:后台
|
||||||
pnpm --filter @inkreach/admin dev
|
pnpm --filter @inkreach/admin dev
|
||||||
# → http://localhost:5173
|
# → http://localhost:5173
|
||||||
```
|
```
|
||||||
|
|
||||||
或者使用 Turborepo 同时启动所有开发服务器:
|
或者使用 Turborepo 同时启动所有开发服务器:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm dev # turbo run dev
|
pnpm dev # turbo run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## 端口分配
|
## 端口分配
|
||||||
|
|
||||||
| 端口 | 项目 | 入口 |
|
| 端口 | 项目 | 入口 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 3001 | NestJS 后端 | `apps/api/src/main.ts` |
|
| 3001 | NestJS 后端 | `apps/api/src/main.ts` |
|
||||||
| 5173 | Vue Admin | `apps/admin/vite.config.ts` |
|
| 5173 | Vue Admin | `apps/admin/vite.config.ts` |
|
||||||
| 3000 | Nuxt 官网 | `apps/website/nuxt.config.ts` |
|
| 3000 | Nuxt 官网 | `apps/website/nuxt.config.ts` |
|
||||||
|
|
||||||
## 数据流概览
|
## 数据流概览
|
||||||
|
|
||||||
```
|
```
|
||||||
[SDS 外部 API]
|
[SDS 外部 API]
|
||||||
│ (同步)
|
│ (同步)
|
||||||
▼
|
▼
|
||||||
[NestJS 后端 :3001] ── public/goods ──► [Nitro 代理 /api/backend/*] ──► [Nuxt 官网 :3000]
|
[NestJS 后端 :3001] ── public/goods ──► [Nitro 代理 /api/backend/*] ──► [Nuxt 官网 :3000]
|
||||||
│ ▲
|
│ ▲
|
||||||
├── /auth /goods /categories ... ──► │ Vite proxy /api/*
|
├── /auth /goods /categories ... ──► │ Vite proxy /api/*
|
||||||
│ │
|
│ │
|
||||||
[PostgreSQL] [Vue Admin :5173]
|
[PostgreSQL] [Vue Admin :5173]
|
||||||
```
|
```
|
||||||
|
|
||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 根目录(使用 pnpm workspace filter)
|
# 根目录(使用 pnpm workspace filter)
|
||||||
pnpm install # 安装所有依赖
|
pnpm install # 安装所有依赖
|
||||||
pnpm dev # 启动所有 dev 服务器 (turbo)
|
pnpm dev # 启动所有 dev 服务器 (turbo)
|
||||||
pnpm build # 构建所有项目 (turbo)
|
pnpm build # 构建所有项目 (turbo)
|
||||||
pnpm format # 格式化所有文件 (prettier)
|
pnpm format # 格式化所有文件 (prettier)
|
||||||
|
|
||||||
# 后端
|
# 后端
|
||||||
pnpm --filter @inkreach/api start:dev # 开发
|
pnpm --filter @inkreach/api start:dev # 开发
|
||||||
pnpm --filter @inkreach/api build # 编译
|
pnpm --filter @inkreach/api build # 编译
|
||||||
pnpm --filter @inkreach/api test # 单元测试
|
pnpm --filter @inkreach/api test # 单元测试
|
||||||
pnpm --filter @inkreach/api prisma:generate # 生成 Prisma Client
|
pnpm --filter @inkreach/api prisma:generate # 生成 Prisma Client
|
||||||
pnpm --filter @inkreach/api prisma:migrate # 运行迁移
|
pnpm --filter @inkreach/api prisma:migrate # 运行迁移
|
||||||
pnpm --filter @inkreach/api prisma:studio # 打开 Prisma Studio
|
pnpm --filter @inkreach/api prisma:studio # 打开 Prisma Studio
|
||||||
|
|
||||||
# 官网
|
# 官网
|
||||||
pnpm --filter @inkreach/website dev # 开发
|
pnpm --filter @inkreach/website dev # 开发
|
||||||
pnpm --filter @inkreach/website build # 构建
|
pnpm --filter @inkreach/website build # 构建
|
||||||
pnpm --filter @inkreach/website generate # 静态站点生成
|
pnpm --filter @inkreach/website generate # 静态站点生成
|
||||||
|
|
||||||
# 后台
|
# 后台
|
||||||
pnpm --filter @inkreach/admin dev # 开发
|
pnpm --filter @inkreach/admin dev # 开发
|
||||||
pnpm --filter @inkreach/admin build # 构建
|
pnpm --filter @inkreach/admin build # 构建
|
||||||
```
|
```
|
||||||
|
|
||||||
## 文档
|
## 文档
|
||||||
|
|
||||||
- 整体目录结构:[`docs/references/structs.md`](docs/references/structs.md)
|
- 整体目录结构:[`docs/references/structs.md`](docs/references/structs.md)
|
||||||
- 数据库表设计:[`docs/dev/database-table-design.md`](docs/dev/database-table-design.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)
|
- 产品中心 PRD:[`docs/dev/product-center-prd.md`](docs/dev/product-center-prd.md)
|
||||||
- 官网子项目结构:[`apps/website/docs/references/structs.md`](apps/website/docs/references/structs.md)
|
- 官网子项目结构:[`apps/website/docs/references/structs.md`](apps/website/docs/references/structs.md)
|
||||||
- 开发规范:[`AGENTS.md`](AGENTS.md)
|
- 开发规范:[`AGENTS.md`](AGENTS.md)
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
.idea
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.suo
|
*.suo
|
||||||
*.ntvs*
|
*.ntvs*
|
||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"recommendations": ["Vue.volar"]
|
"recommendations": ["Vue.volar"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Vue 3 + TypeScript + Vite
|
# 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.
|
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).
|
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).
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>inkreach-official-admin</title>
|
<title>inkreach-official-admin</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.2",
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
"unplugin-auto-import": "^21.0.0",
|
"unplugin-auto-import": "^21.0.0",
|
||||||
"unplugin-vue-components": "^32.1.0",
|
"unplugin-vue-components": "^32.1.0",
|
||||||
"vite": "^8.0.12",
|
"vite": "^8.0.12",
|
||||||
|
"vitest": "^4.1.10",
|
||||||
"vue-tsc": "^3.2.8"
|
"vue-tsc": "^3.2.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg">
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
<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>
|
<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>
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
</symbol>
|
</symbol>
|
||||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
<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"/>
|
<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>
|
||||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
<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="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="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"/>
|
<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>
|
||||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
<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"/>
|
<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>
|
||||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
<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="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"/>
|
<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>
|
||||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
<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"/>
|
<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>
|
</symbol>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
@@ -1,32 +1,32 @@
|
|||||||
<svg preserveAspectRatio="xMidYMid meet" width="97" height="56" viewBox="0 0 97 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg preserveAspectRatio="xMidYMid meet" width="97" height="56" viewBox="0 0 97 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<g id="Frame" clip-path="url(#clip0_0_30)">
|
<g id="Frame" clip-path="url(#clip0_0_30)">
|
||||||
<g id="Group">
|
<g id="Group">
|
||||||
<path id="Vector" d="M16.318 51.2332V53.8323C17.2443 53.6294 18.0909 53.4188 18.8525 53.1979L19.0969 54.0481C17.9416 54.3665 16.7117 54.6542 15.4072 54.9085L15.0341 54.1354C15.2683 54.0044 15.3866 53.8169 15.3866 53.5678V47.6478C16.632 47.4526 17.7486 47.1957 18.7341 46.8747L19.3105 47.617C18.4614 47.8918 17.463 48.1383 16.3155 48.3592V50.342H19.0171V51.2306H16.318V51.2332ZM21.4666 54.6131L21.2119 53.7142L22.0842 53.7347C22.3389 53.7347 22.465 53.5935 22.465 53.3135V48.2513H20.5378V55.9307H19.5986V47.373H23.4067V53.5087C23.4067 54.2458 23.0671 54.6131 22.3878 54.6131H21.4666Z" fill="var(--fill-0, #FF6902)"/>
|
<path id="Vector" d="M16.318 51.2332V53.8323C17.2443 53.6294 18.0909 53.4188 18.8525 53.1979L19.0969 54.0481C17.9416 54.3665 16.7117 54.6542 15.4072 54.9085L15.0341 54.1354C15.2683 54.0044 15.3866 53.8169 15.3866 53.5678V47.6478C16.632 47.4526 17.7486 47.1957 18.7341 46.8747L19.3105 47.617C18.4614 47.8918 17.463 48.1383 16.3155 48.3592V50.342H19.0171V51.2306H16.318V51.2332ZM21.4666 54.6131L21.2119 53.7142L22.0842 53.7347C22.3389 53.7347 22.465 53.5935 22.465 53.3135V48.2513H20.5378V55.9307H19.5986V47.373H23.4067V53.5087C23.4067 54.2458 23.0671 54.6131 22.3878 54.6131H21.4666Z" fill="var(--fill-0, #FF6902)"/>
|
||||||
<path id="Vector_2" d="M34.4941 52.1013C34.4606 52.2965 34.4221 52.4788 34.3757 52.6483H38.0474V53.5267H34.8183C35.4847 54.2895 36.7275 54.8057 38.5466 55.0805L38.0577 55.9589C35.9633 55.548 34.5893 54.8032 33.9358 53.7219C33.7325 54.0737 33.4855 54.3794 33.1922 54.6414C32.5463 55.209 31.4142 55.6584 29.7958 55.9897L29.415 55.0805C30.8173 54.834 31.8233 54.477 32.4305 54.0147C32.6209 53.858 32.783 53.6962 32.9194 53.5267H29.8755V52.6483H33.398C33.4572 52.4583 33.5086 52.2451 33.5549 52.0037L34.4941 52.1013ZM29.9733 47.8224H32.078C31.9468 47.5219 31.8079 47.2548 31.6561 47.0211L32.6338 46.8644C32.7522 47.111 32.8808 47.432 33.0249 47.8224H34.9135C35.0627 47.4962 35.1785 47.1675 35.2557 46.8362L36.254 46.962C36.1691 47.2548 36.0508 47.5425 35.9015 47.8224H37.9368V48.6623H34.4272V49.4045H37.4222V50.2058H34.4272V50.9584H38.2636V51.8188H29.6491V50.9584H33.4855V50.2058H30.5188V49.4045H33.4855V48.6623H29.9707V47.8224H29.9733Z" fill="var(--fill-0, #FF6902)"/>
|
<path id="Vector_2" d="M34.4941 52.1013C34.4606 52.2965 34.4221 52.4788 34.3757 52.6483H38.0474V53.5267H34.8183C35.4847 54.2895 36.7275 54.8057 38.5466 55.0805L38.0577 55.9589C35.9633 55.548 34.5893 54.8032 33.9358 53.7219C33.7325 54.0737 33.4855 54.3794 33.1922 54.6414C32.5463 55.209 31.4142 55.6584 29.7958 55.9897L29.415 55.0805C30.8173 54.834 31.8233 54.477 32.4305 54.0147C32.6209 53.858 32.783 53.6962 32.9194 53.5267H29.8755V52.6483H33.398C33.4572 52.4583 33.5086 52.2451 33.5549 52.0037L34.4941 52.1013ZM29.9733 47.8224H32.078C31.9468 47.5219 31.8079 47.2548 31.6561 47.0211L32.6338 46.8644C32.7522 47.111 32.8808 47.432 33.0249 47.8224H34.9135C35.0627 47.4962 35.1785 47.1675 35.2557 46.8362L36.254 46.962C36.1691 47.2548 36.0508 47.5425 35.9015 47.8224H37.9368V48.6623H34.4272V49.4045H37.4222V50.2058H34.4272V50.9584H38.2636V51.8188H29.6491V50.9584H33.4855V50.2058H30.5188V49.4045H33.4855V48.6623H29.9707V47.8224H29.9733Z" fill="var(--fill-0, #FF6902)"/>
|
||||||
<path id="Vector_3" d="M50.8993 55.756C50.5597 55.756 49.9525 55.7483 49.0776 55.7355C48.419 55.7278 47.876 55.6738 47.4541 55.5685C47.0372 55.4504 46.677 55.2244 46.3786 54.8853C46.2422 54.7235 46.111 54.6414 45.9875 54.6414C45.779 54.6414 45.4188 55.0934 44.9119 56L44.2275 55.3656C44.7241 54.5592 45.1692 54.0506 45.5681 53.8426V51.097H44.2378V50.1981H46.4609V53.9094C46.5123 53.9479 46.5612 53.9967 46.6076 54.0558C46.8623 54.3152 47.1093 54.5078 47.3512 54.6311C47.6522 54.7672 48.1076 54.8468 48.7226 54.8648C49.4919 54.8776 50.1763 54.8853 50.7784 54.8853L52.1395 54.8751C52.5898 54.8545 52.9449 54.8365 53.2073 54.816L52.9835 55.7534H50.8993V55.756ZM45.0174 47.0006C45.6838 47.5091 46.2473 48.0125 46.7105 48.5159L46.0261 49.199C45.635 48.7111 45.0869 48.182 44.3819 47.617L45.0174 47.0006ZM47.2096 49.0039H49.2938C49.3452 48.5338 49.3761 48.0561 49.3813 47.5682V46.9132H50.3101V47.3627C50.3101 47.9354 50.2818 48.4825 50.2226 49.0039H52.5718V49.9028H50.3899C50.7167 51.1073 51.5426 52.3221 52.8651 53.5472L52.2193 54.2304C51.0511 53.0644 50.2689 51.9189 49.8701 50.7914C49.4199 52.3221 48.6274 53.4676 47.4927 54.2304L46.924 53.4676C48.0664 52.7382 48.8101 51.5491 49.1548 49.9028H47.2071V49.0039H47.2096Z" fill="var(--fill-0, #FF6902)"/>
|
<path id="Vector_3" d="M50.8993 55.756C50.5597 55.756 49.9525 55.7483 49.0776 55.7355C48.419 55.7278 47.876 55.6738 47.4541 55.5685C47.0372 55.4504 46.677 55.2244 46.3786 54.8853C46.2422 54.7235 46.111 54.6414 45.9875 54.6414C45.779 54.6414 45.4188 55.0934 44.9119 56L44.2275 55.3656C44.7241 54.5592 45.1692 54.0506 45.5681 53.8426V51.097H44.2378V50.1981H46.4609V53.9094C46.5123 53.9479 46.5612 53.9967 46.6076 54.0558C46.8623 54.3152 47.1093 54.5078 47.3512 54.6311C47.6522 54.7672 48.1076 54.8468 48.7226 54.8648C49.4919 54.8776 50.1763 54.8853 50.7784 54.8853L52.1395 54.8751C52.5898 54.8545 52.9449 54.8365 53.2073 54.816L52.9835 55.7534H50.8993V55.756ZM45.0174 47.0006C45.6838 47.5091 46.2473 48.0125 46.7105 48.5159L46.0261 49.199C45.635 48.7111 45.0869 48.182 44.3819 47.617L45.0174 47.0006ZM47.2096 49.0039H49.2938C49.3452 48.5338 49.3761 48.0561 49.3813 47.5682V46.9132H50.3101V47.3627C50.3101 47.9354 50.2818 48.4825 50.2226 49.0039H52.5718V49.9028H50.3899C50.7167 51.1073 51.5426 52.3221 52.8651 53.5472L52.2193 54.2304C51.0511 53.0644 50.2689 51.9189 49.8701 50.7914C49.4199 52.3221 48.6274 53.4676 47.4927 54.2304L46.924 53.4676C48.0664 52.7382 48.8101 51.5491 49.1548 49.9028H47.2071V49.0039H47.2096Z" fill="var(--fill-0, #FF6902)"/>
|
||||||
<path id="Vector_4" d="M63.6535 46.8953C64.8808 48.1589 66.2908 49.1528 67.8809 49.8745L67.4203 50.7041C66.9186 50.4627 66.4606 50.2135 66.0489 49.9516V50.8299H63.7873V52.2965H66.5378V53.1954H63.7873V54.739H67.4281V55.6481H59.215V54.739H62.8455V53.1954H60.0847V52.2965H62.8455V50.8299H60.5839V49.9516C60.167 50.2058 59.6962 50.4627 59.1738 50.7246L58.7236 49.913C60.4269 49.0989 61.8446 48.0921 62.9819 46.8953H63.6535ZM66.0309 49.9413C65.012 49.3172 64.1089 48.5801 63.319 47.7325C62.5882 48.5339 61.6851 49.271 60.607 49.9413H66.0309Z" fill="var(--fill-0, #FF6902)"/>
|
<path id="Vector_4" d="M63.6535 46.8953C64.8808 48.1589 66.2908 49.1528 67.8809 49.8745L67.4203 50.7041C66.9186 50.4627 66.4606 50.2135 66.0489 49.9516V50.8299H63.7873V52.2965H66.5378V53.1954H63.7873V54.739H67.4281V55.6481H59.215V54.739H62.8455V53.1954H60.0847V52.2965H62.8455V50.8299H60.5839V49.9516C60.167 50.2058 59.6962 50.4627 59.1738 50.7246L58.7236 49.913C60.4269 49.0989 61.8446 48.0921 62.9819 46.8953H63.6535ZM66.0309 49.9413C65.012 49.3172 64.1089 48.5801 63.319 47.7325C62.5882 48.5339 61.6851 49.271 60.607 49.9413H66.0309Z" fill="var(--fill-0, #FF6902)"/>
|
||||||
<path id="Vector_5" d="M73.6859 50.4319H74.7229V48.3618H73.5779V47.4911H76.7401V48.3618H75.6646V50.4319H76.5368V51.3102H75.6646V53.031C76.0505 52.8615 76.4082 52.6946 76.7401 52.5328V53.4317C75.8061 53.894 74.7769 54.2972 73.6473 54.6439L73.4132 53.7245C73.9355 53.6063 74.3729 53.4933 74.7254 53.3829V51.3128H73.6885V50.4319H73.6859ZM76.8276 48.5159H78.9812V46.9235H79.8715V48.5159H81.2017C80.9341 48.2102 80.5893 47.8815 80.1648 47.5296L80.6922 47.0211C81.2017 47.4064 81.616 47.7762 81.935 48.1358L81.5645 48.5159H82.1126V49.4045H79.8715V50.2752C80.0027 50.6861 80.1519 51.0688 80.3217 51.4284C80.7077 50.9789 81.0628 50.4832 81.3895 49.9439L82.0842 50.4627C81.634 51.1664 81.1811 51.7494 80.7231 52.2117C81.2069 53.0464 81.7986 53.7758 82.5036 54.3999L81.8861 55.1319C81.037 54.2972 80.3655 53.3212 79.8689 52.2014V54.9085C79.8689 55.5274 79.5498 55.8356 78.9092 55.8356H77.8851L77.6895 54.9573C78.0549 55.0035 78.3585 55.0266 78.6004 55.0266C78.8551 55.0266 78.9812 54.9136 78.9812 54.685V52.6714C78.3817 53.5113 77.687 54.2381 76.8971 54.8494L76.4082 54.0481C77.3679 53.4163 78.2247 52.543 78.9812 51.4309V49.4097H76.8276V48.5159ZM77.512 50.0209C77.9546 50.6733 78.2942 51.228 78.5309 51.6929L77.8851 52.1424C77.6098 51.6338 77.2496 51.0765 76.7993 50.4729L77.512 50.0209Z" fill="var(--fill-0, #FF6902)"/>
|
<path id="Vector_5" d="M73.6859 50.4319H74.7229V48.3618H73.5779V47.4911H76.7401V48.3618H75.6646V50.4319H76.5368V51.3102H75.6646V53.031C76.0505 52.8615 76.4082 52.6946 76.7401 52.5328V53.4317C75.8061 53.894 74.7769 54.2972 73.6473 54.6439L73.4132 53.7245C73.9355 53.6063 74.3729 53.4933 74.7254 53.3829V51.3128H73.6885V50.4319H73.6859ZM76.8276 48.5159H78.9812V46.9235H79.8715V48.5159H81.2017C80.9341 48.2102 80.5893 47.8815 80.1648 47.5296L80.6922 47.0211C81.2017 47.4064 81.616 47.7762 81.935 48.1358L81.5645 48.5159H82.1126V49.4045H79.8715V50.2752C80.0027 50.6861 80.1519 51.0688 80.3217 51.4284C80.7077 50.9789 81.0628 50.4832 81.3895 49.9439L82.0842 50.4627C81.634 51.1664 81.1811 51.7494 80.7231 52.2117C81.2069 53.0464 81.7986 53.7758 82.5036 54.3999L81.8861 55.1319C81.037 54.2972 80.3655 53.3212 79.8689 52.2014V54.9085C79.8689 55.5274 79.5498 55.8356 78.9092 55.8356H77.8851L77.6895 54.9573C78.0549 55.0035 78.3585 55.0266 78.6004 55.0266C78.8551 55.0266 78.9812 54.9136 78.9812 54.685V52.6714C78.3817 53.5113 77.687 54.2381 76.8971 54.8494L76.4082 54.0481C77.3679 53.4163 78.2247 52.543 78.9812 51.4309V49.4097H76.8276V48.5159ZM77.512 50.0209C77.9546 50.6733 78.2942 51.228 78.5309 51.6929L77.8851 52.1424C77.6098 51.6338 77.2496 51.0765 76.7993 50.4729L77.512 50.0209Z" fill="var(--fill-0, #FF6902)"/>
|
||||||
</g>
|
</g>
|
||||||
<path id="Vector_6" d="M11.1801 50.889H0.165039V51.4386H11.1801V50.889Z" fill="var(--fill-0, #FF6902)"/>
|
<path id="Vector_6" d="M11.1801 50.889H0.165039V51.4386H11.1801V50.889Z" fill="var(--fill-0, #FF6902)"/>
|
||||||
<path id="Vector_7" d="M97 50.889H85.7508V51.5671H97V50.889Z" fill="var(--fill-0, #FF6902)"/>
|
<path id="Vector_7" d="M97 50.889H85.7508V51.5671H97V50.889Z" fill="var(--fill-0, #FF6902)"/>
|
||||||
<g id="Group_2">
|
<g id="Group_2">
|
||||||
<path id="Vector_8" d="M64.2864 27.2064C55.1033 35.3686 44.6466 38.6072 40.9337 34.4414C39.4748 32.8054 39.2741 30.273 40.1207 27.304C37.5091 32.1016 36.9481 36.4293 39.0863 38.8281C42.8017 42.994 53.2585 39.7527 62.439 31.5931C68.0173 26.6362 71.8408 21.0039 73.1504 16.4117C71.2232 19.9508 68.1845 23.7443 64.2864 27.2064Z" fill="var(--fill-0, #FF6800)"/>
|
<path id="Vector_8" d="M64.2864 27.2064C55.1033 35.3686 44.6466 38.6072 40.9337 34.4414C39.4748 32.8054 39.2741 30.273 40.1207 27.304C37.5091 32.1016 36.9481 36.4293 39.0863 38.8281C42.8017 42.994 53.2585 39.7527 62.439 31.5931C68.0173 26.6362 71.8408 21.0039 73.1504 16.4117C71.2232 19.9508 68.1845 23.7443 64.2864 27.2064Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
<path id="Vector_9" d="M3.07218 12.536H0.810499C0.362795 12.536 0 12.8982 0 13.3451V15.6026C0 16.0495 0.362795 16.4117 0.810499 16.4117H3.07218C3.51988 16.4117 3.88268 16.0495 3.88268 15.6026V13.3451C3.88268 12.8982 3.51988 12.536 3.07218 12.536Z" fill="var(--fill-0, #FF6800)"/>
|
<path id="Vector_9" d="M3.07218 12.536H0.810499C0.362795 12.536 0 12.8982 0 13.3451V15.6026C0 16.0495 0.362795 16.4117 0.810499 16.4117H3.07218C3.51988 16.4117 3.88268 16.0495 3.88268 15.6026V13.3451C3.88268 12.8982 3.51988 12.536 3.07218 12.536Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
<path id="Vector_10" d="M3.07218 17.6804H0.810499C0.362795 17.6804 0 18.0451 0 18.492V30.1702C0 30.6171 0.362795 30.9793 0.810499 30.9793H3.07218C3.51988 30.9793 3.88268 30.6171 3.88268 30.1702V18.492C3.88268 18.0451 3.51988 17.6804 3.07218 17.6804Z" fill="var(--fill-0, #FF6800)"/>
|
<path id="Vector_10" d="M3.07218 17.6804H0.810499C0.362795 17.6804 0 18.0451 0 18.492V30.1702C0 30.6171 0.362795 30.9793 0.810499 30.9793H3.07218C3.51988 30.9793 3.88268 30.6171 3.88268 30.1702V18.492C3.88268 18.0451 3.51988 17.6804 3.07218 17.6804Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
<path id="Vector_11" d="M11.6889 17.6907C10.9711 17.701 10.2841 17.8397 9.64853 18.0811C9.50701 17.8422 9.24714 17.6804 8.94867 17.6804H6.5146C6.0669 17.6804 5.7041 18.0426 5.7041 18.4895V23.847V23.9806V30.2113C5.7041 30.6582 6.0669 31.0204 6.5146 31.0204H8.94867C9.39637 31.0204 9.75917 30.6582 9.75917 30.2113V23.9831V23.7571C9.75917 22.6193 10.7086 21.6973 11.8587 21.741C12.9523 21.7821 13.8039 22.7144 13.8039 23.8085V30.2447C13.8039 30.6916 14.1667 31.0538 14.6144 31.0538H17.0485C17.4962 31.0538 17.859 30.6916 17.859 30.2447V23.7571C17.8616 20.3797 15.0853 17.6393 11.6889 17.6907Z" fill="var(--fill-0, #FF6800)"/>
|
<path id="Vector_11" d="M11.6889 17.6907C10.9711 17.701 10.2841 17.8397 9.64853 18.0811C9.50701 17.8422 9.24714 17.6804 8.94867 17.6804H6.5146C6.0669 17.6804 5.7041 18.0426 5.7041 18.4895V23.847V23.9806V30.2113C5.7041 30.6582 6.0669 31.0204 6.5146 31.0204H8.94867C9.39637 31.0204 9.75917 30.6582 9.75917 30.2113V23.9831V23.7571C9.75917 22.6193 10.7086 21.6973 11.8587 21.741C12.9523 21.7821 13.8039 22.7144 13.8039 23.8085V30.2447C13.8039 30.6916 14.1667 31.0538 14.6144 31.0538H17.0485C17.4962 31.0538 17.859 30.6916 17.859 30.2447V23.7571C17.8616 20.3797 15.0853 17.6393 11.6889 17.6907Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
<path id="Vector_12" d="M90.8296 17.6907C90.1606 17.701 89.5199 17.8217 88.9204 18.0349V10.6303C88.9204 10.1835 88.5576 9.82132 88.1099 9.82132H85.6758C85.2281 9.82132 84.8653 10.1835 84.8653 10.6303V22.907C84.8653 22.9969 84.873 23.0842 84.8833 23.169C84.8576 23.3924 84.8447 23.6184 84.8447 23.847V30.2113C84.8447 30.6582 85.2075 31.0204 85.6552 31.0204H88.0893C88.537 31.0204 88.8998 30.6582 88.8998 30.2113V23.7571C88.8998 22.6193 89.8492 21.6973 90.9994 21.741C92.0929 21.7821 92.9446 22.7144 92.9446 23.8085V30.2447C92.9446 30.6916 93.3074 31.0538 93.7551 31.0538H96.1891C96.6368 31.0538 96.9996 30.6916 96.9996 30.2447V23.7571C96.9996 20.3797 94.2234 17.6393 90.8296 17.6907Z" fill="var(--fill-0, #FF6800)"/>
|
<path id="Vector_12" d="M90.8296 17.6907C90.1606 17.701 89.5199 17.8217 88.9204 18.0349V10.6303C88.9204 10.1835 88.5576 9.82132 88.1099 9.82132H85.6758C85.2281 9.82132 84.8653 10.1835 84.8653 10.6303V22.907C84.8653 22.9969 84.873 23.0842 84.8833 23.169C84.8576 23.3924 84.8447 23.6184 84.8447 23.847V30.2113C84.8447 30.6582 85.2075 31.0204 85.6552 31.0204H88.0893C88.537 31.0204 88.8998 30.6582 88.8998 30.2113V23.7571C88.8998 22.6193 89.8492 21.6973 90.9994 21.741C92.0929 21.7821 92.9446 22.7144 92.9446 23.8085V30.2447C92.9446 30.6916 93.3074 31.0538 93.7551 31.0538H96.1891C96.6368 31.0538 96.9996 30.6916 96.9996 30.2447V23.7571C96.9996 20.3797 94.2234 17.6393 90.8296 17.6907Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
<path id="Vector_13" d="M31.0412 17.7909H28.6072C28.1595 17.7909 27.7967 18.153 27.7967 18.5999V19.4089C27.7967 20.4748 26.9656 21.3506 25.9184 21.4276C25.8386 21.4251 25.7563 21.4251 25.6765 21.4251C25.6559 21.4251 25.6379 21.4276 25.6173 21.4276C25.0898 21.3891 24.5238 21.1477 23.9886 20.6314C23.8316 20.4799 23.7416 20.2693 23.7416 20.051V10.6303C23.7416 10.1835 23.3788 9.82132 22.9311 9.82132H20.497C20.0493 9.82132 19.6865 10.1835 19.6865 10.6303V18.5999V21.985V27.5865V30.3269C19.6865 30.7738 20.0493 31.1359 20.497 31.1359H22.9311C23.3788 31.1359 23.7416 30.7738 23.7416 30.3269V30.2653V27.494C23.7416 26.4307 24.5701 25.5549 25.6147 25.4779C25.7125 25.4805 25.8103 25.4805 25.9081 25.4779C26.9759 25.5524 27.7967 26.4693 27.7967 27.5454V30.3295C27.7967 30.7764 28.1595 31.1385 28.6072 31.1385H31.0412C31.4889 31.1385 31.8517 30.7764 31.8517 30.3295V27.4966C31.8517 25.9453 31.2651 24.5302 30.3028 23.454C31.2651 22.3805 31.8517 20.9628 31.8517 19.4089V18.5999C31.8517 18.153 31.4889 17.7909 31.0412 17.7909Z" fill="var(--fill-0, #FF6800)"/>
|
<path id="Vector_13" d="M31.0412 17.7909H28.6072C28.1595 17.7909 27.7967 18.153 27.7967 18.5999V19.4089C27.7967 20.4748 26.9656 21.3506 25.9184 21.4276C25.8386 21.4251 25.7563 21.4251 25.6765 21.4251C25.6559 21.4251 25.6379 21.4276 25.6173 21.4276C25.0898 21.3891 24.5238 21.1477 23.9886 20.6314C23.8316 20.4799 23.7416 20.2693 23.7416 20.051V10.6303C23.7416 10.1835 23.3788 9.82132 22.9311 9.82132H20.497C20.0493 9.82132 19.6865 10.1835 19.6865 10.6303V18.5999V21.985V27.5865V30.3269C19.6865 30.7738 20.0493 31.1359 20.497 31.1359H22.9311C23.3788 31.1359 23.7416 30.7738 23.7416 30.3269V30.2653V27.494C23.7416 26.4307 24.5701 25.5549 25.6147 25.4779C25.7125 25.4805 25.8103 25.4805 25.9081 25.4779C26.9759 25.5524 27.7967 26.4693 27.7967 27.5454V30.3295C27.7967 30.7764 28.1595 31.1385 28.6072 31.1385H31.0412C31.4889 31.1385 31.8517 30.7764 31.8517 30.3295V27.4966C31.8517 25.9453 31.2651 24.5302 30.3028 23.454C31.2651 22.3805 31.8517 20.9628 31.8517 19.4089V18.5999C31.8517 18.153 31.4889 17.7909 31.0412 17.7909Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
<path id="Vector_14" d="M39.8324 21.7744C39.8761 21.7769 39.9173 21.7821 39.9585 21.7846C40.7355 20.6546 41.6052 19.5271 42.5598 18.4124C41.6927 17.9604 40.7046 17.7087 39.66 17.7267C38.9807 17.7369 38.3323 17.8628 37.7277 18.0785C37.6633 17.8628 37.4678 17.7061 37.2311 17.7061H34.5062C34.0585 17.7061 33.6957 18.0682 33.6957 18.5151V23.4181C33.6855 23.5722 33.6777 23.7263 33.6777 23.883V30.2473C33.6777 30.4759 33.7729 30.6839 33.9273 30.8303C34.074 30.9818 34.2798 31.0769 34.5088 31.0769H36.9429C37.3906 31.0769 37.7534 30.7147 37.7534 30.2678V23.5285C37.8846 22.514 38.7723 21.7358 39.8324 21.7744Z" fill="var(--fill-0, #FF6800)"/>
|
<path id="Vector_14" d="M39.8324 21.7744C39.8761 21.7769 39.9173 21.7821 39.9585 21.7846C40.7355 20.6546 41.6052 19.5271 42.5598 18.4124C41.6927 17.9604 40.7046 17.7087 39.66 17.7267C38.9807 17.7369 38.3323 17.8628 37.7277 18.0785C37.6633 17.8628 37.4678 17.7061 37.2311 17.7061H34.5062C34.0585 17.7061 33.6957 18.0682 33.6957 18.5151V23.4181C33.6855 23.5722 33.6777 23.7263 33.6777 23.883V30.2473C33.6777 30.4759 33.7729 30.6839 33.9273 30.8303C34.074 30.9818 34.2798 31.0769 34.5088 31.0769H36.9429C37.3906 31.0769 37.7534 30.7147 37.7534 30.2678V23.5285C37.8846 22.514 38.7723 21.7358 39.8324 21.7744Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
<path id="Vector_15" d="M41.5516 25.4805C41.7291 26.5746 42.1948 27.6199 42.8844 28.4854H42.8818C45.3493 31.6239 50.2304 31.891 53.4003 29.1943C53.5701 29.0504 53.5881 28.7936 53.444 28.6241L51.5709 26.4256C51.432 26.2612 51.1875 26.2381 51.0151 26.3665C49.6257 27.4016 48.1925 27.643 46.7362 26.9008L51.3316 25.0079L54.6611 23.6338C54.8695 23.5491 54.9673 23.3102 54.8798 23.1048C54.5299 22.2675 54.0847 21.1246 53.6087 20.5056C52.3685 18.7617 50.3461 17.6907 48.1488 17.6933H48.1514C44.0628 17.6624 40.8466 21.4662 41.5516 25.4805ZM45.3262 23.9215C45.4909 21.7384 48.262 20.5698 49.9164 22.0312C48.738 22.5166 46.5021 23.4386 45.3262 23.9241V23.9215Z" fill="var(--fill-0, #FF6800)"/>
|
<path id="Vector_15" d="M41.5516 25.4805C41.7291 26.5746 42.1948 27.6199 42.8844 28.4854H42.8818C45.3493 31.6239 50.2304 31.891 53.4003 29.1943C53.5701 29.0504 53.5881 28.7936 53.444 28.6241L51.5709 26.4256C51.432 26.2612 51.1875 26.2381 51.0151 26.3665C49.6257 27.4016 48.1925 27.643 46.7362 26.9008L51.3316 25.0079L54.6611 23.6338C54.8695 23.5491 54.9673 23.3102 54.8798 23.1048C54.5299 22.2675 54.0847 21.1246 53.6087 20.5056C52.3685 18.7617 50.3461 17.6907 48.1488 17.6933H48.1514C44.0628 17.6624 40.8466 21.4662 41.5516 25.4805ZM45.3262 23.9215C45.4909 21.7384 48.262 20.5698 49.9164 22.0312C48.738 22.5166 46.5021 23.4386 45.3262 23.9241V23.9215Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
<path id="Vector_16" d="M77.6686 21.6125C78.5975 21.2299 79.6396 21.3352 80.4604 21.8668C80.7768 22.0723 81.1988 22.0209 81.4664 21.7538L82.9433 20.2796C83.2932 19.9303 83.2495 19.355 82.8533 19.0571C81.7006 18.1864 80.2854 17.7087 78.8213 17.7112C75.7929 17.7112 73.1401 19.7428 72.3579 22.663C71.5732 25.5832 72.852 28.6652 75.4764 30.1779C77.8256 31.5315 80.7305 31.3234 82.8507 29.7285C83.2469 29.4306 83.2932 28.8553 82.9433 28.5034L81.4664 27.0292C81.1988 26.7621 80.7794 26.7107 80.4604 26.9162C79.6396 27.4478 78.5975 27.5531 77.6686 27.1704C76.5417 26.7056 75.8084 25.6089 75.8084 24.3915C75.8084 23.1767 76.5417 22.0774 77.6686 21.6125Z" fill="var(--fill-0, #FF6800)"/>
|
<path id="Vector_16" d="M77.6686 21.6125C78.5975 21.2299 79.6396 21.3352 80.4604 21.8668C80.7768 22.0723 81.1988 22.0209 81.4664 21.7538L82.9433 20.2796C83.2932 19.9303 83.2495 19.355 82.8533 19.0571C81.7006 18.1864 80.2854 17.7087 78.8213 17.7112C75.7929 17.7112 73.1401 19.7428 72.3579 22.663C71.5732 25.5832 72.852 28.6652 75.4764 30.1779C77.8256 31.5315 80.7305 31.3234 82.8507 29.7285C83.2469 29.4306 83.2932 28.8553 82.9433 28.5034L81.4664 27.0292C81.1988 26.7621 80.7794 26.7107 80.4604 26.9162C79.6396 27.4478 78.5975 27.5531 77.6686 27.1704C76.5417 26.7056 75.8084 25.6089 75.8084 24.3915C75.8084 23.1767 76.5417 22.0774 77.6686 21.6125Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
<path id="Vector_17" d="M69.361 19.617V18.1299C69.361 17.9064 69.1783 17.7241 68.9545 17.7241H65.9132C65.6893 17.7241 65.5067 17.9064 65.5067 18.1299V18.3225C64.5907 17.8936 63.564 17.665 62.4808 17.6933C58.9481 17.7909 56.0663 20.67 55.9788 24.1989C55.9145 26.7107 57.2421 28.9169 59.2465 30.1086C60.2757 29.3278 61.2921 28.488 62.2904 27.5942C62.4345 27.4632 62.5786 27.3322 62.7227 27.2012C62.7047 27.2012 62.6892 27.2038 62.6712 27.2038C60.9987 27.2038 59.6582 25.7527 59.8512 24.0448C59.9953 22.7452 61.0451 21.6973 62.347 21.5535C64.0581 21.3634 65.5118 22.699 65.5118 24.3684C65.5118 24.3889 65.5118 24.4095 65.5092 24.4326C66.9681 22.8556 68.2649 21.235 69.361 19.617Z" fill="var(--fill-0, #FF6800)"/>
|
<path id="Vector_17" d="M69.361 19.617V18.1299C69.361 17.9064 69.1783 17.7241 68.9545 17.7241H65.9132C65.6893 17.7241 65.5067 17.9064 65.5067 18.1299V18.3225C64.5907 17.8936 63.564 17.665 62.4808 17.6933C58.9481 17.7909 56.0663 20.67 55.9788 24.1989C55.9145 26.7107 57.2421 28.9169 59.2465 30.1086C60.2757 29.3278 61.2921 28.488 62.2904 27.5942C62.4345 27.4632 62.5786 27.3322 62.7227 27.2012C62.7047 27.2012 62.6892 27.2038 62.6712 27.2038C60.9987 27.2038 59.6582 25.7527 59.8512 24.0448C59.9953 22.7452 61.0451 21.6973 62.347 21.5535C64.0581 21.3634 65.5118 22.699 65.5118 24.3684C65.5118 24.3889 65.5118 24.4095 65.5092 24.4326C66.9681 22.8556 68.2649 21.235 69.361 19.617Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
<path id="Vector_18" d="M65.5089 30.3166V30.9536H68.9567C69.1806 30.9536 69.3633 30.7712 69.3633 30.5478V26.102C68.0356 27.6173 67.0836 28.9066 65.5089 30.3166Z" fill="var(--fill-0, #FF6800)"/>
|
<path id="Vector_18" d="M65.5089 30.3166V30.9536H68.9567C69.1806 30.9536 69.3633 30.7712 69.3633 30.5478V26.102C68.0356 27.6173 67.0836 28.9066 65.5089 30.3166Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
<path id="Vector_19" d="M48.9692 12.2458C57.8229 3.65217 68.4906 0.272244 72.7979 4.69492C74.6762 6.62374 75.0338 9.71602 74.1101 13.304C76.2791 8.46524 76.467 4.15814 74.159 1.78756C70.037 -2.44249 59.5185 1.09411 50.6674 9.6852C47.765 12.5027 45.4004 15.5153 43.6816 18.4381C45.1431 16.3372 46.9185 14.2389 48.9692 12.2458Z" fill="var(--fill-0, #FF6800)"/>
|
<path id="Vector_19" d="M48.9692 12.2458C57.8229 3.65217 68.4906 0.272244 72.7979 4.69492C74.6762 6.62374 75.0338 9.71602 74.1101 13.304C76.2791 8.46524 76.467 4.15814 74.159 1.78756C70.037 -2.44249 59.5185 1.09411 50.6674 9.6852C47.765 12.5027 45.4004 15.5153 43.6816 18.4381C45.1431 16.3372 46.9185 14.2389 48.9692 12.2458Z" fill="var(--fill-0, #FF6800)"/>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
<defs>
|
<defs>
|
||||||
<clipPath id="clip0_0_30">
|
<clipPath id="clip0_0_30">
|
||||||
<rect width="97" height="56" fill="white"/>
|
<rect width="97" height="56" fill="white"/>
|
||||||
</clipPath>
|
</clipPath>
|
||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
@@ -1,14 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// Root component renders router-managed views.
|
// Root component renders router-managed views.
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<router-view />
|
<router-view />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#app {
|
#app {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import request from './request'
|
import request from './request'
|
||||||
import type { LoginRequest, LoginResponse, User } from '@/types'
|
import type { LoginRequest, LoginResponse, User } from '@/types'
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
// Login
|
// Login
|
||||||
login: (data: LoginRequest) => {
|
login: (data: LoginRequest) => {
|
||||||
return request.post<any, LoginResponse>('/auth/login', data)
|
return request.post<any, LoginResponse>('/auth/login', data)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get current user
|
// Get current user
|
||||||
getCurrentUser: () => {
|
getCurrentUser: () => {
|
||||||
return request.get<any, User>('/auth/me')
|
return request.get<any, User>('/auth/me')
|
||||||
},
|
},
|
||||||
|
|
||||||
// Logout
|
// Logout
|
||||||
logout: () => {
|
logout: () => {
|
||||||
return request.post('/auth/logout')
|
return request.post('/auth/logout')
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1,41 +1,41 @@
|
|||||||
import request from './request'
|
import request from './request'
|
||||||
import type {
|
import type {
|
||||||
Category,
|
Category,
|
||||||
CategoryTree,
|
CategoryTree,
|
||||||
CreateCategoryRequest,
|
CreateCategoryRequest,
|
||||||
UpdateCategoryRequest,
|
UpdateCategoryRequest,
|
||||||
CategoryFilter,
|
CategoryFilter,
|
||||||
PaginatedResult,
|
PaginatedResult,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
export const categoriesApi = {
|
export const categoriesApi = {
|
||||||
// Get categories list
|
// Get categories list
|
||||||
getCategoriesList: (params: CategoryFilter) => {
|
getCategoriesList: (params: CategoryFilter) => {
|
||||||
return request.get<any, PaginatedResult<Category>>('/categories', { params })
|
return request.get<any, PaginatedResult<Category>>('/categories', { params })
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get category tree
|
// Get category tree
|
||||||
getCategoryTree: () => {
|
getCategoryTree: () => {
|
||||||
return request.get<any, CategoryTree[]>('/categories')
|
return request.get<any, CategoryTree[]>('/categories')
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get category by id
|
// Get category by id
|
||||||
getCategoryById: (id: string) => {
|
getCategoryById: (id: string) => {
|
||||||
return request.get<any, Category>(`/categories/${id}`)
|
return request.get<any, Category>(`/categories/${id}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Create category
|
// Create category
|
||||||
createCategory: (data: CreateCategoryRequest) => {
|
createCategory: (data: CreateCategoryRequest) => {
|
||||||
return request.post<any, Category>('/categories', data)
|
return request.post<any, Category>('/categories', data)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update category
|
// Update category
|
||||||
updateCategory: (id: string, data: UpdateCategoryRequest) => {
|
updateCategory: (id: string, data: UpdateCategoryRequest) => {
|
||||||
return request.patch<any, Category>(`/categories/${id}`, data)
|
return request.patch<any, Category>(`/categories/${id}`, data)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Delete category
|
// Delete category
|
||||||
deleteCategory: (id: string) => {
|
deleteCategory: (id: string) => {
|
||||||
return request.delete(`/categories/${id}`)
|
return request.delete(`/categories/${id}`)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1,35 +1,35 @@
|
|||||||
import request from './request'
|
import request from './request'
|
||||||
import type {
|
import type {
|
||||||
Country,
|
Country,
|
||||||
CreateCountryRequest,
|
CreateCountryRequest,
|
||||||
UpdateCountryRequest,
|
UpdateCountryRequest,
|
||||||
CountryFilter,
|
CountryFilter,
|
||||||
PaginatedResult,
|
PaginatedResult,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
export const countriesApi = {
|
export const countriesApi = {
|
||||||
// Get countries list
|
// Get countries list
|
||||||
getCountriesList: (params: CountryFilter) => {
|
getCountriesList: (params: CountryFilter) => {
|
||||||
return request.get<any, PaginatedResult<Country>>('/countries', { params })
|
return request.get<any, PaginatedResult<Country>>('/countries', { params })
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get country by id
|
// Get country by id
|
||||||
getCountryById: (id: string) => {
|
getCountryById: (id: string) => {
|
||||||
return request.get<any, Country>(`/countries/${id}`)
|
return request.get<any, Country>(`/countries/${id}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Create country
|
// Create country
|
||||||
createCountry: (data: CreateCountryRequest) => {
|
createCountry: (data: CreateCountryRequest) => {
|
||||||
return request.post<any, Country>('/countries', data)
|
return request.post<any, Country>('/countries', data)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update country
|
// Update country
|
||||||
updateCountry: (id: string, data: UpdateCountryRequest) => {
|
updateCountry: (id: string, data: UpdateCountryRequest) => {
|
||||||
return request.patch<any, Country>(`/countries/${id}`, data)
|
return request.patch<any, Country>(`/countries/${id}`, data)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Delete country
|
// Delete country
|
||||||
deleteCountry: (id: string) => {
|
deleteCountry: (id: string) => {
|
||||||
return request.delete(`/countries/${id}`)
|
return request.delete(`/countries/${id}`)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1,48 +1,58 @@
|
|||||||
import request from './request'
|
import request from './request'
|
||||||
import type {
|
import type {
|
||||||
Good,
|
Good,
|
||||||
GoodDetail,
|
GoodDetail,
|
||||||
CreateGoodRequest,
|
CreateGoodRequest,
|
||||||
UpdateGoodRequest,
|
UpdateGoodRequest,
|
||||||
BatchCreateGoodsRequest,
|
BatchCreateGoodsRequest,
|
||||||
BatchPriorityRequest,
|
BatchPriorityRequest,
|
||||||
GoodsFilter,
|
GoodsFilter,
|
||||||
PaginatedResult,
|
PaginatedResult,
|
||||||
} from '@/types'
|
CreateCustomGoodRequest,
|
||||||
|
UpdateCustomGoodContentRequest,
|
||||||
export const goodsApi = {
|
} from '@/types'
|
||||||
// Get goods list
|
|
||||||
getGoodsList: (params: GoodsFilter) => {
|
export const goodsApi = {
|
||||||
return request.get<any, PaginatedResult<Good>>('/goods', { params })
|
// Get goods list
|
||||||
},
|
getGoodsList: (params: GoodsFilter) => {
|
||||||
|
return request.get<any, PaginatedResult<Good>>('/goods', { params })
|
||||||
// Get good by id
|
},
|
||||||
getGoodById: (id: string) => {
|
|
||||||
|
// Get good by id
|
||||||
|
getGoodById: (id: string) => {
|
||||||
return request.get<any, GoodDetail>(`/goods/${id}`)
|
return request.get<any, GoodDetail>(`/goods/${id}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Create good
|
// Create good
|
||||||
createGood: (data: CreateGoodRequest) => {
|
createGood: (data: CreateGoodRequest) => {
|
||||||
return request.post<any, Good>('/goods', data)
|
return request.post<any, Good>('/goods', data)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update good
|
createCustomGood: (data: CreateCustomGoodRequest) => {
|
||||||
updateGood: (id: string, data: UpdateGoodRequest) => {
|
return request.post<any, GoodDetail>('/goods/custom', data)
|
||||||
return request.patch<any, Good>(`/goods/${id}`, data)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Delete good
|
updateCustomGoodContent: (id: string, data: UpdateCustomGoodContentRequest) => {
|
||||||
deleteGood: (id: string) => {
|
return request.patch<any, GoodDetail>(`/goods/${id}/custom-content`, data)
|
||||||
return request.delete(`/goods/${id}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
// Batch create goods
|
|
||||||
batchCreateGoods: (data: BatchCreateGoodsRequest) => {
|
|
||||||
return request.post<any, Good[]>('/goods/batch', data)
|
|
||||||
},
|
|
||||||
|
|
||||||
// Batch update priority
|
|
||||||
batchUpdatePriority: (data: BatchPriorityRequest) => {
|
|
||||||
return request.patch<any, { count: number }>('/goods/batch-priority', 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)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Batch update priority
|
||||||
|
batchUpdatePriority: (data: BatchPriorityRequest) => {
|
||||||
|
return request.patch<any, { count: number }>('/goods/batch-priority', data)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import request from './request'
|
import request from './request'
|
||||||
import type { OriginGood, OriginGoodsTreeResponse, PaginatedResult } from '@/types'
|
import type { OriginGood, OriginGoodsTreeResponse, PaginatedResult } from '@/types'
|
||||||
|
|
||||||
export const originGoodsApi = {
|
export const originGoodsApi = {
|
||||||
getTree: () => {
|
getTree: () => {
|
||||||
return request.get<any, OriginGoodsTreeResponse>('/origin-goods/tree')
|
return request.get<any, OriginGoodsTreeResponse>('/origin-goods/tree')
|
||||||
},
|
},
|
||||||
|
|
||||||
getOriginGoodsList: (params: { page?: number; pageSize?: number; keyword?: string }) => {
|
getOriginGoodsList: (params: { page?: number; pageSize?: number; keyword?: string }) => {
|
||||||
return request.get<any, PaginatedResult<OriginGood>>('/origin-goods', { params })
|
return request.get<any, PaginatedResult<OriginGood>>('/origin-goods', { params })
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
import request from './request'
|
import request from './request'
|
||||||
import type {
|
import type {
|
||||||
Position,
|
Position,
|
||||||
CreatePositionRequest,
|
CreatePositionRequest,
|
||||||
UpdatePositionRequest,
|
UpdatePositionRequest,
|
||||||
PositionFilter,
|
PositionFilter,
|
||||||
PaginatedResult,
|
PaginatedResult,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
export const positionsApi = {
|
export const positionsApi = {
|
||||||
// Get positions list
|
// Get positions list
|
||||||
getPositionsList: (params: PositionFilter) => {
|
getPositionsList: (params: PositionFilter) => {
|
||||||
return request.get<any, PaginatedResult<Position>>('/positions', { params })
|
return request.get<any, PaginatedResult<Position>>('/positions', { params })
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get position by id
|
// Get position by id
|
||||||
getPositionById: (id: string) => {
|
getPositionById: (id: string) => {
|
||||||
return request.get<any, Position>(`/positions/${id}`)
|
return request.get<any, Position>(`/positions/${id}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Create position
|
// Create position
|
||||||
createPosition: (data: CreatePositionRequest) => {
|
createPosition: (data: CreatePositionRequest) => {
|
||||||
return request.post<any, Position>('/positions', data)
|
return request.post<any, Position>('/positions', data)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update position
|
// Update position
|
||||||
updatePosition: (id: string, data: UpdatePositionRequest) => {
|
updatePosition: (id: string, data: UpdatePositionRequest) => {
|
||||||
return request.patch<any, Position>(`/positions/${id}`, data)
|
return request.patch<any, Position>(`/positions/${id}`, data)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Delete position
|
// Delete position
|
||||||
deletePosition: (id: string) => {
|
deletePosition: (id: string) => {
|
||||||
return request.delete(`/positions/${id}`)
|
return request.delete(`/positions/${id}`)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1,72 +1,81 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
|
import type { AxiosInstance, AxiosResponse, AxiosError, InternalAxiosRequestConfig } from 'axios'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
|
|
||||||
const request: AxiosInstance = axios.create({
|
const request: AxiosInstance = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_BASE || '/api',
|
baseURL: import.meta.env.VITE_API_BASE || '/api',
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
})
|
// Session tokens live in HttpOnly cookies — send them along.
|
||||||
|
withCredentials: true,
|
||||||
// Request interceptor
|
})
|
||||||
request.interceptors.request.use(
|
|
||||||
(config: AxiosRequestConfig) => {
|
// Response interceptor
|
||||||
const token = localStorage.getItem('token')
|
request.interceptors.response.use(
|
||||||
if (token && config.headers) {
|
(response: AxiosResponse) => {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
// Backend wraps everything in { data, success } — unwrap to data
|
||||||
}
|
const body = response.data
|
||||||
return config
|
if (body && typeof body === 'object' && 'success' in body && 'data' in body) {
|
||||||
},
|
return body.data
|
||||||
(error: AxiosError) => {
|
}
|
||||||
return Promise.reject(error)
|
return body
|
||||||
}
|
},
|
||||||
)
|
async (error: AxiosError) => {
|
||||||
|
// Access token expired: try the refresh cookie once, then retry the
|
||||||
// Response interceptor
|
// original request. A second 401 (refresh failed) logs the user out.
|
||||||
request.interceptors.response.use(
|
const config = error.config as (InternalAxiosRequestConfig & { _retried?: boolean }) | undefined
|
||||||
(response: AxiosResponse) => {
|
if (
|
||||||
// Backend wraps everything in { data, success } — unwrap to data
|
error.response?.status === 401 &&
|
||||||
const body = response.data
|
config &&
|
||||||
if (body && typeof body === 'object' && 'success' in body && 'data' in body) {
|
!config._retried &&
|
||||||
return body.data
|
!config.url?.includes('/auth/login') &&
|
||||||
}
|
!config.url?.includes('/auth/refresh')
|
||||||
return body
|
) {
|
||||||
},
|
config._retried = true
|
||||||
(error: AxiosError) => {
|
try {
|
||||||
if (error.response) {
|
await axios.post(
|
||||||
const { status, data } = error.response
|
`${import.meta.env.VITE_API_BASE || '/api'}/auth/refresh`,
|
||||||
|
{},
|
||||||
switch (status) {
|
{ withCredentials: true },
|
||||||
case 401:
|
)
|
||||||
ElMessage.error('Unauthorized, please login')
|
return request.request(config)
|
||||||
localStorage.removeItem('token')
|
} catch {
|
||||||
localStorage.removeItem('user')
|
// fall through to the 401 handling below
|
||||||
router.push('/login')
|
}
|
||||||
break
|
}
|
||||||
case 403:
|
|
||||||
ElMessage.error('Forbidden')
|
if (error.response) {
|
||||||
break
|
const { status, data } = error.response
|
||||||
case 404:
|
|
||||||
ElMessage.error('Resource not found')
|
switch (status) {
|
||||||
break
|
case 401:
|
||||||
case 500:
|
ElMessage.error('Unauthorized, please login')
|
||||||
ElMessage.error('Server error')
|
router.push('/login')
|
||||||
break
|
break
|
||||||
default:
|
case 403:
|
||||||
const errorMessage = (data as any)?.message || 'Request failed'
|
ElMessage.error('Forbidden')
|
||||||
ElMessage.error(errorMessage)
|
break
|
||||||
}
|
case 404:
|
||||||
} else if (error.request) {
|
ElMessage.error('Resource not found')
|
||||||
ElMessage.error('Network error')
|
break
|
||||||
} else {
|
case 500:
|
||||||
ElMessage.error('Request failed')
|
ElMessage.error('Server error')
|
||||||
}
|
break
|
||||||
|
default:
|
||||||
return Promise.reject(error)
|
const errorMessage = (data as any)?.message || 'Request failed'
|
||||||
}
|
ElMessage.error(errorMessage)
|
||||||
)
|
}
|
||||||
|
} else if (error.request) {
|
||||||
export default request
|
ElMessage.error('Network error')
|
||||||
|
} else {
|
||||||
|
ElMessage.error('Request failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default request
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import request from './request'
|
import request from './request'
|
||||||
import type { SyncLog } from '@/types'
|
import type { SyncLog } from '@/types'
|
||||||
|
|
||||||
export const syncApi = {
|
export const syncApi = {
|
||||||
syncProducts: () => {
|
syncProducts: () => {
|
||||||
return request.post<any, { message: string }>('/sync/products')
|
return request.post<any, { message: string }>('/sync/products')
|
||||||
},
|
},
|
||||||
|
|
||||||
syncCategories: () => {
|
syncCategories: () => {
|
||||||
return request.post<any, { message: string }>('/sync/categories')
|
return request.post<any, { message: string }>('/sync/categories')
|
||||||
},
|
},
|
||||||
|
|
||||||
syncProductDetails: () => {
|
syncProductDetails: () => {
|
||||||
@@ -19,10 +19,10 @@ export const syncApi = {
|
|||||||
`/sync/products/${goodId}/detail`,
|
`/sync/products/${goodId}/detail`,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
getSyncStatus: (limit?: number) => {
|
getSyncStatus: (limit?: number) => {
|
||||||
return request.get<any, SyncLog[]>('/sync/status', {
|
return request.get<any, SyncLog[]>('/sync/status', {
|
||||||
params: limit ? { limit } : undefined,
|
params: limit ? { limit } : undefined,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,33 @@
|
|||||||
import request from './request'
|
import request from './request'
|
||||||
import type {
|
import type {
|
||||||
TagGroup,
|
TagGroup,
|
||||||
CreateTagGroupRequest,
|
CreateTagGroupRequest,
|
||||||
UpdateTagGroupRequest,
|
UpdateTagGroupRequest,
|
||||||
ReorderTagGroupsRequest,
|
ReorderTagGroupsRequest,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
export const tagGroupsApi = {
|
export const tagGroupsApi = {
|
||||||
getTagGroupsList: () => {
|
getTagGroupsList: () => {
|
||||||
return request.get<any, TagGroup[]>('/tag-groups')
|
return request.get<any, TagGroup[]>('/tag-groups')
|
||||||
},
|
},
|
||||||
|
|
||||||
getTagGroupById: (id: string) => {
|
getTagGroupById: (id: string) => {
|
||||||
return request.get<any, TagGroup>(`/tag-groups/${id}`)
|
return request.get<any, TagGroup>(`/tag-groups/${id}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
createTagGroup: (data: CreateTagGroupRequest) => {
|
createTagGroup: (data: CreateTagGroupRequest) => {
|
||||||
return request.post<any, TagGroup>('/tag-groups', data)
|
return request.post<any, TagGroup>('/tag-groups', data)
|
||||||
},
|
},
|
||||||
|
|
||||||
updateTagGroup: (id: string, data: UpdateTagGroupRequest) => {
|
updateTagGroup: (id: string, data: UpdateTagGroupRequest) => {
|
||||||
return request.patch<any, TagGroup>(`/tag-groups/${id}`, data)
|
return request.patch<any, TagGroup>(`/tag-groups/${id}`, data)
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteTagGroup: (id: string) => {
|
deleteTagGroup: (id: string) => {
|
||||||
return request.delete(`/tag-groups/${id}`)
|
return request.delete(`/tag-groups/${id}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
reorderTagGroups: (data: ReorderTagGroupsRequest) => {
|
reorderTagGroups: (data: ReorderTagGroupsRequest) => {
|
||||||
return request.patch('/tag-groups/sort', data)
|
return request.patch('/tag-groups/sort', data)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,41 @@
|
|||||||
import request from './request'
|
import request from './request'
|
||||||
import type {
|
import type {
|
||||||
Tag,
|
Tag,
|
||||||
CreateTagRequest,
|
CreateTagRequest,
|
||||||
UpdateTagRequest,
|
UpdateTagRequest,
|
||||||
TagFilter,
|
TagFilter,
|
||||||
ReorderTagsRequest,
|
ReorderTagsRequest,
|
||||||
PaginatedResult,
|
PaginatedResult,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
export const tagsApi = {
|
export const tagsApi = {
|
||||||
// Get tags list
|
// Get tags list
|
||||||
getTagsList: (params: TagFilter) => {
|
getTagsList: (params: TagFilter) => {
|
||||||
return request.get<any, PaginatedResult<Tag>>('/tags', { params })
|
return request.get<any, PaginatedResult<Tag>>('/tags', { params })
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get tag by id
|
// Get tag by id
|
||||||
getTagById: (id: string) => {
|
getTagById: (id: string) => {
|
||||||
return request.get<any, Tag>(`/tags/${id}`)
|
return request.get<any, Tag>(`/tags/${id}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Create tag
|
// Create tag
|
||||||
createTag: (data: CreateTagRequest) => {
|
createTag: (data: CreateTagRequest) => {
|
||||||
return request.post<any, Tag>('/tags', data)
|
return request.post<any, Tag>('/tags', data)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update tag
|
// Update tag
|
||||||
updateTag: (id: string, data: UpdateTagRequest) => {
|
updateTag: (id: string, data: UpdateTagRequest) => {
|
||||||
return request.patch<any, Tag>(`/tags/${id}`, data)
|
return request.patch<any, Tag>(`/tags/${id}`, data)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Delete tag
|
// Delete tag
|
||||||
deleteTag: (id: string) => {
|
deleteTag: (id: string) => {
|
||||||
return request.delete(`/tags/${id}`)
|
return request.delete(`/tags/${id}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Reorder tags (and/or move across groups)
|
// Reorder tags (and/or move across groups)
|
||||||
reorderTags: (data: ReorderTagsRequest) => {
|
reorderTags: (data: ReorderTagsRequest) => {
|
||||||
return request.patch('/tags/sort', data)
|
return request.patch('/tags/sort', data)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import request from './request'
|
import request from './request'
|
||||||
|
|
||||||
export interface UploadResult {
|
export interface UploadResult {
|
||||||
url: string
|
url: string
|
||||||
filename: string
|
filename: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const uploadApi = {
|
export const uploadApi = {
|
||||||
uploadImage: (file: File) => {
|
uploadImage: (file: File) => {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
return request.post<any, UploadResult>('/upload/image', formData)
|
return request.post<any, UploadResult>('/upload/image', formData)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ export {}
|
|||||||
/* prettier-ignore */
|
/* prettier-ignore */
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
|
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||||
ElAside: typeof import('element-plus/es')['ElAside']
|
ElAside: typeof import('element-plus/es')['ElAside']
|
||||||
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||||
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
ElCascader: typeof import('element-plus/es')['ElCascader']
|
ElCascader: typeof import('element-plus/es')['ElCascader']
|
||||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||||
|
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||||
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||||
@@ -33,15 +35,18 @@ declare module 'vue' {
|
|||||||
ElImage: typeof import('element-plus/es')['ElImage']
|
ElImage: typeof import('element-plus/es')['ElImage']
|
||||||
ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
|
ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
|
||||||
ElInput: typeof import('element-plus/es')['ElInput']
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
|
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||||
ElMain: typeof import('element-plus/es')['ElMain']
|
ElMain: typeof import('element-plus/es')['ElMain']
|
||||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
ElOption: typeof import('element-plus/es')['ElOption']
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup']
|
ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup']
|
||||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||||
|
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
ElTable: typeof import('element-plus/es')['ElTable']
|
ElTable: typeof import('element-plus/es')['ElTable']
|
||||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||||
|
|||||||
@@ -1,154 +1,154 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Plus, Loading } from '@element-plus/icons-vue'
|
import { Plus, Loading } from '@element-plus/icons-vue'
|
||||||
import type { UploadProps } from 'element-plus'
|
import type { UploadProps } from 'element-plus'
|
||||||
import { uploadApi } from '@/api/upload'
|
import { uploadApi } from '@/api/upload'
|
||||||
|
|
||||||
const model = defineModel<string>({ default: '' })
|
const model = defineModel<string>({ default: '' })
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
width?: number
|
width?: number
|
||||||
height?: number
|
height?: number
|
||||||
shape?: 'square' | 'circle'
|
shape?: 'square' | 'circle'
|
||||||
label?: string
|
label?: string
|
||||||
}>(), {
|
}>(), {
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 80,
|
height: 80,
|
||||||
shape: 'square',
|
shape: 'square',
|
||||||
label: '点击上传',
|
label: '点击上传',
|
||||||
})
|
})
|
||||||
|
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
const previewVisible = ref(false)
|
const previewVisible = ref(false)
|
||||||
|
|
||||||
const customUpload: UploadProps['httpRequest'] = async (options) => {
|
const customUpload: UploadProps['httpRequest'] = async (options) => {
|
||||||
const file = options.file as File
|
const file = options.file as File
|
||||||
uploading.value = true
|
uploading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await uploadApi.uploadImage(file)
|
const result = await uploadApi.uploadImage(file)
|
||||||
model.value = result.url
|
model.value = result.url
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.response?.data?.message || '上传失败')
|
ElMessage.error(e?.response?.data?.message || '上传失败')
|
||||||
} finally {
|
} finally {
|
||||||
uploading.value = false
|
uploading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeImage() {
|
function removeImage() {
|
||||||
model.value = ''
|
model.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function onImgError() {
|
function onImgError() {
|
||||||
console.warn('Image failed to load:', model.value)
|
console.warn('Image failed to load:', model.value)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="img-upload">
|
<div class="img-upload">
|
||||||
<el-upload
|
<el-upload
|
||||||
class="img-uploader"
|
class="img-uploader"
|
||||||
:show-file-list="false"
|
:show-file-list="false"
|
||||||
:http-request="customUpload"
|
:http-request="customUpload"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
>
|
>
|
||||||
<div v-if="model" class="img-preview-wrap" :style="{ width: width + 'px', height: height + 'px', borderRadius: shape === 'circle' ? '50%' : '8px' }">
|
<div v-if="model" class="img-preview-wrap" :style="{ width: width + 'px', height: height + 'px', borderRadius: shape === 'circle' ? '50%' : '8px' }">
|
||||||
<img :src="model" class="img-preview-img" @click.stop="previewVisible = true" @error="onImgError" />
|
<img :src="model" class="img-preview-img" @click.stop="previewVisible = true" @error="onImgError" />
|
||||||
<div class="img-overlay">
|
<div class="img-overlay">
|
||||||
<span @click.stop="previewVisible = true">预览</span>
|
<span @click.stop="previewVisible = true">预览</span>
|
||||||
<span @click.stop="removeImage">删除</span>
|
<span @click.stop="removeImage">删除</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="img-placeholder" :style="{ width: width + 'px', height: height + 'px', borderRadius: shape === 'circle' ? '50%' : '8px' }">
|
<div v-else class="img-placeholder" :style="{ width: width + 'px', height: height + 'px', borderRadius: shape === 'circle' ? '50%' : '8px' }">
|
||||||
<el-icon v-if="!uploading" :size="20"><Plus /></el-icon>
|
<el-icon v-if="!uploading" :size="20"><Plus /></el-icon>
|
||||||
<el-icon v-else :size="20" class="is-loading"><Loading /></el-icon>
|
<el-icon v-else :size="20" class="is-loading"><Loading /></el-icon>
|
||||||
<span class="img-placeholder-text">{{ uploading ? '上传中' : label }}</span>
|
<span class="img-placeholder-text">{{ uploading ? '上传中' : label }}</span>
|
||||||
</div>
|
</div>
|
||||||
</el-upload>
|
</el-upload>
|
||||||
|
|
||||||
<el-image-viewer v-if="previewVisible" :url-list="[model]" @close="previewVisible = false" />
|
<el-image-viewer v-if="previewVisible" :url-list="[model]" @close="previewVisible = false" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.img-upload {
|
.img-upload {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.img-uploader {
|
.img-uploader {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.img-placeholder {
|
.img-placeholder {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
border: 1px dashed #d9d9d9;
|
border: 1px dashed #d9d9d9;
|
||||||
background: #fafafa;
|
background: #fafafa;
|
||||||
color: #a8abb2;
|
color: #a8abb2;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.img-placeholder:hover {
|
.img-placeholder:hover {
|
||||||
border-color: var(--brand-color, #ff6800);
|
border-color: var(--brand-color, #ff6800);
|
||||||
color: var(--brand-color, #ff6800);
|
color: var(--brand-color, #ff6800);
|
||||||
}
|
}
|
||||||
|
|
||||||
.img-placeholder-text {
|
.img-placeholder-text {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.img-preview-wrap {
|
.img-preview-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.img-preview-img {
|
.img-preview-img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.img-overlay {
|
.img-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.45);
|
background: rgba(0, 0, 0, 0.45);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.img-preview-wrap:hover .img-overlay {
|
.img-preview-wrap:hover .img-overlay {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.img-overlay span {
|
.img-overlay span {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: rgba(255, 255, 255, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.img-overlay span:hover {
|
.img-overlay span:hover {
|
||||||
background: rgba(255, 255, 255, 0.3);
|
background: rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.is-loading {
|
.is-loading {
|
||||||
animation: rotating 1.5s linear infinite;
|
animation: rotating 1.5s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes rotating {
|
@keyframes rotating {
|
||||||
from { transform: rotate(0deg); }
|
from { transform: rotate(0deg); }
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,274 +1,274 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { ElMessageBox } from 'element-plus'
|
import { ElMessageBox } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
Goods,
|
Goods,
|
||||||
Expand,
|
Expand,
|
||||||
Fold,
|
Fold,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
User,
|
User,
|
||||||
SwitchButton,
|
SwitchButton,
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
|
||||||
interface MenuItem {
|
interface MenuItem {
|
||||||
index: string
|
index: string
|
||||||
title: string
|
title: string
|
||||||
icon: string
|
icon: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const menuItems = ref<MenuItem[]>([
|
const menuItems = ref<MenuItem[]>([
|
||||||
{ index: '/goods', title: '商品管理', icon: 'Goods' },
|
{ index: '/goods', title: '商品管理', icon: 'Goods' },
|
||||||
])
|
])
|
||||||
|
|
||||||
const iconMap: Record<string, unknown> = {
|
const iconMap: Record<string, unknown> = {
|
||||||
Goods,
|
Goods,
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeMenu = computed(() => route.path)
|
const activeMenu = computed(() => route.path)
|
||||||
const collapsed = computed(() => appStore.sidebarCollapsed)
|
const collapsed = computed(() => appStore.sidebarCollapsed)
|
||||||
const username = computed(() => authStore.user?.username || 'Admin')
|
const username = computed(() => authStore.user?.username || 'Admin')
|
||||||
|
|
||||||
const breadcrumb = computed(() => {
|
const breadcrumb = computed(() => {
|
||||||
const meta = route.meta as { title?: string } | undefined
|
const meta = route.meta as { title?: string } | undefined
|
||||||
return meta?.title || '商品管理'
|
return meta?.title || '商品管理'
|
||||||
})
|
})
|
||||||
|
|
||||||
function toggleSidebar() {
|
function toggleSidebar() {
|
||||||
appStore.toggleSidebar()
|
appStore.toggleSidebar()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm('确定退出登录吗?', '退出登录', {
|
await ElMessageBox.confirm('确定退出登录吗?', '退出登录', {
|
||||||
confirmButtonText: '退出登录',
|
confirmButtonText: '退出登录',
|
||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await authStore.logout()
|
await authStore.logout()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCommand(command: string) {
|
function handleCommand(command: string) {
|
||||||
if (command === 'logout') {
|
if (command === 'logout') {
|
||||||
handleLogout()
|
handleLogout()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<el-container class="layout-root">
|
<el-container class="layout-root">
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<el-aside :width="collapsed ? '64px' : '240px'" class="layout-aside">
|
<el-aside :width="collapsed ? '64px' : '240px'" class="layout-aside">
|
||||||
<div class="brand" :class="{ collapsed }">
|
<div class="brand" :class="{ collapsed }">
|
||||||
<img src="/logo-unified.svg" class="brand-logo" alt="InkReach" />
|
<img src="/logo-unified.svg" class="brand-logo" alt="InkReach" />
|
||||||
<span v-if="!collapsed" class="brand-text">印美达官网后台</span>
|
<span v-if="!collapsed" class="brand-text">印美达官网后台</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-menu
|
<el-menu
|
||||||
:default-active="activeMenu"
|
:default-active="activeMenu"
|
||||||
:collapse="collapsed"
|
:collapse="collapsed"
|
||||||
:collapse-transition="false"
|
:collapse-transition="false"
|
||||||
background-color="#1f1f24"
|
background-color="#1f1f24"
|
||||||
text-color="#d4d4d8"
|
text-color="#d4d4d8"
|
||||||
active-text-color="#ff6800"
|
active-text-color="#ff6800"
|
||||||
router
|
router
|
||||||
>
|
>
|
||||||
<el-menu-item v-for="item in menuItems" :key="item.index" :index="item.index">
|
<el-menu-item v-for="item in menuItems" :key="item.index" :index="item.index">
|
||||||
<el-icon>
|
<el-icon>
|
||||||
<component :is="iconMap[item.icon]" />
|
<component :is="iconMap[item.icon]" />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<template #title>{{ item.title }}</template>
|
<template #title>{{ item.title }}</template>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
</el-aside>
|
</el-aside>
|
||||||
|
|
||||||
<el-container>
|
<el-container>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<el-header class="layout-header" height="56px">
|
<el-header class="layout-header" height="56px">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<el-button
|
<el-button
|
||||||
text
|
text
|
||||||
class="collapse-btn"
|
class="collapse-btn"
|
||||||
:title="collapsed ? '展开侧边栏' : '折叠侧边栏'"
|
:title="collapsed ? '展开侧边栏' : '折叠侧边栏'"
|
||||||
@click="toggleSidebar"
|
@click="toggleSidebar"
|
||||||
>
|
>
|
||||||
<el-icon :size="20">
|
<el-icon :size="20">
|
||||||
<component :is="collapsed ? Expand : Fold" />
|
<component :is="collapsed ? Expand : Fold" />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
</el-button>
|
</el-button>
|
||||||
|
|
||||||
<el-breadcrumb separator="/" class="breadcrumb">
|
<el-breadcrumb separator="/" class="breadcrumb">
|
||||||
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
||||||
<el-breadcrumb-item>{{ breadcrumb }}</el-breadcrumb-item>
|
<el-breadcrumb-item>{{ breadcrumb }}</el-breadcrumb-item>
|
||||||
</el-breadcrumb>
|
</el-breadcrumb>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<el-dropdown trigger="click" @command="handleCommand">
|
<el-dropdown trigger="click" @command="handleCommand">
|
||||||
<span class="user-trigger">
|
<span class="user-trigger">
|
||||||
<el-icon><User /></el-icon>
|
<el-icon><User /></el-icon>
|
||||||
<span class="user-name">{{ username }}</span>
|
<span class="user-name">{{ username }}</span>
|
||||||
<el-icon><ArrowDown /></el-icon>
|
<el-icon><ArrowDown /></el-icon>
|
||||||
</span>
|
</span>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
<el-dropdown-item command="logout">
|
<el-dropdown-item command="logout">
|
||||||
<el-icon><SwitchButton /></el-icon>
|
<el-icon><SwitchButton /></el-icon>
|
||||||
<span>退出登录</span>
|
<span>退出登录</span>
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</template>
|
</template>
|
||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
</div>
|
</div>
|
||||||
</el-header>
|
</el-header>
|
||||||
|
|
||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<el-main class="layout-main">
|
<el-main class="layout-main">
|
||||||
<router-view v-slot="{ Component }">
|
<router-view v-slot="{ Component }">
|
||||||
<transition name="fade-slide" mode="out-in">
|
<transition name="fade-slide" mode="out-in">
|
||||||
<component :is="Component" />
|
<component :is="Component" />
|
||||||
</transition>
|
</transition>
|
||||||
</router-view>
|
</router-view>
|
||||||
</el-main>
|
</el-main>
|
||||||
</el-container>
|
</el-container>
|
||||||
</el-container>
|
</el-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.layout-root {
|
.layout-root {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-aside {
|
.layout-aside {
|
||||||
background: #1f1f24;
|
background: #1f1f24;
|
||||||
transition: width 0.2s ease;
|
transition: width 0.2s ease;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
border-bottom: 1px solid #2b2b33;
|
border-bottom: 1px solid #2b2b33;
|
||||||
background: #16161a;
|
background: #16161a;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand.collapsed {
|
.brand.collapsed {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-logo {
|
.brand-logo {
|
||||||
height: 24px;
|
height: 24px;
|
||||||
width: auto;
|
width: auto;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-text {
|
.brand-text {
|
||||||
color: #e4e4e7;
|
color: #e4e4e7;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-aside :deep(.el-menu) {
|
.layout-aside :deep(.el-menu) {
|
||||||
border-right: none;
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-aside :deep(.el-menu-item.is-active) {
|
.layout-aside :deep(.el-menu-item.is-active) {
|
||||||
background: rgba(255, 104, 0, 0.12) !important;
|
background: rgba(255, 104, 0, 0.12) !important;
|
||||||
border-right: 3px solid var(--brand-color);
|
border-right: 3px solid var(--brand-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-aside :deep(.el-menu-item:hover) {
|
.layout-aside :deep(.el-menu-item:hover) {
|
||||||
background: rgba(255, 255, 255, 0.04) !important;
|
background: rgba(255, 255, 255, 0.04) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-header {
|
.layout-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-bottom: 1px solid #e5e7eb;
|
border-bottom: 1px solid #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left {
|
.header-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collapse-btn {
|
.collapse-btn {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.breadcrumb {
|
.breadcrumb {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-right {
|
.header-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-trigger {
|
.user-trigger {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: #374151;
|
color: #374151;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
transition: background 0.15s ease;
|
transition: background 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-trigger:hover {
|
.user-trigger:hover {
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-name {
|
.user-name {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-main {
|
.layout-main {
|
||||||
background: #f5f7fa;
|
background: #f5f7fa;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* View transition */
|
/* View transition */
|
||||||
.fade-slide-enter-active,
|
.fade-slide-enter-active,
|
||||||
.fade-slide-leave-active {
|
.fade-slide-leave-active {
|
||||||
transition:
|
transition:
|
||||||
opacity 0.18s ease,
|
opacity 0.18s ease,
|
||||||
transform 0.18s ease;
|
transform 0.18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-slide-enter-from {
|
.fade-slide-enter-from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(8px);
|
transform: translateY(8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-slide-leave-to {
|
.fade-slide-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-8px);
|
transform: translateY(-8px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import ElementPlus from 'element-plus'
|
import ElementPlus from 'element-plus'
|
||||||
import 'element-plus/dist/index.css'
|
import 'element-plus/dist/index.css'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.use(ElementPlus)
|
app.use(ElementPlus)
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
@@ -1,55 +1,55 @@
|
|||||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
name: 'Login',
|
name: 'Login',
|
||||||
component: () => import('@/views/login/LoginView.vue'),
|
component: () => import('@/views/login/LoginView.vue'),
|
||||||
meta: { title: '登录', public: true },
|
meta: { title: '登录', public: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
component: () => import('@/layouts/DefaultLayout.vue'),
|
component: () => import('@/layouts/DefaultLayout.vue'),
|
||||||
redirect: '/goods',
|
redirect: '/goods',
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: 'goods',
|
path: 'goods',
|
||||||
name: 'Goods',
|
name: 'Goods',
|
||||||
component: () => import('@/views/product-management/ProductManagementView.vue'),
|
component: () => import('@/views/product-management/ProductManagementView.vue'),
|
||||||
meta: { title: '商品管理' },
|
meta: { title: '商品管理' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/:pathMatch(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
redirect: '/goods',
|
redirect: '/goods',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory('/admin/'),
|
||||||
routes,
|
routes,
|
||||||
})
|
})
|
||||||
|
|
||||||
router.beforeEach((to) => {
|
router.beforeEach((to) => {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const isPublic = to.meta?.public === true
|
const isPublic = to.meta?.public === true
|
||||||
|
|
||||||
if (!authStore.isLoggedIn && !isPublic) {
|
if (!authStore.isLoggedIn && !isPublic) {
|
||||||
return { path: '/login', replace: true }
|
return { path: '/login', replace: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authStore.isLoggedIn && to.path === '/login') {
|
if (authStore.isLoggedIn && to.path === '/login') {
|
||||||
return { path: '/', replace: true }
|
return { path: '/', replace: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
router.afterEach((to) => {
|
router.afterEach((to) => {
|
||||||
const title = (to.meta?.title as string) || 'Inkreach 官网后台'
|
const title = (to.meta?.title as string) || 'Inkreach 官网后台'
|
||||||
document.title = `${title} | Inkreach 官网后台`
|
document.title = `${title} | Inkreach 官网后台`
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
const SIDEBAR_KEY = 'app:sidebarCollapsed'
|
const SIDEBAR_KEY = 'app:sidebarCollapsed'
|
||||||
|
|
||||||
export const useAppStore = defineStore('app', () => {
|
export const useAppStore = defineStore('app', () => {
|
||||||
const sidebarCollapsed = ref<boolean>(
|
const sidebarCollapsed = ref<boolean>(
|
||||||
localStorage.getItem(SIDEBAR_KEY) === 'true'
|
localStorage.getItem(SIDEBAR_KEY) === 'true'
|
||||||
)
|
)
|
||||||
|
|
||||||
function toggleSidebar() {
|
function toggleSidebar() {
|
||||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||||
localStorage.setItem(SIDEBAR_KEY, String(sidebarCollapsed.value))
|
localStorage.setItem(SIDEBAR_KEY, String(sidebarCollapsed.value))
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSidebarCollapsed(value: boolean) {
|
function setSidebarCollapsed(value: boolean) {
|
||||||
sidebarCollapsed.value = value
|
sidebarCollapsed.value = value
|
||||||
localStorage.setItem(SIDEBAR_KEY, String(value))
|
localStorage.setItem(SIDEBAR_KEY, String(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sidebarCollapsed,
|
sidebarCollapsed,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
setSidebarCollapsed,
|
setSidebarCollapsed,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,76 +1,67 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { LoginRequest, User } from '@/types'
|
import type { LoginRequest, User } from '@/types'
|
||||||
import { authApi } from '@/api/auth'
|
import { authApi } from '@/api/auth'
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
// Token persisted to localStorage
|
// The session lives in HttpOnly cookies set by the API; nothing
|
||||||
const token = ref<string>(localStorage.getItem('token') || '')
|
// security-relevant is stored client-side. `user` is just UI state,
|
||||||
|
// restored from the server via /auth/me on app start.
|
||||||
// User persisted to localStorage (parsed if available)
|
const user = ref<User | null>(null)
|
||||||
const user = ref<User | null>(loadUser())
|
const sessionChecked = ref(false)
|
||||||
|
|
||||||
const isLoggedIn = computed(() => !!token.value)
|
// Tokens moved to HttpOnly cookies; clean up any stale values from the
|
||||||
|
// previous localStorage-based session.
|
||||||
function loadUser(): User | null {
|
localStorage.removeItem('token')
|
||||||
const raw = localStorage.getItem('user')
|
localStorage.removeItem('user')
|
||||||
if (!raw) return null
|
|
||||||
try {
|
const isLoggedIn = computed(() => !!user.value)
|
||||||
return JSON.parse(raw) as User
|
|
||||||
} catch {
|
// Restore the session once per app start. The router guard awaits this
|
||||||
return null
|
// so a page refresh on a protected route does not bounce to /login.
|
||||||
}
|
async function ensureSessionChecked() {
|
||||||
}
|
if (sessionChecked.value) return
|
||||||
|
sessionChecked.value = true
|
||||||
function setToken(newToken: string) {
|
try {
|
||||||
token.value = newToken
|
user.value = await authApi.getCurrentUser()
|
||||||
if (newToken) {
|
} catch {
|
||||||
localStorage.setItem('token', newToken)
|
user.value = null
|
||||||
} else {
|
}
|
||||||
localStorage.removeItem('token')
|
}
|
||||||
}
|
|
||||||
}
|
function setUser(newUser: User | null) {
|
||||||
|
user.value = newUser
|
||||||
function setUser(newUser: User | null) {
|
}
|
||||||
user.value = newUser
|
|
||||||
if (newUser) {
|
async function login(payload: LoginRequest) {
|
||||||
localStorage.setItem('user', JSON.stringify(newUser))
|
const res = await authApi.login(payload) as any
|
||||||
} else {
|
const userData: User | null = res.user ?? res.data?.user ?? null
|
||||||
localStorage.removeItem('user')
|
sessionChecked.value = true
|
||||||
}
|
setUser(userData)
|
||||||
}
|
return res
|
||||||
|
}
|
||||||
async function login(payload: LoginRequest) {
|
|
||||||
const res = await authApi.login(payload) as any;
|
async function fetchCurrentUser() {
|
||||||
const accessToken: string = res.accessToken ?? res.data?.accessToken ?? '';
|
const current = await authApi.getCurrentUser()
|
||||||
const userData: User | null = res.user ?? res.data?.user ?? null;
|
setUser(current)
|
||||||
if (accessToken) setToken(accessToken);
|
return current
|
||||||
if (userData) setUser(userData);
|
}
|
||||||
return res;
|
|
||||||
}
|
async function logout() {
|
||||||
|
try {
|
||||||
async function fetchCurrentUser() {
|
await authApi.logout()
|
||||||
const current = await authApi.getCurrentUser()
|
} catch {
|
||||||
setUser(current)
|
// Ignore network errors during logout
|
||||||
return current
|
}
|
||||||
}
|
setUser(null)
|
||||||
|
}
|
||||||
async function logout() {
|
|
||||||
try {
|
return {
|
||||||
await authApi.logout()
|
user,
|
||||||
} catch {
|
isLoggedIn,
|
||||||
// Ignore network errors during logout
|
ensureSessionChecked,
|
||||||
}
|
login,
|
||||||
setToken('')
|
fetchCurrentUser,
|
||||||
setUser(null)
|
logout,
|
||||||
}
|
}
|
||||||
|
})
|
||||||
return {
|
|
||||||
token,
|
|
||||||
user,
|
|
||||||
isLoggedIn,
|
|
||||||
login,
|
|
||||||
fetchCurrentUser,
|
|
||||||
logout,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,100 +1,100 @@
|
|||||||
:root {
|
:root {
|
||||||
--brand-color: #ff6800;
|
--brand-color: #ff6800;
|
||||||
--brand-color-hover: #ff7d1f;
|
--brand-color-hover: #ff7d1f;
|
||||||
--brand-color-active: #e65a00;
|
--brand-color-active: #e65a00;
|
||||||
|
|
||||||
--sidebar-width: 240px;
|
--sidebar-width: 240px;
|
||||||
--sidebar-width-collapsed: 64px;
|
--sidebar-width-collapsed: 64px;
|
||||||
--header-height: 56px;
|
--header-height: 56px;
|
||||||
|
|
||||||
font-family:
|
font-family:
|
||||||
-apple-system,
|
-apple-system,
|
||||||
BlinkMacSystemFont,
|
BlinkMacSystemFont,
|
||||||
'Segoe UI',
|
'Segoe UI',
|
||||||
Roboto,
|
Roboto,
|
||||||
'Helvetica Neue',
|
'Helvetica Neue',
|
||||||
Arial,
|
Arial,
|
||||||
'PingFang SC',
|
'PingFang SC',
|
||||||
'Hiragino Sans GB',
|
'Hiragino Sans GB',
|
||||||
'Microsoft YaHei',
|
'Microsoft YaHei',
|
||||||
sans-serif;
|
sans-serif;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
background-color: #f5f7fa;
|
background-color: #f5f7fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#app {
|
#app {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Element Plus brand color overrides */
|
/* Element Plus brand color overrides */
|
||||||
:root {
|
:root {
|
||||||
--el-color-primary: var(--brand-color);
|
--el-color-primary: var(--brand-color);
|
||||||
--el-color-primary-light-3: #ff8f3d;
|
--el-color-primary-light-3: #ff8f3d;
|
||||||
--el-color-primary-light-5: #ffb073;
|
--el-color-primary-light-5: #ffb073;
|
||||||
--el-color-primary-light-7: #ffd1a8;
|
--el-color-primary-light-7: #ffd1a8;
|
||||||
--el-color-primary-light-8: #ffe0c2;
|
--el-color-primary-light-8: #ffe0c2;
|
||||||
--el-color-primary-light-9: #fff0e0;
|
--el-color-primary-light-9: #fff0e0;
|
||||||
--el-color-primary-dark-2: #e65a00;
|
--el-color-primary-dark-2: #e65a00;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Brand-colored buttons */
|
/* Brand-colored buttons */
|
||||||
.el-button--primary {
|
.el-button--primary {
|
||||||
--el-button-bg-color: var(--brand-color);
|
--el-button-bg-color: var(--brand-color);
|
||||||
--el-button-border-color: var(--brand-color);
|
--el-button-border-color: var(--brand-color);
|
||||||
--el-button-hover-bg-color: var(--brand-color-hover);
|
--el-button-hover-bg-color: var(--brand-color-hover);
|
||||||
--el-button-hover-border-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-bg-color: var(--brand-color-active);
|
||||||
--el-button-active-border-color: var(--brand-color-active);
|
--el-button-active-border-color: var(--brand-color-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Page wrapper helper */
|
/* Page wrapper helper */
|
||||||
.page-container {
|
.page-container {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-card {
|
.page-card {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Compact filter bar */
|
/* Compact filter bar */
|
||||||
.filter-bar {
|
.filter-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-bar .el-input,
|
.filter-bar .el-input,
|
||||||
.filter-bar .el-select,
|
.filter-bar .el-select,
|
||||||
.filter-bar .el-cascader {
|
.filter-bar .el-cascader {
|
||||||
width: 200px;
|
width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Action cell padding */
|
/* Action cell padding */
|
||||||
.table-actions .el-button + .el-button {
|
.table-actions .el-button + .el-button {
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Brand-colored dialog titles */
|
/* Brand-colored dialog titles */
|
||||||
.brand-title {
|
.brand-title {
|
||||||
color: var(--brand-color);
|
color: var(--brand-color);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,364 +1,443 @@
|
|||||||
// Common types
|
// Common types
|
||||||
export interface PaginatedResult<T> {
|
export interface PaginatedResult<T> {
|
||||||
items: T[]
|
items: T[]
|
||||||
total: number
|
total: number
|
||||||
page: number
|
page: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth types
|
// Auth types
|
||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
username: string
|
username: string
|
||||||
password: string
|
password: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
accessToken: string
|
accessToken: string
|
||||||
user: User
|
user: User
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string
|
id: string
|
||||||
username: string
|
username: string
|
||||||
email?: string
|
email?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Good types
|
// Good types
|
||||||
export interface Good {
|
export interface Good {
|
||||||
id: string
|
id: string
|
||||||
goodName: string
|
goodName: string
|
||||||
goodImage: string | null
|
goodImage: string | null
|
||||||
goodPriority: number
|
goodPriority: number
|
||||||
originGoodId: string
|
originGoodId: string
|
||||||
originGood?: OriginGood
|
originGood?: OriginGood
|
||||||
countryId: string
|
countryId: string
|
||||||
country?: Country
|
country?: Country
|
||||||
categoryId: string
|
categoryId: string
|
||||||
category?: Category
|
category?: Category
|
||||||
tagId?: string
|
tagId?: string
|
||||||
tag?: Tag
|
tag?: Tag
|
||||||
tags: Tag[]
|
tags: Tag[]
|
||||||
positionId?: string
|
positionId?: string
|
||||||
position?: Position
|
position?: Position
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
mergedOriginGoods?: MergedOriginGoodSummary[]
|
||||||
|
}
|
||||||
export interface OriginGoodDetail {
|
|
||||||
productCode?: string | null
|
export interface MergedOriginGoodSummary {
|
||||||
englishName?: string | null
|
id: string
|
||||||
productionCycleHours?: number | null
|
sdsGoodId: string
|
||||||
minWeightG?: string | null
|
goodName: string | null
|
||||||
productionProcess?: string | null
|
goodImage: string | null
|
||||||
materialDescription?: string | null
|
goodPrice: string | null
|
||||||
sizeChart?: { columns?: Array<{ key: string; name: string }>; rows?: any[] } | null
|
hasDetail: boolean
|
||||||
packageSpecs?: { rows?: any[] } | null
|
variantCount: number
|
||||||
syncedAt?: string | null
|
}
|
||||||
[key: string]: unknown
|
|
||||||
}
|
export interface OriginGoodDetail {
|
||||||
|
productCode?: string | null
|
||||||
export interface OriginGoodVariant {
|
englishName?: string | null
|
||||||
sdsVariantId: string
|
productionCycleHours?: number | null
|
||||||
sku: string
|
minWeightG?: string | null
|
||||||
sizeName?: string | null
|
productionProcess?: string | null
|
||||||
colorName?: string | null
|
materialDescription?: string | null
|
||||||
colorHex?: string | null
|
sizeChart?: { columns?: Array<{ key: string; name: string }>; rows?: any[] } | null
|
||||||
price?: string | null
|
packageSpecs?: { rows?: any[] } | null
|
||||||
enabled: boolean
|
syncedAt?: string | null
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GoodDetail extends Good {
|
export interface OriginGoodVariant {
|
||||||
originDetail: OriginGoodDetail | null
|
sdsVariantId: string
|
||||||
variants: OriginGoodVariant[]
|
sku: string
|
||||||
}
|
sizeName?: string | null
|
||||||
|
colorName?: string | null
|
||||||
export interface CreateGoodRequest {
|
colorHex?: string | null
|
||||||
goodName: string
|
price?: string | null
|
||||||
goodImage?: string
|
enabled: boolean
|
||||||
originGoodId: number
|
[key: string]: unknown
|
||||||
countryId: number
|
}
|
||||||
categoryId: number
|
|
||||||
tagIds?: number[]
|
export interface CustomGoodVariantRequest {
|
||||||
positionId?: number
|
sku: string
|
||||||
goodPriority?: number
|
sizeId?: string | null
|
||||||
}
|
sizeName?: string | null
|
||||||
|
colorId?: string | null
|
||||||
export interface UpdateGoodRequest {
|
colorName?: string | null
|
||||||
goodName?: string
|
colorHex?: string | null
|
||||||
goodImage?: string | null
|
imageUrl?: string | null
|
||||||
originGoodId?: number
|
price?: string | null
|
||||||
countryId?: number
|
originalPrice?: string | null
|
||||||
categoryId?: number
|
weightG?: string | null
|
||||||
tagIds?: number[]
|
boxLengthCm?: string | null
|
||||||
positionId?: number | null
|
boxWidthCm?: string | null
|
||||||
goodPriority?: number
|
boxHeightCm?: string | null
|
||||||
}
|
designData?: Record<string, unknown> | null
|
||||||
|
enabled?: boolean
|
||||||
export interface BatchCreateItem {
|
sortOrder?: number
|
||||||
originGoodId: number
|
}
|
||||||
priority?: number
|
|
||||||
}
|
export interface CustomGoodDetailRequest {
|
||||||
|
productCode?: string | null
|
||||||
export interface BatchCreateGoodsRequest {
|
englishName?: string | null
|
||||||
items: BatchCreateItem[]
|
productionCycleHours?: number | null
|
||||||
countryId: number
|
minWeightG?: string | null
|
||||||
categoryId: number
|
productionProcess?: string | null
|
||||||
tagIds?: number[]
|
materialDescription?: string | null
|
||||||
positionId?: number
|
blankDesignUrl?: string | null
|
||||||
defaultPriority?: number
|
detailsPageVideoUrl?: string | null
|
||||||
}
|
textureName?: string | null
|
||||||
|
reminder?: string | null
|
||||||
export interface UpdatePriorityRequest {
|
productPerformance?: string | null
|
||||||
goodIds: string[]
|
applicableScenarios?: string | null
|
||||||
priority: number
|
washingInstructions?: string | null
|
||||||
}
|
specialDescription?: string | null
|
||||||
|
designExplanation?: string | null
|
||||||
export interface BatchPriorityItem {
|
designArea?: string | null
|
||||||
id: number
|
pictureRequest?: string | null
|
||||||
priority: number
|
sizeChart?: Record<string, unknown> | null
|
||||||
}
|
packageSpecs?: Record<string, unknown> | null
|
||||||
|
options?: Record<string, unknown> | null
|
||||||
export interface BatchPriorityRequest {
|
media?: Record<string, unknown> | null
|
||||||
items: BatchPriorityItem[]
|
}
|
||||||
}
|
|
||||||
|
export interface GoodDetail extends Good {
|
||||||
// Country types
|
originDetail: OriginGoodDetail | null
|
||||||
export interface Country {
|
variants: OriginGoodVariant[]
|
||||||
id: string
|
}
|
||||||
countryName: string
|
|
||||||
countryIcon?: string | null
|
export interface CreateGoodRequest {
|
||||||
createdAt: string
|
goodName: string
|
||||||
updatedAt: string
|
goodImage?: string
|
||||||
}
|
originGoodId: number
|
||||||
|
countryId: number
|
||||||
export interface CreateCountryRequest {
|
categoryId: number
|
||||||
countryName: string
|
tagIds?: number[]
|
||||||
countryIcon?: string
|
positionId?: number
|
||||||
}
|
goodPriority?: number
|
||||||
|
mergedOriginGoodIds?: number[]
|
||||||
export interface UpdateCountryRequest {
|
}
|
||||||
countryName?: string
|
|
||||||
countryIcon?: string | null
|
export interface CreateCustomGoodRequest {
|
||||||
}
|
goodName: string
|
||||||
|
goodImage?: string
|
||||||
// Category types
|
goodPrice?: string | null
|
||||||
export interface Category {
|
countryId: number
|
||||||
id: string
|
categoryId: number
|
||||||
categoryName: string
|
tagIds?: number[]
|
||||||
categoryIcon?: string | null
|
positionId?: number
|
||||||
sdsCategoryId?: string | null
|
goodPriority?: number
|
||||||
parentCategoryId?: string | null
|
detail?: CustomGoodDetailRequest
|
||||||
parent?: Category
|
variants?: CustomGoodVariantRequest[]
|
||||||
children?: Category[]
|
}
|
||||||
_count?: {
|
|
||||||
children: number
|
export interface UpdateCustomGoodContentRequest {
|
||||||
}
|
goodName?: string
|
||||||
createdAt: string
|
goodImage?: string | null
|
||||||
updatedAt: string
|
goodPrice?: string | null
|
||||||
}
|
detail?: CustomGoodDetailRequest
|
||||||
|
variants?: CustomGoodVariantRequest[]
|
||||||
export interface CreateCategoryRequest {
|
}
|
||||||
categoryName: string
|
|
||||||
categoryIcon?: string
|
export interface UpdateGoodRequest {
|
||||||
parentCategoryId?: number
|
goodName?: string
|
||||||
}
|
goodImage?: string | null
|
||||||
|
originGoodId?: number
|
||||||
export interface UpdateCategoryRequest {
|
countryId?: number
|
||||||
categoryName?: string
|
categoryId?: number
|
||||||
categoryIcon?: string | null
|
tagIds?: number[]
|
||||||
parentCategoryId?: number | null
|
positionId?: number | null
|
||||||
}
|
goodPriority?: number
|
||||||
|
mergedOriginGoodIds?: number[]
|
||||||
export interface CategoryTree extends Category {
|
}
|
||||||
children?: CategoryTree[]
|
|
||||||
}
|
export interface BatchCreateItem {
|
||||||
|
originGoodId: number
|
||||||
// Tag types
|
priority?: number
|
||||||
export interface Tag {
|
}
|
||||||
id: string
|
|
||||||
tagName: string
|
export interface BatchCreateGoodsRequest {
|
||||||
tagColor?: string | null
|
items: BatchCreateItem[]
|
||||||
tagFontColor?: string | null
|
countryId: number
|
||||||
timing?: string | null
|
categoryId: number
|
||||||
tagGroupId?: string | null
|
tagIds?: number[]
|
||||||
sortOrder?: number
|
positionId?: number
|
||||||
tagGroup?: TagGroup | null
|
defaultPriority?: number
|
||||||
createdAt: string
|
}
|
||||||
updatedAt: string
|
|
||||||
}
|
export interface UpdatePriorityRequest {
|
||||||
|
goodIds: string[]
|
||||||
export interface CreateTagRequest {
|
priority: number
|
||||||
tagName: string
|
}
|
||||||
tagColor?: string
|
|
||||||
tagFontColor?: string
|
export interface BatchPriorityItem {
|
||||||
timing?: string
|
id: number
|
||||||
tagGroupId?: number
|
priority: number
|
||||||
sortOrder?: number
|
}
|
||||||
}
|
|
||||||
|
export interface BatchPriorityRequest {
|
||||||
export interface UpdateTagRequest {
|
items: BatchPriorityItem[]
|
||||||
tagName?: string
|
}
|
||||||
tagColor?: string | null
|
|
||||||
tagFontColor?: string | null
|
// Country types
|
||||||
timing?: string | null
|
export interface Country {
|
||||||
tagGroupId?: number | null
|
id: string
|
||||||
sortOrder?: number
|
countryName: string
|
||||||
}
|
countryIcon?: string | null
|
||||||
|
createdAt: string
|
||||||
// Tag Group types
|
updatedAt: string
|
||||||
export interface TagGroup {
|
}
|
||||||
id: string
|
|
||||||
groupName: string
|
export interface CreateCountryRequest {
|
||||||
groupIcon?: string | null
|
countryName: string
|
||||||
groupColor?: string | null
|
countryIcon?: string
|
||||||
sortOrder: number
|
}
|
||||||
createdAt: string
|
|
||||||
updatedAt: string
|
export interface UpdateCountryRequest {
|
||||||
_count?: { tags: number }
|
countryName?: string
|
||||||
}
|
countryIcon?: string | null
|
||||||
|
}
|
||||||
export interface CreateTagGroupRequest {
|
|
||||||
groupName: string
|
// Category types
|
||||||
groupIcon?: string
|
export interface Category {
|
||||||
groupColor?: string
|
id: string
|
||||||
sortOrder?: number
|
categoryName: string
|
||||||
}
|
categoryIcon?: string | null
|
||||||
|
sdsCategoryId?: string | null
|
||||||
export interface UpdateTagGroupRequest {
|
parentCategoryId?: string | null
|
||||||
groupName?: string
|
parent?: Category
|
||||||
groupIcon?: string | null
|
children?: Category[]
|
||||||
groupColor?: string | null
|
_count?: {
|
||||||
sortOrder?: number
|
children: number
|
||||||
}
|
}
|
||||||
|
createdAt: string
|
||||||
export interface ReorderTagGroupsRequest {
|
updatedAt: string
|
||||||
items: Array<{ id: number; sortOrder: number }>
|
}
|
||||||
}
|
|
||||||
|
export interface CreateCategoryRequest {
|
||||||
export interface ReorderTagsRequest {
|
categoryName: string
|
||||||
items: Array<{ id: number; tagGroupId?: number | null; sortOrder: number }>
|
categoryIcon?: string
|
||||||
}
|
parentCategoryId?: number
|
||||||
|
}
|
||||||
// Position types
|
|
||||||
export interface Position {
|
export interface UpdateCategoryRequest {
|
||||||
id: string
|
categoryName?: string
|
||||||
indexVal: number
|
categoryIcon?: string | null
|
||||||
countryId?: string
|
parentCategoryId?: number | null
|
||||||
country?: Country
|
}
|
||||||
categoryId?: string
|
|
||||||
category?: Category
|
export interface CategoryTree extends Category {
|
||||||
createdAt: string
|
children?: CategoryTree[]
|
||||||
updatedAt: string
|
}
|
||||||
}
|
|
||||||
|
// Tag types
|
||||||
export interface CreatePositionRequest {
|
export interface Tag {
|
||||||
indexVal: number
|
id: string
|
||||||
countryId?: number
|
tagName: string
|
||||||
categoryId?: number
|
tagColor?: string | null
|
||||||
}
|
tagFontColor?: string | null
|
||||||
|
timing?: string | null
|
||||||
export interface UpdatePositionRequest {
|
tagGroupId?: string | null
|
||||||
indexVal?: number
|
sortOrder?: number
|
||||||
countryId?: number | null
|
tagGroup?: TagGroup | null
|
||||||
categoryId?: number | null
|
createdAt: string
|
||||||
}
|
updatedAt: string
|
||||||
|
}
|
||||||
// Origin Good types
|
|
||||||
export interface OriginGood {
|
export interface CreateTagRequest {
|
||||||
id: string
|
tagName: string
|
||||||
goodName: string | null
|
tagColor?: string
|
||||||
goodImage: string | null
|
tagFontColor?: string
|
||||||
goodPrice: string | null
|
timing?: string
|
||||||
sdsGoodId: string
|
tagGroupId?: number
|
||||||
sdsCategoryId: string | null
|
sortOrder?: number
|
||||||
delisted?: boolean
|
}
|
||||||
createdAt: string
|
|
||||||
updatedAt: string
|
export interface UpdateTagRequest {
|
||||||
hasDetail?: boolean
|
tagName?: string
|
||||||
detailSyncedAt?: string | null
|
tagColor?: string | null
|
||||||
variantCount?: number
|
tagFontColor?: string | null
|
||||||
sizeRowCount?: number
|
timing?: string | null
|
||||||
packageRowCount?: number
|
tagGroupId?: number | null
|
||||||
productCode?: string | null
|
sortOrder?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Origin Goods Tree types
|
// Tag Group types
|
||||||
export interface OriginGoodsTreeNode {
|
export interface TagGroup {
|
||||||
id: string
|
id: string
|
||||||
goodName: string
|
groupName: string
|
||||||
goodImage: string | null
|
groupIcon?: string | null
|
||||||
goodPrice: string | null
|
groupColor?: string | null
|
||||||
sdsGoodId: string
|
sortOrder: number
|
||||||
delisted: boolean
|
createdAt: string
|
||||||
configuredCount: number
|
updatedAt: string
|
||||||
configuredCountries: string[]
|
_count?: { tags: number }
|
||||||
configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]
|
}
|
||||||
hasDetail: boolean
|
|
||||||
detailSyncedAt: string | null
|
export interface CreateTagGroupRequest {
|
||||||
variantCount: number
|
groupName: string
|
||||||
sizeRowCount: number
|
groupIcon?: string
|
||||||
packageRowCount: number
|
groupColor?: string
|
||||||
}
|
sortOrder?: number
|
||||||
|
}
|
||||||
export interface OriginGoodsTreeCategoryNode {
|
|
||||||
categoryId: string
|
export interface UpdateTagGroupRequest {
|
||||||
categoryName: string
|
groupName?: string
|
||||||
sdsCategoryId: string | null
|
groupIcon?: string | null
|
||||||
configuredCount: number
|
groupColor?: string | null
|
||||||
totalCount: number
|
sortOrder?: number
|
||||||
children: OriginGoodsTreeCategoryNode[]
|
}
|
||||||
originGoods: OriginGoodsTreeNode[]
|
|
||||||
}
|
export interface ReorderTagGroupsRequest {
|
||||||
|
items: Array<{ id: number; sortOrder: number }>
|
||||||
export interface OriginGoodsTreeResponse {
|
}
|
||||||
tree: OriginGoodsTreeCategoryNode[]
|
|
||||||
totalOriginGoods: number
|
export interface ReorderTagsRequest {
|
||||||
configuredCount: number
|
items: Array<{ id: number; tagGroupId?: number | null; sortOrder: number }>
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync types
|
// Position types
|
||||||
export interface SyncLog {
|
export interface Position {
|
||||||
id: string
|
id: string
|
||||||
type: 'CATEGORIES' | 'PRODUCTS' | 'PRODUCT_DETAILS'
|
indexVal: number
|
||||||
status: 'RUNNING' | 'SUCCESS' | 'FAILED'
|
countryId?: string
|
||||||
message: string | null
|
country?: Country
|
||||||
startedAt: string
|
categoryId?: string
|
||||||
finishedAt: string | null
|
category?: Category
|
||||||
}
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
// Filter types
|
}
|
||||||
export interface GoodsFilter {
|
|
||||||
countryId?: string
|
export interface CreatePositionRequest {
|
||||||
categoryId?: string
|
indexVal: number
|
||||||
tagId?: string
|
countryId?: number
|
||||||
keyword?: string
|
categoryId?: number
|
||||||
page?: number
|
}
|
||||||
pageSize?: number
|
|
||||||
}
|
export interface UpdatePositionRequest {
|
||||||
|
indexVal?: number
|
||||||
export interface CategoryFilter {
|
countryId?: number | null
|
||||||
parentCategoryId?: string
|
categoryId?: number | null
|
||||||
categoryName?: string
|
}
|
||||||
page?: number
|
|
||||||
pageSize?: number
|
// Origin Good types
|
||||||
}
|
export interface OriginGood {
|
||||||
|
id: string
|
||||||
export interface CountryFilter {
|
goodName: string | null
|
||||||
countryName?: string
|
goodImage: string | null
|
||||||
page?: number
|
goodPrice: string | null
|
||||||
pageSize?: number
|
sdsGoodId: string
|
||||||
}
|
source: 'SDS' | 'CUSTOM'
|
||||||
|
isCustom: boolean
|
||||||
export interface TagFilter {
|
sdsCategoryId: string | null
|
||||||
tagName?: string
|
delisted?: boolean
|
||||||
page?: number
|
createdAt: string
|
||||||
pageSize?: number
|
updatedAt: string
|
||||||
}
|
hasDetail?: boolean
|
||||||
|
detailSyncedAt?: string | null
|
||||||
export interface PositionFilter {
|
variantCount?: number
|
||||||
countryId?: string
|
sizeRowCount?: number
|
||||||
categoryId?: string
|
packageRowCount?: number
|
||||||
page?: number
|
productCode?: string | null
|
||||||
pageSize?: number
|
}
|
||||||
}
|
|
||||||
|
// Origin Goods Tree types
|
||||||
|
export interface OriginGoodsTreeNode {
|
||||||
|
id: string
|
||||||
|
goodName: string
|
||||||
|
goodImage: string | null
|
||||||
|
goodPrice: string | null
|
||||||
|
sdsGoodId: string
|
||||||
|
delisted: boolean
|
||||||
|
configuredCount: number
|
||||||
|
configuredCountries: string[]
|
||||||
|
configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]
|
||||||
|
hasDetail: boolean
|
||||||
|
detailSyncedAt: string | null
|
||||||
|
variantCount: number
|
||||||
|
sizeRowCount: number
|
||||||
|
packageRowCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OriginGoodsTreeCategoryNode {
|
||||||
|
categoryId: string
|
||||||
|
categoryName: string
|
||||||
|
sdsCategoryId: string | null
|
||||||
|
configuredCount: number
|
||||||
|
totalCount: number
|
||||||
|
children: OriginGoodsTreeCategoryNode[]
|
||||||
|
originGoods: OriginGoodsTreeNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OriginGoodsTreeResponse {
|
||||||
|
tree: OriginGoodsTreeCategoryNode[]
|
||||||
|
totalOriginGoods: number
|
||||||
|
configuredCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync types
|
||||||
|
export interface SyncLog {
|
||||||
|
id: string
|
||||||
|
type: 'CATEGORIES' | 'PRODUCTS' | 'PRODUCT_DETAILS'
|
||||||
|
status: 'RUNNING' | 'SUCCESS' | 'FAILED'
|
||||||
|
message: string | null
|
||||||
|
startedAt: string
|
||||||
|
finishedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter types
|
||||||
|
export interface GoodsFilter {
|
||||||
|
countryId?: string
|
||||||
|
categoryId?: string
|
||||||
|
tagId?: string
|
||||||
|
keyword?: string
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryFilter {
|
||||||
|
parentCategoryId?: string
|
||||||
|
categoryName?: string
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CountryFilter {
|
||||||
|
countryName?: string
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TagFilter {
|
||||||
|
tagName?: string
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PositionFilter {
|
||||||
|
countryId?: string
|
||||||
|
categoryId?: string
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { sameOriginGroup, truncateToProcess } from './origin-name';
|
||||||
|
|
||||||
|
describe('truncateToProcess', () => {
|
||||||
|
it('keeps first 3 dash segments (drops warehouse)', () => {
|
||||||
|
expect(truncateToProcess('美国(包邮)180g纯棉T恤成人款-DG001-单面印花-美西洛杉矶一仓'))
|
||||||
|
.toBe('美国(包邮)180g纯棉T恤成人款-DG001-单面印花');
|
||||||
|
});
|
||||||
|
it('is identity for 3-segment names', () => {
|
||||||
|
expect(truncateToProcess('美国(不包邮)230g水洗炒雪花T恤-KRHM003-直喷双面'))
|
||||||
|
.toBe('美国(不包邮)230g水洗炒雪花T恤-KRHM003-直喷双面');
|
||||||
|
});
|
||||||
|
it('handles malformed names without country prefix', () => {
|
||||||
|
expect(truncateToProcess('(不包邮)230g水洗炒雪花T恤-FRTM001-双面印花'))
|
||||||
|
.toBe('(不包邮)230g水洗炒雪花T恤-FRTM001-双面印花');
|
||||||
|
});
|
||||||
|
it('returns empty for null/empty', () => {
|
||||||
|
expect(truncateToProcess(null)).toBe('');
|
||||||
|
expect(truncateToProcess('')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sameOriginGroup', () => {
|
||||||
|
it('matches same product across warehouses', () => {
|
||||||
|
expect(sameOriginGroup(
|
||||||
|
'美国(包邮)180g纯棉T恤成人款-DG001-单面印花-美西洛杉矶一仓',
|
||||||
|
'美国(包邮)180g纯棉T恤成人款-DG001-单面印花-美中亚特兰大仓',
|
||||||
|
)).toBe(true);
|
||||||
|
});
|
||||||
|
it('does not match different SKU', () => {
|
||||||
|
expect(sameOriginGroup(
|
||||||
|
'美国(包邮)180g纯棉T恤成人款-DG001-单面印花',
|
||||||
|
'美国(包邮)180g纯棉T恤成人款-DG002-单面印花',
|
||||||
|
)).toBe(false);
|
||||||
|
});
|
||||||
|
it('does not match different country', () => {
|
||||||
|
expect(sameOriginGroup(
|
||||||
|
'美国(包邮)T恤-DG001-单面印花',
|
||||||
|
'德国(不包邮)T恤-DG001-单面印花',
|
||||||
|
)).toBe(false);
|
||||||
|
});
|
||||||
|
it('does not match empty names', () => {
|
||||||
|
expect(sameOriginGroup(null, 'x-y-z')).toBe(false);
|
||||||
|
expect(sameOriginGroup('', '')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* 原产品名匹配规则(见 data-cleaning/README.md 命名调查):
|
||||||
|
* `国家(物流备注)品名-SKU-工艺位置[-仓库名]`
|
||||||
|
* 截断到「工艺位置」= 保留按 `-` 分段的前 3 段,丢弃可选的仓库名段。
|
||||||
|
* 国家前缀在第 1 段内,天然参与比较(不同国家不合并)。
|
||||||
|
*/
|
||||||
|
const KEEP_SEGMENTS = 3;
|
||||||
|
|
||||||
|
export function truncateToProcess(name: string | null | undefined): string {
|
||||||
|
if (!name) return '';
|
||||||
|
return name.split('-').slice(0, KEEP_SEGMENTS).join('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameOriginGroup(
|
||||||
|
a: string | null | undefined,
|
||||||
|
b: string | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
const ka = truncateToProcess(a);
|
||||||
|
return ka !== '' && ka === truncateToProcess(b);
|
||||||
|
}
|
||||||
@@ -1,246 +1,245 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Plus, Edit, Delete, Refresh } from '@element-plus/icons-vue'
|
import { Plus, Edit, Delete, Refresh } from '@element-plus/icons-vue'
|
||||||
import type {
|
import type {
|
||||||
Category,
|
Category,
|
||||||
CategoryTree,
|
CategoryTree,
|
||||||
CreateCategoryRequest,
|
CreateCategoryRequest,
|
||||||
UpdateCategoryRequest,
|
UpdateCategoryRequest,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
import { categoriesApi } from '@/api/categories'
|
import { categoriesApi } from '@/api/categories'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const tree = ref<CategoryTree[]>([])
|
const tree = ref<CategoryTree[]>([])
|
||||||
|
|
||||||
async function fetchTree() {
|
async function fetchTree() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
tree.value = await categoriesApi.getCategoryTree()
|
tree.value = await categoriesApi.getCategoryTree()
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Cascader ----------
|
// ---------- Cascader ----------
|
||||||
interface CascaderNode {
|
interface CascaderNode {
|
||||||
value: string
|
value: string
|
||||||
label: string
|
label: string
|
||||||
children?: CascaderNode[]
|
children?: CascaderNode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const cascaderOptions = ref<CascaderNode[]>([])
|
const cascaderOptions = ref<CascaderNode[]>([])
|
||||||
|
|
||||||
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
|
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
|
||||||
return nodes.map((n) => ({
|
return nodes.map((n) => ({
|
||||||
value: n.id,
|
value: n.id,
|
||||||
label: n.categoryName,
|
label: n.categoryName,
|
||||||
children: n.children?.length ? buildCascader(n.children) : undefined,
|
children: n.children?.length ? buildCascader(n.children) : undefined,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function rebuildCascader() {
|
function rebuildCascader() {
|
||||||
cascaderOptions.value = buildCascader(tree.value)
|
cascaderOptions.value = buildCascader(tree.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Dialog ----------
|
// ---------- Dialog ----------
|
||||||
const dialogRef = ref()
|
const dialogRef = ref()
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
const dialogMode = ref<'create' | 'edit'>('create')
|
const dialogMode = ref<'create' | 'edit'>('create')
|
||||||
const dialogLoading = ref(false)
|
const dialogLoading = ref(false)
|
||||||
|
|
||||||
const dialogForm = reactive<CreateCategoryRequest & { id?: string }>({
|
const dialogForm = reactive<CreateCategoryRequest & { id?: string }>({
|
||||||
categoryName: '',
|
categoryName: '',
|
||||||
categoryIcon: '',
|
categoryIcon: '',
|
||||||
parentCategoryId: '',
|
})
|
||||||
})
|
|
||||||
|
const dialogRules = {
|
||||||
const dialogRules = {
|
categoryName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
|
||||||
categoryName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
|
}
|
||||||
}
|
|
||||||
|
async function openAddDialog() {
|
||||||
async function openAddDialog() {
|
dialogMode.value = 'create'
|
||||||
dialogMode.value = 'create'
|
Object.assign(dialogForm, { id: undefined, categoryName: '', categoryIcon: '', parentCategoryId: '' })
|
||||||
Object.assign(dialogForm, { id: undefined, categoryName: '', categoryIcon: '', parentCategoryId: '' })
|
rebuildCascader()
|
||||||
rebuildCascader()
|
dialogVisible.value = true
|
||||||
dialogVisible.value = true
|
}
|
||||||
}
|
|
||||||
|
async function openEditDialog(c: Category) {
|
||||||
async function openEditDialog(c: Category) {
|
dialogMode.value = 'edit'
|
||||||
dialogMode.value = 'edit'
|
Object.assign(dialogForm, {
|
||||||
Object.assign(dialogForm, {
|
id: c.id,
|
||||||
id: c.id,
|
categoryName: c.categoryName,
|
||||||
categoryName: c.categoryName,
|
categoryIcon: c.categoryIcon || '',
|
||||||
categoryIcon: c.categoryIcon || '',
|
parentCategoryId: c.parentCategoryId || '',
|
||||||
parentCategoryId: c.parentCategoryId || '',
|
})
|
||||||
})
|
rebuildCascader()
|
||||||
rebuildCascader()
|
dialogVisible.value = true
|
||||||
dialogVisible.value = true
|
}
|
||||||
}
|
|
||||||
|
async function handleSubmit() {
|
||||||
async function handleSubmit() {
|
if (!dialogRef.value) return
|
||||||
if (!dialogRef.value) return
|
await dialogRef.value.validate(async (valid: boolean) => {
|
||||||
await dialogRef.value.validate(async (valid: boolean) => {
|
if (!valid) return
|
||||||
if (!valid) return
|
dialogLoading.value = true
|
||||||
dialogLoading.value = true
|
try {
|
||||||
try {
|
const payload: CreateCategoryRequest = {
|
||||||
const payload: CreateCategoryRequest = {
|
categoryName: dialogForm.categoryName,
|
||||||
categoryName: dialogForm.categoryName,
|
categoryIcon: dialogForm.categoryIcon || undefined,
|
||||||
categoryIcon: dialogForm.categoryIcon || undefined,
|
parentCategoryId: dialogForm.parentCategoryId || undefined,
|
||||||
parentCategoryId: dialogForm.parentCategoryId || undefined,
|
}
|
||||||
}
|
if (dialogMode.value === 'create') {
|
||||||
if (dialogMode.value === 'create') {
|
await categoriesApi.createCategory(payload)
|
||||||
await categoriesApi.createCategory(payload)
|
ElMessage.success('分类创建成功')
|
||||||
ElMessage.success('分类创建成功')
|
} else {
|
||||||
} else {
|
await categoriesApi.updateCategory(dialogForm.id!, payload as UpdateCategoryRequest)
|
||||||
await categoriesApi.updateCategory(dialogForm.id!, payload as UpdateCategoryRequest)
|
ElMessage.success('分类更新成功')
|
||||||
ElMessage.success('分类更新成功')
|
}
|
||||||
}
|
dialogVisible.value = false
|
||||||
dialogVisible.value = false
|
fetchTree()
|
||||||
fetchTree()
|
} finally {
|
||||||
} finally {
|
dialogLoading.value = false
|
||||||
dialogLoading.value = false
|
}
|
||||||
}
|
})
|
||||||
})
|
}
|
||||||
}
|
|
||||||
|
async function handleDelete(c: Category) {
|
||||||
async function handleDelete(c: Category) {
|
try {
|
||||||
try {
|
await ElMessageBox.confirm(`确定删除「${c.categoryName}」吗?`, '确认', {
|
||||||
await ElMessageBox.confirm(`确定删除「${c.categoryName}」吗?`, '确认', {
|
type: 'warning',
|
||||||
type: 'warning',
|
confirmButtonText: '删除',
|
||||||
confirmButtonText: '删除',
|
cancelButtonText: '取消',
|
||||||
cancelButtonText: '取消',
|
})
|
||||||
})
|
} catch {
|
||||||
} catch {
|
return
|
||||||
return
|
}
|
||||||
}
|
await categoriesApi.deleteCategory(c.id)
|
||||||
await categoriesApi.deleteCategory(c.id)
|
ElMessage.success('删除成功')
|
||||||
ElMessage.success('删除成功')
|
fetchTree()
|
||||||
fetchTree()
|
}
|
||||||
}
|
|
||||||
|
onMounted(fetchTree)
|
||||||
onMounted(fetchTree)
|
</script>
|
||||||
</script>
|
|
||||||
|
<template>
|
||||||
<template>
|
<div class="page-container">
|
||||||
<div class="page-container">
|
<div class="page-card">
|
||||||
<div class="page-card">
|
<div class="toolbar">
|
||||||
<div class="toolbar">
|
<el-button type="primary" @click="openAddDialog">
|
||||||
<el-button type="primary" @click="openAddDialog">
|
<el-icon><Plus /></el-icon>
|
||||||
<el-icon><Plus /></el-icon>
|
<span>新增分类</span>
|
||||||
<span>新增分类</span>
|
</el-button>
|
||||||
</el-button>
|
<el-button @click="fetchTree">
|
||||||
<el-button @click="fetchTree">
|
<el-icon><Refresh /></el-icon>
|
||||||
<el-icon><Refresh /></el-icon>
|
<span>刷新</span>
|
||||||
<span>刷新</span>
|
</el-button>
|
||||||
</el-button>
|
</div>
|
||||||
</div>
|
|
||||||
|
<el-table
|
||||||
<el-table
|
v-loading="loading"
|
||||||
v-loading="loading"
|
:data="tree"
|
||||||
:data="tree"
|
border
|
||||||
border
|
row-key="id"
|
||||||
row-key="id"
|
:tree-props="{ children: 'children' }"
|
||||||
:tree-props="{ children: 'children' }"
|
default-expand-all
|
||||||
default-expand-all
|
>
|
||||||
>
|
<el-table-column prop="categoryName" label="名称" min-width="220" />
|
||||||
<el-table-column prop="categoryName" label="名称" min-width="220" />
|
<el-table-column label="图标" width="100">
|
||||||
<el-table-column label="图标" width="100">
|
<template #default="{ row }: any">
|
||||||
<template #default="{ row }: { row: CategoryTree }">
|
<el-image
|
||||||
<el-image
|
v-if="row.categoryIcon"
|
||||||
v-if="row.categoryIcon"
|
:src="row.categoryIcon"
|
||||||
:src="row.categoryIcon"
|
:preview-src-list="[row.categoryIcon]"
|
||||||
:preview-src-list="[row.categoryIcon]"
|
fit="cover"
|
||||||
fit="cover"
|
style="width: 32px; height: 32px; border-radius: 4px;"
|
||||||
style="width: 32px; height: 32px; border-radius: 4px;"
|
/>
|
||||||
/>
|
<span v-else>-</span>
|
||||||
<span v-else>-</span>
|
</template>
|
||||||
</template>
|
</el-table-column>
|
||||||
</el-table-column>
|
<el-table-column label="父级">
|
||||||
<el-table-column label="父级">
|
<template #default="{ row }: any">
|
||||||
<template #default="{ row }: { row: CategoryTree }">
|
{{ row.parent?.categoryName || '-' }}
|
||||||
{{ row.parent?.categoryName || '-' }}
|
</template>
|
||||||
</template>
|
</el-table-column>
|
||||||
</el-table-column>
|
<el-table-column label="子级数" width="100">
|
||||||
<el-table-column label="子级数" width="100">
|
<template #default="{ row }: any">
|
||||||
<template #default="{ row }: { row: CategoryTree }">
|
{{ row._count?.children ?? row.children?.length ?? 0 }}
|
||||||
{{ row._count?.children ?? row.children?.length ?? 0 }}
|
</template>
|
||||||
</template>
|
</el-table-column>
|
||||||
</el-table-column>
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
<el-table-column label="操作" width="180" fixed="right">
|
<template #default="{ row }: any">
|
||||||
<template #default="{ row }: { row: CategoryTree }">
|
<div class="table-actions">
|
||||||
<div class="table-actions">
|
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
<el-icon><Edit /></el-icon>
|
||||||
<el-icon><Edit /></el-icon>
|
<span>编辑</span>
|
||||||
<span>编辑</span>
|
</el-button>
|
||||||
</el-button>
|
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
<el-icon><Delete /></el-icon>
|
||||||
<el-icon><Delete /></el-icon>
|
<span>删除</span>
|
||||||
<span>删除</span>
|
</el-button>
|
||||||
</el-button>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</template>
|
</el-table-column>
|
||||||
</el-table-column>
|
<template #empty>
|
||||||
<template #empty>
|
<el-empty description="暂无分类" />
|
||||||
<el-empty description="暂无分类" />
|
</template>
|
||||||
</template>
|
</el-table>
|
||||||
</el-table>
|
</div>
|
||||||
</div>
|
|
||||||
|
<el-dialog
|
||||||
<el-dialog
|
v-model="dialogVisible"
|
||||||
v-model="dialogVisible"
|
:title="dialogMode === 'create' ? '新增分类' : '编辑分类'"
|
||||||
:title="dialogMode === 'create' ? '新增分类' : '编辑分类'"
|
width="520px"
|
||||||
width="520px"
|
destroy-on-close
|
||||||
destroy-on-close
|
>
|
||||||
>
|
<el-form
|
||||||
<el-form
|
ref="dialogRef"
|
||||||
ref="dialogRef"
|
:model="dialogForm"
|
||||||
:model="dialogForm"
|
:rules="dialogRules"
|
||||||
:rules="dialogRules"
|
label-width="100px"
|
||||||
label-width="100px"
|
>
|
||||||
>
|
<el-form-item label="名称" prop="categoryName">
|
||||||
<el-form-item label="名称" prop="categoryName">
|
<el-input v-model="dialogForm.categoryName" placeholder="请输入分类名称" />
|
||||||
<el-input v-model="dialogForm.categoryName" placeholder="请输入分类名称" />
|
</el-form-item>
|
||||||
</el-form-item>
|
<el-form-item label="父级分类">
|
||||||
<el-form-item label="父级分类">
|
<el-cascader
|
||||||
<el-cascader
|
v-model="dialogForm.parentCategoryId"
|
||||||
v-model="dialogForm.parentCategoryId"
|
:options="cascaderOptions"
|
||||||
:options="cascaderOptions"
|
:props="{
|
||||||
:props="{
|
checkStrictly: true,
|
||||||
checkStrictly: true,
|
value: 'value',
|
||||||
value: 'value',
|
label: 'label',
|
||||||
label: 'label',
|
children: 'children',
|
||||||
children: 'children',
|
emitPath: false,
|
||||||
emitPath: false,
|
}"
|
||||||
}"
|
placeholder="顶级(可选)"
|
||||||
placeholder="顶级(可选)"
|
clearable
|
||||||
clearable
|
style="width: 100%"
|
||||||
style="width: 100%"
|
/>
|
||||||
/>
|
</el-form-item>
|
||||||
</el-form-item>
|
<el-form-item label="图标 URL">
|
||||||
<el-form-item label="图标 URL">
|
<el-input v-model="dialogForm.categoryIcon" placeholder="https://..." />
|
||||||
<el-input v-model="dialogForm.categoryIcon" placeholder="https://..." />
|
</el-form-item>
|
||||||
</el-form-item>
|
</el-form>
|
||||||
</el-form>
|
<template #footer>
|
||||||
<template #footer>
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
<el-button @click="dialogVisible = false">取消</el-button>
|
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
</el-button>
|
||||||
</el-button>
|
</template>
|
||||||
</template>
|
</el-dialog>
|
||||||
</el-dialog>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</template>
|
|
||||||
|
<style scoped>
|
||||||
<style scoped>
|
.toolbar {
|
||||||
.toolbar {
|
display: flex;
|
||||||
display: flex;
|
gap: 8px;
|
||||||
gap: 8px;
|
margin-bottom: 16px;
|
||||||
margin-bottom: 16px;
|
}
|
||||||
}
|
|
||||||
|
.pagination {
|
||||||
.pagination {
|
margin-top: 16px;
|
||||||
margin-top: 16px;
|
justify-content: flex-end;
|
||||||
justify-content: flex-end;
|
}
|
||||||
}
|
</style>
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,231 +1,231 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
|
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
|
||||||
import type {
|
import type {
|
||||||
Country,
|
Country,
|
||||||
CreateCountryRequest,
|
CreateCountryRequest,
|
||||||
UpdateCountryRequest,
|
UpdateCountryRequest,
|
||||||
CountryFilter,
|
CountryFilter,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
import { countriesApi } from '@/api/countries'
|
import { countriesApi } from '@/api/countries'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const list = ref<Country[]>([])
|
const list = ref<Country[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
|
|
||||||
const filter = reactive<Required<CountryFilter>>({
|
const filter = reactive<Required<CountryFilter>>({
|
||||||
countryName: '',
|
countryName: '',
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
})
|
})
|
||||||
|
|
||||||
async function fetchList() {
|
async function fetchList() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await countriesApi.getCountriesList(filter)
|
await countriesApi.getCountriesList(filter)
|
||||||
const data = await countriesApi.getCountriesList(filter) as any
|
const data = await countriesApi.getCountriesList(filter) as any
|
||||||
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||||
list.value = arr
|
list.value = arr
|
||||||
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
|
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSearch() {
|
function handleSearch() {
|
||||||
filter.page = 1
|
filter.page = 1
|
||||||
fetchList()
|
fetchList()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleReset() {
|
function handleReset() {
|
||||||
filter.countryName = ''
|
filter.countryName = ''
|
||||||
filter.page = 1
|
filter.page = 1
|
||||||
fetchList()
|
fetchList()
|
||||||
}
|
}
|
||||||
|
|
||||||
const dialogRef = ref()
|
const dialogRef = ref()
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
const dialogMode = ref<'create' | 'edit'>('create')
|
const dialogMode = ref<'create' | 'edit'>('create')
|
||||||
const dialogLoading = ref(false)
|
const dialogLoading = ref(false)
|
||||||
|
|
||||||
const dialogForm = reactive<CreateCountryRequest & { id?: string }>({
|
const dialogForm = reactive<CreateCountryRequest & { id?: string }>({
|
||||||
countryName: '',
|
countryName: '',
|
||||||
countryIcon: '',
|
countryIcon: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const dialogRules = {
|
const dialogRules = {
|
||||||
countryName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
|
countryName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAddDialog() {
|
function openAddDialog() {
|
||||||
dialogMode.value = 'create'
|
dialogMode.value = 'create'
|
||||||
Object.assign(dialogForm, { id: undefined, countryName: '', countryIcon: '' })
|
Object.assign(dialogForm, { id: undefined, countryName: '', countryIcon: '' })
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEditDialog(c: Country) {
|
function openEditDialog(c: Country) {
|
||||||
dialogMode.value = 'edit'
|
dialogMode.value = 'edit'
|
||||||
Object.assign(dialogForm, { id: c.id, countryName: c.countryName, countryIcon: c.countryIcon || '' })
|
Object.assign(dialogForm, { id: c.id, countryName: c.countryName, countryIcon: c.countryIcon || '' })
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
if (!dialogRef.value) return
|
if (!dialogRef.value) return
|
||||||
await dialogRef.value.validate(async (valid: boolean) => {
|
await dialogRef.value.validate(async (valid: boolean) => {
|
||||||
if (!valid) return
|
if (!valid) return
|
||||||
dialogLoading.value = true
|
dialogLoading.value = true
|
||||||
try {
|
try {
|
||||||
const payload: CreateCountryRequest = {
|
const payload: CreateCountryRequest = {
|
||||||
countryName: dialogForm.countryName,
|
countryName: dialogForm.countryName,
|
||||||
countryIcon: dialogForm.countryIcon || undefined,
|
countryIcon: dialogForm.countryIcon || undefined,
|
||||||
}
|
}
|
||||||
if (dialogMode.value === 'create') {
|
if (dialogMode.value === 'create') {
|
||||||
await countriesApi.createCountry(payload)
|
await countriesApi.createCountry(payload)
|
||||||
ElMessage.success('国家创建成功')
|
ElMessage.success('国家创建成功')
|
||||||
} else {
|
} else {
|
||||||
await countriesApi.updateCountry(dialogForm.id!, payload as UpdateCountryRequest)
|
await countriesApi.updateCountry(dialogForm.id!, payload as UpdateCountryRequest)
|
||||||
ElMessage.success('国家更新成功')
|
ElMessage.success('国家更新成功')
|
||||||
}
|
}
|
||||||
dialogVisible.value = false
|
dialogVisible.value = false
|
||||||
fetchList()
|
fetchList()
|
||||||
} finally {
|
} finally {
|
||||||
dialogLoading.value = false
|
dialogLoading.value = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(c: Country) {
|
async function handleDelete(c: Country) {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(`确定删除「${c.countryName}」吗?`, '确认', {
|
await ElMessageBox.confirm(`确定删除「${c.countryName}」吗?`, '确认', {
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
confirmButtonText: '删除',
|
confirmButtonText: '删除',
|
||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await countriesApi.deleteCountry(c.id)
|
await countriesApi.deleteCountry(c.id)
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success('删除成功')
|
||||||
fetchList()
|
fetchList()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(fetchList)
|
onMounted(fetchList)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<div class="page-card">
|
<div class="page-card">
|
||||||
<div class="filter-bar">
|
<div class="filter-bar">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="filter.countryName"
|
v-model="filter.countryName"
|
||||||
placeholder="按名称搜索"
|
placeholder="按名称搜索"
|
||||||
clearable
|
clearable
|
||||||
@keyup.enter="handleSearch"
|
@keyup.enter="handleSearch"
|
||||||
@clear="handleSearch"
|
@clear="handleSearch"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<el-icon><Search /></el-icon>
|
<el-icon><Search /></el-icon>
|
||||||
</template>
|
</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
<el-button type="primary" @click="handleSearch">
|
<el-button type="primary" @click="handleSearch">
|
||||||
<el-icon><Search /></el-icon>
|
<el-icon><Search /></el-icon>
|
||||||
<span>搜索</span>
|
<span>搜索</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button @click="handleReset">
|
<el-button @click="handleReset">
|
||||||
<el-icon><Refresh /></el-icon>
|
<el-icon><Refresh /></el-icon>
|
||||||
<span>重置</span>
|
<span>重置</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
|
|
||||||
<div class="filter-spacer" />
|
<div class="filter-spacer" />
|
||||||
|
|
||||||
<el-button type="primary" @click="openAddDialog">
|
<el-button type="primary" @click="openAddDialog">
|
||||||
<el-icon><Plus /></el-icon>
|
<el-icon><Plus /></el-icon>
|
||||||
<span>新增国家</span>
|
<span>新增国家</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="list" border stripe>
|
<el-table v-loading="loading" :data="list" border stripe>
|
||||||
<el-table-column label="图标" width="80">
|
<el-table-column label="图标" width="80">
|
||||||
<template #default="{ row }: { row: Country }">
|
<template #default="{ row }: any">
|
||||||
<el-image
|
<el-image
|
||||||
v-if="row.countryIcon"
|
v-if="row.countryIcon"
|
||||||
:src="row.countryIcon"
|
:src="row.countryIcon"
|
||||||
:preview-src-list="[row.countryIcon]"
|
:preview-src-list="[row.countryIcon]"
|
||||||
fit="cover"
|
fit="cover"
|
||||||
style="width: 32px; height: 32px; border-radius: 4px;"
|
style="width: 32px; height: 32px; border-radius: 4px;"
|
||||||
/>
|
/>
|
||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="countryName" label="名称" min-width="200" />
|
<el-table-column prop="countryName" label="名称" min-width="200" />
|
||||||
<el-table-column label="创建时间" width="180">
|
<el-table-column label="创建时间" width="180">
|
||||||
<template #default="{ row }: { row: Country }">
|
<template #default="{ row }: any">
|
||||||
{{ new Date(row.createdAt).toLocaleString() }}
|
{{ new Date(row.createdAt).toLocaleString() }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="180" fixed="right">
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
<template #default="{ row }: { row: Country }">
|
<template #default="{ row }: any">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||||
<el-icon><Edit /></el-icon>
|
<el-icon><Edit /></el-icon>
|
||||||
<span>编辑</span>
|
<span>编辑</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||||
<el-icon><Delete /></el-icon>
|
<el-icon><Delete /></el-icon>
|
||||||
<span>删除</span>
|
<span>删除</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<template #empty>
|
<template #empty>
|
||||||
<el-empty description="暂无国家" />
|
<el-empty description="暂无国家" />
|
||||||
</template>
|
</template>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<el-pagination
|
<el-pagination
|
||||||
class="pagination"
|
class="pagination"
|
||||||
v-model:current-page="filter.page"
|
v-model:current-page="filter.page"
|
||||||
v-model:page-size="filter.pageSize"
|
v-model:page-size="filter.pageSize"
|
||||||
:total="total"
|
:total="total"
|
||||||
:page-sizes="[10, 20, 50, 100]"
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
@current-change="(p: number) => { filter.page = p; fetchList() }"
|
@current-change="(p: number) => { filter.page = p; fetchList() }"
|
||||||
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
|
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="dialogVisible"
|
v-model="dialogVisible"
|
||||||
:title="dialogMode === 'create' ? '新增国家' : '编辑国家'"
|
:title="dialogMode === 'create' ? '新增国家' : '编辑国家'"
|
||||||
width="480px"
|
width="480px"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
>
|
>
|
||||||
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
||||||
<el-form-item label="名称" prop="countryName">
|
<el-form-item label="名称" prop="countryName">
|
||||||
<el-input v-model="dialogForm.countryName" placeholder="请输入国家名称" />
|
<el-input v-model="dialogForm.countryName" placeholder="请输入国家名称" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="图标 URL">
|
<el-form-item label="图标 URL">
|
||||||
<el-input v-model="dialogForm.countryIcon" placeholder="https://..." />
|
<el-input v-model="dialogForm.countryIcon" placeholder="https://..." />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="dialogVisible = false">取消</el-button>
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.filter-spacer {
|
.filter-spacer {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination {
|
.pagination {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,181 +1,181 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref } from 'vue'
|
import { reactive, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const formRef = ref<FormInstance>()
|
const formRef = ref<FormInstance>()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const rules: FormRules = {
|
const rules: FormRules = {
|
||||||
username: [
|
username: [
|
||||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||||
{ min: 2, max: 64, message: '长度 2-64', trigger: 'blur' },
|
{ min: 2, max: 64, message: '长度 2-64', trigger: 'blur' },
|
||||||
],
|
],
|
||||||
password: [
|
password: [
|
||||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||||
{ min: 4, max: 64, message: '长度 4-64', trigger: 'blur' },
|
{ min: 4, max: 64, message: '长度 4-64', trigger: 'blur' },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
if (!formRef.value) return
|
if (!formRef.value) return
|
||||||
await formRef.value.validate(async (valid) => {
|
await formRef.value.validate(async (valid) => {
|
||||||
if (!valid) return
|
if (!valid) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await authStore.login({ username: form.username, password: form.password })
|
await authStore.login({ username: form.username, password: form.password })
|
||||||
ElMessage.success('登录成功')
|
ElMessage.success('登录成功')
|
||||||
router.push('/')
|
router.push('/')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Error toast is shown by axios response interceptor
|
// Error toast is shown by axios response interceptor
|
||||||
console.error('Login failed', err)
|
console.error('Login failed', err)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="login-page">
|
<div class="login-page">
|
||||||
<div class="login-bg" />
|
<div class="login-bg" />
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<div class="login-brand">
|
<div class="login-brand">
|
||||||
<div class="brand-logo">Inkreach</div>
|
<div class="brand-logo">Inkreach</div>
|
||||||
<div class="brand-subtitle">官网后台</div>
|
<div class="brand-subtitle">官网后台</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-form
|
<el-form
|
||||||
ref="formRef"
|
ref="formRef"
|
||||||
:model="form"
|
:model="form"
|
||||||
:rules="rules"
|
:rules="rules"
|
||||||
size="large"
|
size="large"
|
||||||
label-position="top"
|
label-position="top"
|
||||||
@submit.prevent="handleSubmit"
|
@submit.prevent="handleSubmit"
|
||||||
>
|
>
|
||||||
<el-form-item label="用户名" prop="username">
|
<el-form-item label="用户名" prop="username">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="form.username"
|
v-model="form.username"
|
||||||
placeholder="请输入用户名"
|
placeholder="请输入用户名"
|
||||||
clearable
|
clearable
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<el-icon><User /></el-icon>
|
<el-icon><User /></el-icon>
|
||||||
</template>
|
</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="密码" prop="password">
|
<el-form-item label="密码" prop="password">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="form.password"
|
v-model="form.password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="请输入密码"
|
placeholder="请输入密码"
|
||||||
show-password
|
show-password
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
@keyup.enter="handleSubmit"
|
@keyup.enter="handleSubmit"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<el-icon><Lock /></el-icon>
|
<el-icon><Lock /></el-icon>
|
||||||
</template>
|
</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
type="primary"
|
||||||
class="login-button"
|
class="login-button"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
native-type="submit"
|
native-type="submit"
|
||||||
@click="handleSubmit"
|
@click="handleSubmit"
|
||||||
>
|
>
|
||||||
登录
|
登录
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<div class="login-footer">
|
<div class="login-footer">
|
||||||
<span>© {{ new Date().getFullYear() }} Inkreach</span>
|
<span>© {{ new Date().getFullYear() }} Inkreach</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.login-page {
|
.login-page {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-bg {
|
.login-bg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 20% 20%, rgba(255, 104, 0, 0.18), transparent 60%),
|
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%),
|
radial-gradient(circle at 80% 80%, rgba(255, 141, 31, 0.15), transparent 60%),
|
||||||
linear-gradient(135deg, #1f1f24 0%, #2b2b33 100%);
|
linear-gradient(135deg, #1f1f24 0%, #2b2b33 100%);
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card {
|
.login-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
width: 420px;
|
width: 420px;
|
||||||
max-width: calc(100vw - 32px);
|
max-width: calc(100vw - 32px);
|
||||||
padding: 40px 36px 28px;
|
padding: 40px 36px 28px;
|
||||||
background: rgba(255, 255, 255, 0.97);
|
background: rgba(255, 255, 255, 0.97);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.18);
|
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-brand {
|
.login-brand {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-logo {
|
.brand-logo {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--brand-color);
|
color: var(--brand-color);
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-subtitle {
|
.brand-subtitle {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-button {
|
.login-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-footer {
|
.login-footer {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-form-item__label) {
|
:deep(.el-form-item__label) {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #374151;
|
color: #374151;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,317 +1,314 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
|
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
|
||||||
import type {
|
import type {
|
||||||
Position,
|
Position,
|
||||||
CreatePositionRequest,
|
CreatePositionRequest,
|
||||||
UpdatePositionRequest,
|
UpdatePositionRequest,
|
||||||
PositionFilter,
|
PositionFilter,
|
||||||
Country,
|
Country,
|
||||||
Category,
|
CategoryTree,
|
||||||
CategoryTree,
|
} from '@/types'
|
||||||
} from '@/types'
|
import { positionsApi } from '@/api/positions'
|
||||||
import { positionsApi } from '@/api/positions'
|
import { countriesApi } from '@/api/countries'
|
||||||
import { countriesApi } from '@/api/countries'
|
import { categoriesApi } from '@/api/categories'
|
||||||
import { categoriesApi } from '@/api/categories'
|
|
||||||
|
const loading = ref(false)
|
||||||
const loading = ref(false)
|
const list = ref<Position[]>([])
|
||||||
const list = ref<Position[]>([])
|
const total = ref(0)
|
||||||
const total = ref(0)
|
|
||||||
|
const countries = ref<Country[]>([])
|
||||||
const countries = ref<Country[]>([])
|
const categoriesTree = ref<CategoryTree[]>([])
|
||||||
const categoriesTree = ref<CategoryTree[]>([])
|
|
||||||
|
const filter = reactive<Required<PositionFilter>>({
|
||||||
const filter = reactive<Required<PositionFilter>>({
|
countryId: '',
|
||||||
countryId: '',
|
categoryId: '',
|
||||||
categoryId: '',
|
page: 1,
|
||||||
page: 1,
|
pageSize: 10,
|
||||||
pageSize: 10,
|
})
|
||||||
})
|
|
||||||
|
async function loadLookups() {
|
||||||
async function loadLookups() {
|
const [c, ct] = await Promise.all([
|
||||||
const [c, ct] = await Promise.all([
|
countriesApi.getCountriesList({ page: 1, pageSize: 500 }),
|
||||||
countriesApi.getCountriesList({ page: 1, pageSize: 500 }),
|
categoriesApi.getCategoryTree(),
|
||||||
categoriesApi.getCategoryTree(),
|
])
|
||||||
])
|
countries.value = c.items
|
||||||
countries.value = c.items
|
categoriesTree.value = ct
|
||||||
categoriesTree.value = ct
|
}
|
||||||
}
|
|
||||||
|
async function fetchList() {
|
||||||
async function fetchList() {
|
loading.value = true
|
||||||
loading.value = true
|
try {
|
||||||
try {
|
await positionsApi.getPositionsList(filter)
|
||||||
const res = await positionsApi.getPositionsList(filter)
|
const data = await positionsApi.getPositionsList(filter) as any
|
||||||
const data = await positionsApi.getPositionsList(filter) as any
|
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||||
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
list.value = arr
|
||||||
list.value = arr
|
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
|
||||||
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
|
} finally {
|
||||||
} finally {
|
loading.value = false
|
||||||
loading.value = false
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
function handleSearch() {
|
||||||
function handleSearch() {
|
filter.page = 1
|
||||||
filter.page = 1
|
fetchList()
|
||||||
fetchList()
|
}
|
||||||
}
|
|
||||||
|
function handleReset() {
|
||||||
function handleReset() {
|
filter.countryId = ''
|
||||||
filter.countryId = ''
|
filter.categoryId = ''
|
||||||
filter.categoryId = ''
|
filter.page = 1
|
||||||
filter.page = 1
|
fetchList()
|
||||||
fetchList()
|
}
|
||||||
}
|
|
||||||
|
// ---------- Cascader ----------
|
||||||
// ---------- Cascader ----------
|
interface CascaderNode {
|
||||||
interface CascaderNode {
|
value: string
|
||||||
value: string
|
label: string
|
||||||
label: string
|
children?: CascaderNode[]
|
||||||
children?: CascaderNode[]
|
}
|
||||||
}
|
|
||||||
|
const cascaderOptions = ref<CascaderNode[]>([])
|
||||||
const cascaderOptions = ref<CascaderNode[]>([])
|
|
||||||
|
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
|
||||||
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
|
return nodes.map((n) => ({
|
||||||
return nodes.map((n) => ({
|
value: n.id,
|
||||||
value: n.id,
|
label: n.categoryName,
|
||||||
label: n.categoryName,
|
children: n.children?.length ? buildCascader(n.children) : undefined,
|
||||||
children: n.children?.length ? buildCascader(n.children) : undefined,
|
}))
|
||||||
}))
|
}
|
||||||
}
|
|
||||||
|
// ---------- Dialog ----------
|
||||||
// ---------- Dialog ----------
|
const dialogRef = ref()
|
||||||
const dialogRef = ref()
|
const dialogVisible = ref(false)
|
||||||
const dialogVisible = ref(false)
|
const dialogMode = ref<'create' | 'edit'>('create')
|
||||||
const dialogMode = ref<'create' | 'edit'>('create')
|
const dialogLoading = ref(false)
|
||||||
const dialogLoading = ref(false)
|
|
||||||
|
const dialogForm = reactive<CreatePositionRequest & { id?: string }>({
|
||||||
const dialogForm = reactive<CreatePositionRequest & { id?: string }>({
|
indexVal: 0,
|
||||||
indexVal: 0,
|
})
|
||||||
countryId: '',
|
|
||||||
categoryId: '',
|
const dialogRules = {
|
||||||
})
|
indexVal: [{ required: true, message: '排序值是必填项', trigger: 'blur' }],
|
||||||
|
}
|
||||||
const dialogRules = {
|
|
||||||
indexVal: [{ required: true, message: '排序值是必填项', trigger: 'blur' }],
|
function rebuildCascader() {
|
||||||
}
|
cascaderOptions.value = buildCascader(categoriesTree.value)
|
||||||
|
}
|
||||||
function rebuildCascader() {
|
|
||||||
cascaderOptions.value = buildCascader(categoriesTree.value)
|
function openAddDialog() {
|
||||||
}
|
dialogMode.value = 'create'
|
||||||
|
Object.assign(dialogForm, { id: undefined, indexVal: 0, countryId: '', categoryId: '' })
|
||||||
function openAddDialog() {
|
rebuildCascader()
|
||||||
dialogMode.value = 'create'
|
dialogVisible.value = true
|
||||||
Object.assign(dialogForm, { id: undefined, indexVal: 0, countryId: '', categoryId: '' })
|
}
|
||||||
rebuildCascader()
|
|
||||||
dialogVisible.value = true
|
function openEditDialog(p: Position) {
|
||||||
}
|
dialogMode.value = 'edit'
|
||||||
|
Object.assign(dialogForm, {
|
||||||
function openEditDialog(p: Position) {
|
id: p.id,
|
||||||
dialogMode.value = 'edit'
|
indexVal: p.indexVal,
|
||||||
Object.assign(dialogForm, {
|
countryId: p.countryId || '',
|
||||||
id: p.id,
|
categoryId: p.categoryId || '',
|
||||||
indexVal: p.indexVal,
|
})
|
||||||
countryId: p.countryId || '',
|
rebuildCascader()
|
||||||
categoryId: p.categoryId || '',
|
dialogVisible.value = true
|
||||||
})
|
}
|
||||||
rebuildCascader()
|
|
||||||
dialogVisible.value = true
|
async function handleSubmit() {
|
||||||
}
|
if (!dialogRef.value) return
|
||||||
|
await dialogRef.value.validate(async (valid: boolean) => {
|
||||||
async function handleSubmit() {
|
if (!valid) return
|
||||||
if (!dialogRef.value) return
|
dialogLoading.value = true
|
||||||
await dialogRef.value.validate(async (valid: boolean) => {
|
try {
|
||||||
if (!valid) return
|
const payload: CreatePositionRequest = {
|
||||||
dialogLoading.value = true
|
indexVal: Number(dialogForm.indexVal),
|
||||||
try {
|
countryId: dialogForm.countryId || undefined,
|
||||||
const payload: CreatePositionRequest = {
|
categoryId: dialogForm.categoryId || undefined,
|
||||||
indexVal: Number(dialogForm.indexVal),
|
}
|
||||||
countryId: dialogForm.countryId || undefined,
|
if (dialogMode.value === 'create') {
|
||||||
categoryId: dialogForm.categoryId || undefined,
|
await positionsApi.createPosition(payload)
|
||||||
}
|
ElMessage.success('位置创建成功')
|
||||||
if (dialogMode.value === 'create') {
|
} else {
|
||||||
await positionsApi.createPosition(payload)
|
await positionsApi.updatePosition(dialogForm.id!, payload as UpdatePositionRequest)
|
||||||
ElMessage.success('位置创建成功')
|
ElMessage.success('位置更新成功')
|
||||||
} else {
|
}
|
||||||
await positionsApi.updatePosition(dialogForm.id!, payload as UpdatePositionRequest)
|
dialogVisible.value = false
|
||||||
ElMessage.success('位置更新成功')
|
fetchList()
|
||||||
}
|
} finally {
|
||||||
dialogVisible.value = false
|
dialogLoading.value = false
|
||||||
fetchList()
|
}
|
||||||
} finally {
|
})
|
||||||
dialogLoading.value = false
|
}
|
||||||
}
|
|
||||||
})
|
async function handleDelete(p: Position) {
|
||||||
}
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定删除位置 #${p.indexVal} 吗?`, '确认', {
|
||||||
async function handleDelete(p: Position) {
|
type: 'warning',
|
||||||
try {
|
confirmButtonText: '删除',
|
||||||
await ElMessageBox.confirm(`确定删除位置 #${p.indexVal} 吗?`, '确认', {
|
cancelButtonText: '取消',
|
||||||
type: 'warning',
|
})
|
||||||
confirmButtonText: '删除',
|
} catch {
|
||||||
cancelButtonText: '取消',
|
return
|
||||||
})
|
}
|
||||||
} catch {
|
await positionsApi.deletePosition(p.id)
|
||||||
return
|
ElMessage.success('删除成功')
|
||||||
}
|
fetchList()
|
||||||
await positionsApi.deletePosition(p.id)
|
}
|
||||||
ElMessage.success('删除成功')
|
|
||||||
fetchList()
|
function getCategoryName(p: Position): string {
|
||||||
}
|
if (!p.category) return p.categoryId || '-'
|
||||||
|
return p.category.parent ? `${p.category.parent.categoryName} / ${p.category.categoryName}` : p.category.categoryName
|
||||||
function getCategoryName(p: Position): string {
|
}
|
||||||
if (!p.category) return p.categoryId || '-'
|
|
||||||
return p.category.parent ? `${p.category.parent.categoryName} / ${p.category.categoryName}` : p.category.categoryName
|
onMounted(async () => {
|
||||||
}
|
await loadLookups()
|
||||||
|
await fetchList()
|
||||||
onMounted(async () => {
|
})
|
||||||
await loadLookups()
|
</script>
|
||||||
await fetchList()
|
|
||||||
})
|
<template>
|
||||||
</script>
|
<div class="page-container">
|
||||||
|
<div class="page-card">
|
||||||
<template>
|
<div class="filter-bar">
|
||||||
<div class="page-container">
|
<el-select
|
||||||
<div class="page-card">
|
v-model="filter.countryId"
|
||||||
<div class="filter-bar">
|
placeholder="请选择国家"
|
||||||
<el-select
|
clearable
|
||||||
v-model="filter.countryId"
|
@change="handleSearch"
|
||||||
placeholder="请选择国家"
|
>
|
||||||
clearable
|
<el-option
|
||||||
@change="handleSearch"
|
v-for="c in countries"
|
||||||
>
|
:key="c.id"
|
||||||
<el-option
|
:label="c.countryName"
|
||||||
v-for="c in countries"
|
:value="c.id"
|
||||||
:key="c.id"
|
/>
|
||||||
:label="c.countryName"
|
</el-select>
|
||||||
:value="c.id"
|
|
||||||
/>
|
<el-cascader
|
||||||
</el-select>
|
v-model="filter.categoryId"
|
||||||
|
:options="cascaderOptions"
|
||||||
<el-cascader
|
:props="{ checkStrictly: true, value: 'value', label: 'label', children: 'children', emitPath: false }"
|
||||||
v-model="filter.categoryId"
|
placeholder="请选择分类"
|
||||||
:options="cascaderOptions"
|
clearable
|
||||||
:props="{ checkStrictly: true, value: 'value', label: 'label', children: 'children', emitPath: false }"
|
@change="handleSearch"
|
||||||
placeholder="请选择分类"
|
/>
|
||||||
clearable
|
|
||||||
@change="handleSearch"
|
<el-button type="primary" @click="handleSearch">
|
||||||
/>
|
<el-icon><Search /></el-icon>
|
||||||
|
<span>搜索</span>
|
||||||
<el-button type="primary" @click="handleSearch">
|
</el-button>
|
||||||
<el-icon><Search /></el-icon>
|
<el-button @click="handleReset">
|
||||||
<span>搜索</span>
|
<el-icon><Refresh /></el-icon>
|
||||||
</el-button>
|
<span>重置</span>
|
||||||
<el-button @click="handleReset">
|
</el-button>
|
||||||
<el-icon><Refresh /></el-icon>
|
|
||||||
<span>重置</span>
|
<div class="filter-spacer" />
|
||||||
</el-button>
|
|
||||||
|
<el-button type="primary" @click="openAddDialog">
|
||||||
<div class="filter-spacer" />
|
<el-icon><Plus /></el-icon>
|
||||||
|
<span>新增位置</span>
|
||||||
<el-button type="primary" @click="openAddDialog">
|
</el-button>
|
||||||
<el-icon><Plus /></el-icon>
|
</div>
|
||||||
<span>新增位置</span>
|
|
||||||
</el-button>
|
<el-table v-loading="loading" :data="list" border stripe>
|
||||||
</div>
|
<el-table-column label="排序值" prop="indexVal" width="100" sortable />
|
||||||
|
<el-table-column label="国家" min-width="160">
|
||||||
<el-table v-loading="loading" :data="list" border stripe>
|
<template #default="{ row }: any">
|
||||||
<el-table-column label="排序值" prop="indexVal" width="100" sortable />
|
{{ row.country?.countryName || '-' }}
|
||||||
<el-table-column label="国家" min-width="160">
|
</template>
|
||||||
<template #default="{ row }: { row: Position }">
|
</el-table-column>
|
||||||
{{ row.country?.countryName || '-' }}
|
<el-table-column label="分类" min-width="200">
|
||||||
</template>
|
<template #default="{ row }: any">
|
||||||
</el-table-column>
|
{{ getCategoryName(row) }}
|
||||||
<el-table-column label="分类" min-width="200">
|
</template>
|
||||||
<template #default="{ row }: { row: Position }">
|
</el-table-column>
|
||||||
{{ getCategoryName(row) }}
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
</template>
|
<template #default="{ row }: any">
|
||||||
</el-table-column>
|
<div class="table-actions">
|
||||||
<el-table-column label="操作" width="180" fixed="right">
|
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||||
<template #default="{ row }: { row: Position }">
|
<el-icon><Edit /></el-icon>
|
||||||
<div class="table-actions">
|
<span>编辑</span>
|
||||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
</el-button>
|
||||||
<el-icon><Edit /></el-icon>
|
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||||
<span>编辑</span>
|
<el-icon><Delete /></el-icon>
|
||||||
</el-button>
|
<span>删除</span>
|
||||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
</el-button>
|
||||||
<el-icon><Delete /></el-icon>
|
</div>
|
||||||
<span>删除</span>
|
</template>
|
||||||
</el-button>
|
</el-table-column>
|
||||||
</div>
|
<template #empty>
|
||||||
</template>
|
<el-empty description="暂无位置" />
|
||||||
</el-table-column>
|
</template>
|
||||||
<template #empty>
|
</el-table>
|
||||||
<el-empty description="暂无位置" />
|
|
||||||
</template>
|
<el-pagination
|
||||||
</el-table>
|
class="pagination"
|
||||||
|
v-model:current-page="filter.page"
|
||||||
<el-pagination
|
v-model:page-size="filter.pageSize"
|
||||||
class="pagination"
|
:total="total"
|
||||||
v-model:current-page="filter.page"
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
v-model:page-size="filter.pageSize"
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
:total="total"
|
@current-change="(p: number) => { filter.page = p; fetchList() }"
|
||||||
:page-sizes="[10, 20, 50, 100]"
|
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
/>
|
||||||
@current-change="(p: number) => { filter.page = p; fetchList() }"
|
</div>
|
||||||
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
|
|
||||||
/>
|
<el-dialog
|
||||||
</div>
|
v-model="dialogVisible"
|
||||||
|
:title="dialogMode === 'create' ? '新增位置' : '编辑位置'"
|
||||||
<el-dialog
|
width="520px"
|
||||||
v-model="dialogVisible"
|
destroy-on-close
|
||||||
:title="dialogMode === 'create' ? '新增位置' : '编辑位置'"
|
>
|
||||||
width="520px"
|
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
||||||
destroy-on-close
|
<el-form-item label="排序值" prop="indexVal">
|
||||||
>
|
<el-input-number v-model="dialogForm.indexVal" :min="0" :max="9999" />
|
||||||
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
</el-form-item>
|
||||||
<el-form-item label="排序值" prop="indexVal">
|
<el-form-item label="国家">
|
||||||
<el-input-number v-model="dialogForm.indexVal" :min="0" :max="9999" />
|
<el-select v-model="dialogForm.countryId" placeholder="可选" clearable style="width: 100%">
|
||||||
</el-form-item>
|
<el-option
|
||||||
<el-form-item label="国家">
|
v-for="c in countries"
|
||||||
<el-select v-model="dialogForm.countryId" placeholder="可选" clearable style="width: 100%">
|
:key="c.id"
|
||||||
<el-option
|
:label="c.countryName"
|
||||||
v-for="c in countries"
|
:value="c.id"
|
||||||
:key="c.id"
|
/>
|
||||||
:label="c.countryName"
|
</el-select>
|
||||||
:value="c.id"
|
</el-form-item>
|
||||||
/>
|
<el-form-item label="分类">
|
||||||
</el-select>
|
<el-cascader
|
||||||
</el-form-item>
|
v-model="dialogForm.categoryId"
|
||||||
<el-form-item label="分类">
|
:options="cascaderOptions"
|
||||||
<el-cascader
|
:props="{
|
||||||
v-model="dialogForm.categoryId"
|
checkStrictly: true,
|
||||||
:options="cascaderOptions"
|
value: 'value',
|
||||||
:props="{
|
label: 'label',
|
||||||
checkStrictly: true,
|
children: 'children',
|
||||||
value: 'value',
|
emitPath: false,
|
||||||
label: 'label',
|
}"
|
||||||
children: 'children',
|
placeholder="可选"
|
||||||
emitPath: false,
|
clearable
|
||||||
}"
|
style="width: 100%"
|
||||||
placeholder="可选"
|
/>
|
||||||
clearable
|
</el-form-item>
|
||||||
style="width: 100%"
|
</el-form>
|
||||||
/>
|
<template #footer>
|
||||||
</el-form-item>
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
</el-form>
|
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||||
<template #footer>
|
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||||
<el-button @click="dialogVisible = false">取消</el-button>
|
</el-button>
|
||||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
</template>
|
||||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
</el-dialog>
|
||||||
</el-button>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
|
||||||
</div>
|
<style scoped>
|
||||||
</template>
|
.filter-spacer {
|
||||||
|
flex: 1;
|
||||||
<style scoped>
|
}
|
||||||
.filter-spacer {
|
|
||||||
flex: 1;
|
.pagination {
|
||||||
}
|
margin-top: 16px;
|
||||||
|
justify-content: flex-end;
|
||||||
.pagination {
|
}
|
||||||
margin-top: 16px;
|
</style>
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,55 +1,55 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { markRaw, ref, watch } from 'vue'
|
import { markRaw, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { Goods, Refresh } from '@element-plus/icons-vue'
|
import { Goods, Refresh } from '@element-plus/icons-vue'
|
||||||
import GoodsView from '@/views/goods/GoodsView.vue'
|
import GoodsView from '@/views/goods/GoodsView.vue'
|
||||||
import SyncView from '@/views/sync/SyncView.vue'
|
import SyncView from '@/views/sync/SyncView.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ name: 'goods', label: '商品配置', icon: markRaw(Goods), comp: markRaw(GoodsView) },
|
{ name: 'goods', label: '商品配置', icon: markRaw(Goods), comp: markRaw(GoodsView) },
|
||||||
{ name: 'sync', label: '数据同步', icon: markRaw(Refresh), comp: markRaw(SyncView) },
|
{ name: 'sync', label: '数据同步', icon: markRaw(Refresh), comp: markRaw(SyncView) },
|
||||||
]
|
]
|
||||||
|
|
||||||
const activeTab = ref((route.query.tab as string) || 'goods')
|
const activeTab = ref((route.query.tab as string) || 'goods')
|
||||||
|
|
||||||
watch(activeTab, (val) => {
|
watch(activeTab, (val) => {
|
||||||
router.replace({ query: { ...route.query, tab: val } })
|
router.replace({ query: { ...route.query, tab: val } })
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="pm-wrapper">
|
<div class="pm-wrapper">
|
||||||
<el-tabs v-model="activeTab" class="pm-tabs" type="card">
|
<el-tabs v-model="activeTab" class="pm-tabs" type="card">
|
||||||
<el-tab-pane
|
<el-tab-pane
|
||||||
v-for="t in tabs"
|
v-for="t in tabs"
|
||||||
:key="t.name"
|
:key="t.name"
|
||||||
:name="t.name"
|
:name="t.name"
|
||||||
>
|
>
|
||||||
<template #label>
|
<template #label>
|
||||||
<span class="pm-tab-label">
|
<span class="pm-tab-label">
|
||||||
<el-icon class="pm-tab-icon"><component :is="t.icon" /></el-icon>
|
<el-icon class="pm-tab-icon"><component :is="t.icon" /></el-icon>
|
||||||
{{ t.label }}
|
{{ t.label }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<component :is="t.comp" />
|
<component :is="t.comp" />
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.pm-wrapper { height: 100%; display: flex; flex-direction: column; }
|
.pm-wrapper { height: 100%; display: flex; flex-direction: column; }
|
||||||
.pm-tabs { height: 100%; display: flex; flex-direction: column; }
|
.pm-tabs { height: 100%; display: flex; flex-direction: column; }
|
||||||
.pm-tabs :deep(.el-tabs__header) { margin-bottom: 0; flex-shrink: 0; }
|
.pm-tabs :deep(.el-tabs__header) { margin-bottom: 0; flex-shrink: 0; }
|
||||||
.pm-tabs :deep(.el-tabs__content) { flex: 1; min-height: 0; overflow: hidden; }
|
.pm-tabs :deep(.el-tabs__content) { flex: 1; min-height: 0; overflow: hidden; }
|
||||||
.pm-tabs :deep(.el-tab-pane) { height: 100%; }
|
.pm-tabs :deep(.el-tab-pane) { height: 100%; }
|
||||||
.pm-tab-label {
|
.pm-tab-label {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
.pm-tab-icon { font-size: 14px; }
|
.pm-tab-icon { font-size: 14px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,65 +1,65 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Refresh, Box, Clock, CircleCheck, CircleClose, Loading } from '@element-plus/icons-vue'
|
import { Refresh, Box, Clock, CircleCheck, CircleClose, Loading } from '@element-plus/icons-vue'
|
||||||
import type { SyncLog } from '@/types'
|
import type { SyncLog } from '@/types'
|
||||||
import { syncApi } from '@/api/sync'
|
import { syncApi } from '@/api/sync'
|
||||||
|
|
||||||
const logs = ref<SyncLog[]>([])
|
const logs = ref<SyncLog[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const syncing = ref(false)
|
const syncing = ref(false)
|
||||||
type SyncType = 'PRODUCTS' | 'CATEGORIES' | 'PRODUCT_DETAILS'
|
type SyncType = 'PRODUCTS' | 'CATEGORIES' | 'PRODUCT_DETAILS'
|
||||||
const currentType = ref<SyncType>('PRODUCTS')
|
const currentType = ref<SyncType>('PRODUCTS')
|
||||||
|
|
||||||
let timer: ReturnType<typeof setInterval> | null = null
|
let timer: ReturnType<typeof setInterval> | null = null
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
async function refreshLogs() {
|
async function refreshLogs() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await syncApi.getSyncStatus(50) as any
|
const data = await syncApi.getSyncStatus(50) as any
|
||||||
logs.value = Array.isArray(data) ? data : []
|
logs.value = Array.isArray(data) ? data : []
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to fetch sync status', err)
|
console.warn('Failed to fetch sync status', err)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncTypeLabel(type: SyncType): string {
|
function syncTypeLabel(type: SyncType): string {
|
||||||
if (type === 'CATEGORIES') return '分类'
|
if (type === 'CATEGORIES') return '分类'
|
||||||
if (type === 'PRODUCT_DETAILS') return '商品详情'
|
if (type === 'PRODUCT_DETAILS') return '全部原产品详情'
|
||||||
return '商品列表'
|
return '商品列表'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollUntilDone(type: SyncType) {
|
async function pollUntilDone(type: SyncType) {
|
||||||
if (pollTimer) clearInterval(pollTimer)
|
if (pollTimer) clearInterval(pollTimer)
|
||||||
pollTimer = setInterval(async () => {
|
pollTimer = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await syncApi.getSyncStatus(5) as any
|
const data = await syncApi.getSyncStatus(5) as any
|
||||||
const latest = Array.isArray(data) ? data : []
|
const latest = Array.isArray(data) ? data : []
|
||||||
if (latest.length > 0) logs.value = [...latest, ...logs.value.slice(latest.length)]
|
if (latest.length > 0) logs.value = [...latest, ...logs.value.slice(latest.length)]
|
||||||
const top = latest.find((l: SyncLog) => l.type === type)
|
const top = latest.find((l: SyncLog) => l.type === type)
|
||||||
if (top && top.status !== 'RUNNING') {
|
if (top && top.status !== 'RUNNING') {
|
||||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||||
syncing.value = false
|
syncing.value = false
|
||||||
if (top.status === 'SUCCESS') {
|
if (top.status === 'SUCCESS') {
|
||||||
ElMessage.success(`${syncTypeLabel(type)}同步完成`)
|
ElMessage.success(`${syncTypeLabel(type)}同步完成`)
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(`${syncTypeLabel(type)}同步失败`)
|
ElMessage.error(`${syncTypeLabel(type)}同步失败`)
|
||||||
}
|
}
|
||||||
await refreshLogs()
|
await refreshLogs()
|
||||||
}
|
}
|
||||||
} catch { /* ignore poll errors */ }
|
} catch { /* ignore poll errors */ }
|
||||||
}, 3000)
|
}, 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSyncProducts() {
|
async function handleSyncProducts() {
|
||||||
await doSync('PRODUCTS')
|
await doSync('PRODUCTS')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSyncCategories() {
|
async function handleSyncCategories() {
|
||||||
await doSync('CATEGORIES')
|
await doSync('CATEGORIES')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSyncProductDetails() {
|
async function handleSyncProductDetails() {
|
||||||
@@ -68,94 +68,94 @@ async function handleSyncProductDetails() {
|
|||||||
|
|
||||||
async function doSync(type: SyncType) {
|
async function doSync(type: SyncType) {
|
||||||
const label = syncTypeLabel(type)
|
const label = syncTypeLabel(type)
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
`确定立即执行${label}同步吗?${type !== 'CATEGORIES' ? '此操作可能需要几分钟。' : ''}`,
|
`确定立即执行${label}同步吗?${type === 'PRODUCT_DETAILS' ? '将同步全部有效原产品,耗时取决于原产品数量。' : type !== 'CATEGORIES' ? '此操作可能需要几分钟。' : ''}`,
|
||||||
'确认',
|
'确认',
|
||||||
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
|
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
|
||||||
)
|
)
|
||||||
} catch { return }
|
} catch { return }
|
||||||
|
|
||||||
syncing.value = true
|
syncing.value = true
|
||||||
currentType.value = type
|
currentType.value = type
|
||||||
try {
|
try {
|
||||||
if (type === 'PRODUCTS') {
|
if (type === 'PRODUCTS') {
|
||||||
await syncApi.syncProducts()
|
await syncApi.syncProducts()
|
||||||
} else if (type === 'PRODUCT_DETAILS') {
|
} else if (type === 'PRODUCT_DETAILS') {
|
||||||
await syncApi.syncProductDetails()
|
await syncApi.syncProductDetails()
|
||||||
} else {
|
} else {
|
||||||
await syncApi.syncCategories()
|
await syncApi.syncCategories()
|
||||||
}
|
}
|
||||||
ElMessage.info(`${label}同步已开始`)
|
ElMessage.info(`${label}同步已开始`)
|
||||||
pollUntilDone(type)
|
pollUntilDone(type)
|
||||||
} catch {
|
} catch {
|
||||||
ElMessage.error(`${label}同步启动失败`)
|
ElMessage.error(`${label}同步启动失败`)
|
||||||
syncing.value = false
|
syncing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(s?: string): string {
|
function formatTime(s?: string): string {
|
||||||
if (!s) return '-'
|
if (!s) return '-'
|
||||||
const d = new Date(s)
|
const d = new Date(s)
|
||||||
return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
|
return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(start?: string, end?: string): string | null {
|
function formatDuration(start?: string, end?: string): string | null {
|
||||||
if (!start || !end) return null
|
if (!start || !end) return null
|
||||||
const ms = new Date(end).getTime() - new Date(start).getTime()
|
const ms = new Date(end).getTime() - new Date(start).getTime()
|
||||||
if (ms < 1000) return `${ms}ms`
|
if (ms < 1000) return `${ms}ms`
|
||||||
return `${(ms / 1000).toFixed(1)}s`
|
return `${(ms / 1000).toFixed(1)}s`
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = computed(() => {
|
const stats = computed(() => {
|
||||||
const total = logs.value.length
|
const total = logs.value.length
|
||||||
const success = logs.value.filter(l => l.status === 'SUCCESS').length
|
const success = logs.value.filter(l => l.status === 'SUCCESS').length
|
||||||
const failed = logs.value.filter(l => l.status === 'FAILED').length
|
const failed = logs.value.filter(l => l.status === 'FAILED').length
|
||||||
const lastLog = logs.value[0]
|
const lastLog = logs.value[0]
|
||||||
return { total, success, failed, lastLog }
|
return { total, success, failed, lastLog }
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
refreshLogs()
|
refreshLogs()
|
||||||
timer = setInterval(refreshLogs, 60_000)
|
timer = setInterval(refreshLogs, 60_000)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (timer) clearInterval(timer)
|
if (timer) clearInterval(timer)
|
||||||
if (pollTimer) clearInterval(pollTimer)
|
if (pollTimer) clearInterval(pollTimer)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="sync-page" v-loading="loading">
|
<div class="sync-page" v-loading="loading">
|
||||||
<!-- Action Card -->
|
<!-- Action Card -->
|
||||||
<div class="sync-action-card">
|
<div class="sync-action-card">
|
||||||
<div class="sync-action-info">
|
<div class="sync-action-info">
|
||||||
<div class="sync-action-icon">
|
<div class="sync-action-icon">
|
||||||
<el-icon :size="28"><Box /></el-icon>
|
<el-icon :size="28"><Box /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="sync-action-title">数据同步</h2>
|
<h2 class="sync-action-title">数据同步</h2>
|
||||||
<p class="sync-action-desc">从上游 SDS 系统拉取最新分类和产品数据并同步到本地数据库</p>
|
<p class="sync-action-desc">分类和产品每小时自动同步;全部 SDS 原产品详情每天 03:30 自动同步,也可手动执行</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sync-action-buttons">
|
<div class="sync-action-buttons">
|
||||||
<el-button
|
<el-button
|
||||||
size="large"
|
size="large"
|
||||||
:loading="syncing && currentType === 'CATEGORIES'"
|
:loading="syncing && currentType === 'CATEGORIES'"
|
||||||
:disabled="syncing"
|
:disabled="syncing"
|
||||||
@click="handleSyncCategories"
|
@click="handleSyncCategories"
|
||||||
>
|
>
|
||||||
同步分类
|
同步分类
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
type="primary"
|
||||||
size="large"
|
size="large"
|
||||||
:loading="syncing && currentType === 'PRODUCTS'"
|
:loading="syncing && currentType === 'PRODUCTS'"
|
||||||
:disabled="syncing"
|
:disabled="syncing"
|
||||||
:icon="Refresh"
|
:icon="Refresh"
|
||||||
@click="handleSyncProducts"
|
@click="handleSyncProducts"
|
||||||
>
|
>
|
||||||
同步产品
|
同步产品
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
@@ -166,309 +166,309 @@ onUnmounted(() => {
|
|||||||
:icon="Refresh"
|
:icon="Refresh"
|
||||||
@click="handleSyncProductDetails"
|
@click="handleSyncProductDetails"
|
||||||
>
|
>
|
||||||
同步商品详情
|
同步全部原产品详情
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats Row -->
|
<!-- Stats Row -->
|
||||||
<div class="sync-stats">
|
<div class="sync-stats">
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<span class="stat-value">{{ stats.total }}</span>
|
<span class="stat-value">{{ stats.total }}</span>
|
||||||
<span class="stat-label">总同步次数</span>
|
<span class="stat-label">总同步次数</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-divider" />
|
<div class="stat-divider" />
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<span class="stat-value stat-success">{{ stats.success }}</span>
|
<span class="stat-value stat-success">{{ stats.success }}</span>
|
||||||
<span class="stat-label">成功</span>
|
<span class="stat-label">成功</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-divider" />
|
<div class="stat-divider" />
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<span class="stat-value stat-failed">{{ stats.failed }}</span>
|
<span class="stat-value stat-failed">{{ stats.failed }}</span>
|
||||||
<span class="stat-label">失败</span>
|
<span class="stat-label">失败</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-divider" />
|
<div class="stat-divider" />
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<span class="stat-value stat-time">{{ stats.lastLog ? formatTime(stats.lastLog.startedAt) : '-' }}</span>
|
<span class="stat-value stat-time">{{ stats.lastLog ? formatTime(stats.lastLog.startedAt) : '-' }}</span>
|
||||||
<span class="stat-label">最近同步</span>
|
<span class="stat-label">最近同步</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Log Timeline -->
|
<!-- Log Timeline -->
|
||||||
<div class="sync-logs">
|
<div class="sync-logs">
|
||||||
<div class="sync-logs-head">
|
<div class="sync-logs-head">
|
||||||
<h3 class="sync-logs-title">同步日志</h3>
|
<h3 class="sync-logs-title">同步日志</h3>
|
||||||
<el-button text :icon="Refresh" @click="refreshLogs">刷新</el-button>
|
<el-button text :icon="Refresh" @click="refreshLogs">刷新</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="logs.length === 0 && !loading" class="sync-empty">
|
<div v-if="logs.length === 0 && !loading" class="sync-empty">
|
||||||
<el-empty description="暂无同步记录" :image-size="80" />
|
<el-empty description="暂无同步记录" :image-size="80" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="sync-timeline">
|
<div v-else class="sync-timeline">
|
||||||
<div
|
<div
|
||||||
v-for="log in logs"
|
v-for="log in logs"
|
||||||
:key="log.id"
|
:key="log.id"
|
||||||
class="timeline-item"
|
class="timeline-item"
|
||||||
>
|
>
|
||||||
<div class="timeline-dot" :class="log.status === 'SUCCESS' ? 'is-success' : (log.status === 'RUNNING' ? 'is-running' : 'is-failed')">
|
<div class="timeline-dot" :class="log.status === 'SUCCESS' ? 'is-success' : (log.status === 'RUNNING' ? 'is-running' : 'is-failed')">
|
||||||
<el-icon :size="12">
|
<el-icon :size="12">
|
||||||
<Loading v-if="log.status === 'RUNNING'" />
|
<Loading v-if="log.status === 'RUNNING'" />
|
||||||
<CircleCheck v-else-if="log.status === 'SUCCESS'" />
|
<CircleCheck v-else-if="log.status === 'SUCCESS'" />
|
||||||
<CircleClose v-else />
|
<CircleClose v-else />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="timeline-content">
|
<div class="timeline-content">
|
||||||
<div class="timeline-header">
|
<div class="timeline-header">
|
||||||
<span class="timeline-type">{{ syncTypeLabel(log.type) }}</span>
|
<span class="timeline-type">{{ syncTypeLabel(log.type) }}</span>
|
||||||
<span class="timeline-status" :class="log.status === 'SUCCESS' ? 'is-success' : (log.status === 'RUNNING' ? 'is-running' : 'is-failed')">
|
<span class="timeline-status" :class="log.status === 'SUCCESS' ? 'is-success' : (log.status === 'RUNNING' ? 'is-running' : 'is-failed')">
|
||||||
{{ log.status === 'SUCCESS' ? '成功' : log.status === 'RUNNING' ? '进行中' : '失败' }}
|
{{ log.status === 'SUCCESS' ? '成功' : log.status === 'RUNNING' ? '进行中' : '失败' }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="formatDuration(log.startedAt, log.finishedAt || undefined)" class="timeline-duration">
|
<span v-if="formatDuration(log.startedAt, log.finishedAt || undefined)" class="timeline-duration">
|
||||||
<el-icon :size="11"><Clock /></el-icon>
|
<el-icon :size="11"><Clock /></el-icon>
|
||||||
{{ formatDuration(log.startedAt, log.finishedAt || undefined) }}
|
{{ formatDuration(log.startedAt, log.finishedAt || undefined) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="log.message" class="timeline-message">{{ log.message }}</p>
|
<p v-if="log.message" class="timeline-message">{{ log.message }}</p>
|
||||||
<span class="timeline-time">{{ formatTime(log.startedAt) }}</span>
|
<span class="timeline-time">{{ formatTime(log.startedAt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.sync-page {
|
.sync-page {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Action Card */
|
/* Action Card */
|
||||||
.sync-action-card {
|
.sync-action-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 24px 28px;
|
padding: 24px 28px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid #ebeef5;
|
border: 1px solid #ebeef5;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sync-action-info {
|
.sync-action-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sync-action-icon {
|
.sync-action-icon {
|
||||||
width: 56px;
|
width: 56px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: linear-gradient(135deg, #fff2e8, #ffe0c2);
|
background: linear-gradient(135deg, #fff2e8, #ffe0c2);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: var(--brand-color, #ff6800);
|
color: var(--brand-color, #ff6800);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sync-action-title {
|
.sync-action-title {
|
||||||
margin: 0 0 4px;
|
margin: 0 0 4px;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sync-action-desc {
|
.sync-action-desc {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #909399;
|
color: #909399;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sync-action-buttons {
|
.sync-action-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stats */
|
/* Stats */
|
||||||
.sync-stats {
|
.sync-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
padding: 16px 28px;
|
padding: 16px 28px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid #ebeef5;
|
border: 1px solid #ebeef5;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-item {
|
.stat-item {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value.stat-success { color: #67c23a; }
|
.stat-value.stat-success { color: #67c23a; }
|
||||||
.stat-value.stat-failed { color: #f56c6c; }
|
.stat-value.stat-failed { color: #f56c6c; }
|
||||||
.stat-value.stat-time { font-size: 14px; font-weight: 600; color: #606266; }
|
.stat-value.stat-time { font-size: 14px; font-weight: 600; color: #606266; }
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #909399;
|
color: #909399;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-divider {
|
.stat-divider {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
background: #ebeef5;
|
background: #ebeef5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Logs */
|
/* Logs */
|
||||||
.sync-logs {
|
.sync-logs {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid #ebeef5;
|
border: 1px solid #ebeef5;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sync-logs-head {
|
.sync-logs-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
border-bottom: 1px solid #f5f5f5;
|
border-bottom: 1px solid #f5f5f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sync-logs-title {
|
.sync-logs-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sync-empty {
|
.sync-empty {
|
||||||
padding: 40px 0;
|
padding: 40px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Timeline */
|
/* Timeline */
|
||||||
.sync-timeline {
|
.sync-timeline {
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
max-height: 500px;
|
max-height: 500px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-item {
|
.timeline-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding-bottom: 20px;
|
padding-bottom: 20px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-item:not(:last-child)::before {
|
.timeline-item:not(:last-child)::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 7px;
|
left: 7px;
|
||||||
top: 22px;
|
top: 22px;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 2px;
|
width: 2px;
|
||||||
background: #f0f0f0;
|
background: #f0f0f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-dot {
|
.timeline-dot {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-dot.is-success {
|
.timeline-dot.is-success {
|
||||||
background: #f0f9eb;
|
background: #f0f9eb;
|
||||||
color: #67c23a;
|
color: #67c23a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-dot.is-failed {
|
.timeline-dot.is-failed {
|
||||||
background: #fef0f0;
|
background: #fef0f0;
|
||||||
color: #f56c6c;
|
color: #f56c6c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-dot.is-running {
|
.timeline-dot.is-running {
|
||||||
background: #ecf5ff;
|
background: #ecf5ff;
|
||||||
color: #409eff;
|
color: #409eff;
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-content {
|
.timeline-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-header {
|
.timeline-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-status {
|
.timeline-status {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-status.is-success { color: #67c23a; }
|
.timeline-status.is-success { color: #67c23a; }
|
||||||
.timeline-status.is-failed { color: #f56c6c; }
|
.timeline-status.is-failed { color: #f56c6c; }
|
||||||
.timeline-status.is-running { color: #409eff; }
|
.timeline-status.is-running { color: #409eff; }
|
||||||
|
|
||||||
.timeline-type {
|
.timeline-type {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #606266;
|
color: #606266;
|
||||||
background: #f5f7fa;
|
background: #f5f7fa;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-duration {
|
.timeline-duration {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #909399;
|
color: #909399;
|
||||||
background: #f5f7fa;
|
background: #f5f7fa;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-message {
|
.timeline-message {
|
||||||
margin: 0 0 4px;
|
margin: 0 0 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #606266;
|
color: #606266;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-time {
|
.timeline-time {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #c0c4cc;
|
color: #c0c4cc;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,324 +1,323 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
|
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
|
||||||
import type {
|
import type {
|
||||||
Tag,
|
Tag,
|
||||||
TagGroup,
|
TagGroup,
|
||||||
CreateTagRequest,
|
CreateTagRequest,
|
||||||
UpdateTagRequest,
|
UpdateTagRequest,
|
||||||
TagFilter,
|
TagFilter,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
import { tagsApi } from '@/api/tags'
|
import { tagsApi } from '@/api/tags'
|
||||||
import { tagGroupsApi } from '@/api/tag-groups'
|
import { tagGroupsApi } from '@/api/tag-groups'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const list = ref<Tag[]>([])
|
const list = ref<Tag[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const allGroups = ref<TagGroup[]>([])
|
const allGroups = ref<TagGroup[]>([])
|
||||||
|
|
||||||
const filter = reactive<Required<TagFilter>>({
|
const filter = reactive<Required<TagFilter>>({
|
||||||
tagName: '',
|
tagName: '',
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
})
|
})
|
||||||
|
|
||||||
async function fetchList() {
|
async function fetchList() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await tagsApi.getTagsList(filter)
|
await tagsApi.getTagsList(filter)
|
||||||
const data = await tagsApi.getTagsList(filter) as any
|
const data = await tagsApi.getTagsList(filter) as any
|
||||||
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||||
list.value = arr
|
list.value = arr
|
||||||
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
|
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadGroups() {
|
async function loadGroups() {
|
||||||
allGroups.value = await tagGroupsApi.getTagGroupsList()
|
allGroups.value = await tagGroupsApi.getTagGroupsList()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSearch() {
|
function handleSearch() {
|
||||||
filter.page = 1
|
filter.page = 1
|
||||||
fetchList()
|
fetchList()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleReset() {
|
function handleReset() {
|
||||||
filter.tagName = ''
|
filter.tagName = ''
|
||||||
filter.page = 1
|
filter.page = 1
|
||||||
fetchList()
|
fetchList()
|
||||||
}
|
}
|
||||||
|
|
||||||
const dialogRef = ref()
|
const dialogRef = ref()
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
const dialogMode = ref<'create' | 'edit'>('create')
|
const dialogMode = ref<'create' | 'edit'>('create')
|
||||||
const dialogLoading = ref(false)
|
const dialogLoading = ref(false)
|
||||||
|
|
||||||
const dialogForm = reactive<CreateTagRequest & { id?: string; tagGroupId?: number | null }>({
|
const dialogForm = reactive<CreateTagRequest & { id?: string; tagGroupId?: number | null }>({
|
||||||
id: undefined,
|
id: undefined,
|
||||||
tagName: '',
|
tagName: '',
|
||||||
tagColor: '#ff6800',
|
tagColor: '#ff6800',
|
||||||
tagFontColor: '#ffffff',
|
tagFontColor: '#ffffff',
|
||||||
timing: '',
|
timing: '',
|
||||||
tagGroupId: null,
|
})
|
||||||
})
|
|
||||||
|
const dialogRules = {
|
||||||
const dialogRules = {
|
tagName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
|
||||||
tagName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
|
}
|
||||||
}
|
|
||||||
|
function openAddDialog() {
|
||||||
function openAddDialog() {
|
dialogMode.value = 'create'
|
||||||
dialogMode.value = 'create'
|
Object.assign(dialogForm, { id: undefined, tagName: '', tagColor: '#ff6800', tagFontColor: '#ffffff', timing: '', tagGroupId: null })
|
||||||
Object.assign(dialogForm, { id: undefined, tagName: '', tagColor: '#ff6800', tagFontColor: '#ffffff', timing: '', tagGroupId: null })
|
dialogVisible.value = true
|
||||||
dialogVisible.value = true
|
}
|
||||||
}
|
|
||||||
|
function openEditDialog(t: Tag) {
|
||||||
function openEditDialog(t: Tag) {
|
dialogMode.value = 'edit'
|
||||||
dialogMode.value = 'edit'
|
Object.assign(dialogForm, {
|
||||||
Object.assign(dialogForm, {
|
id: t.id,
|
||||||
id: t.id,
|
tagName: t.tagName,
|
||||||
tagName: t.tagName,
|
tagColor: t.tagColor || '#ff6800',
|
||||||
tagColor: t.tagColor || '#ff6800',
|
tagFontColor: t.tagFontColor || '#ffffff',
|
||||||
tagFontColor: t.tagFontColor || '#ffffff',
|
timing: t.timing || '',
|
||||||
timing: t.timing || '',
|
tagGroupId: t.tagGroupId ? Number(t.tagGroupId) : null,
|
||||||
tagGroupId: t.tagGroupId ? Number(t.tagGroupId) : null,
|
})
|
||||||
})
|
dialogVisible.value = true
|
||||||
dialogVisible.value = true
|
}
|
||||||
}
|
|
||||||
|
async function handleSubmit() {
|
||||||
async function handleSubmit() {
|
if (!dialogRef.value) return
|
||||||
if (!dialogRef.value) return
|
await dialogRef.value.validate(async (valid: boolean) => {
|
||||||
await dialogRef.value.validate(async (valid: boolean) => {
|
if (!valid) return
|
||||||
if (!valid) return
|
dialogLoading.value = true
|
||||||
dialogLoading.value = true
|
try {
|
||||||
try {
|
const payload: CreateTagRequest = {
|
||||||
const payload: CreateTagRequest = {
|
tagName: dialogForm.tagName,
|
||||||
tagName: dialogForm.tagName,
|
tagColor: dialogForm.tagColor || undefined,
|
||||||
tagColor: dialogForm.tagColor || undefined,
|
tagFontColor: dialogForm.tagFontColor || undefined,
|
||||||
tagFontColor: dialogForm.tagFontColor || undefined,
|
timing: dialogForm.timing || undefined,
|
||||||
timing: dialogForm.timing || undefined,
|
tagGroupId: dialogForm.tagGroupId ?? undefined,
|
||||||
tagGroupId: dialogForm.tagGroupId ?? undefined,
|
}
|
||||||
}
|
if (dialogMode.value === 'create') {
|
||||||
if (dialogMode.value === 'create') {
|
await tagsApi.createTag(payload)
|
||||||
await tagsApi.createTag(payload)
|
ElMessage.success('标签创建成功')
|
||||||
ElMessage.success('标签创建成功')
|
} else {
|
||||||
} else {
|
await tagsApi.updateTag(dialogForm.id!, payload as UpdateTagRequest)
|
||||||
await tagsApi.updateTag(dialogForm.id!, payload as UpdateTagRequest)
|
ElMessage.success('标签更新成功')
|
||||||
ElMessage.success('标签更新成功')
|
}
|
||||||
}
|
dialogVisible.value = false
|
||||||
dialogVisible.value = false
|
fetchList()
|
||||||
fetchList()
|
} finally {
|
||||||
} finally {
|
dialogLoading.value = false
|
||||||
dialogLoading.value = false
|
}
|
||||||
}
|
})
|
||||||
})
|
}
|
||||||
}
|
|
||||||
|
async function handleDelete(t: Tag) {
|
||||||
async function handleDelete(t: Tag) {
|
try {
|
||||||
try {
|
await ElMessageBox.confirm(`确定删除「${t.tagName}」吗?`, '确认', {
|
||||||
await ElMessageBox.confirm(`确定删除「${t.tagName}」吗?`, '确认', {
|
type: 'warning',
|
||||||
type: 'warning',
|
confirmButtonText: '删除',
|
||||||
confirmButtonText: '删除',
|
cancelButtonText: '取消',
|
||||||
cancelButtonText: '取消',
|
})
|
||||||
})
|
} catch {
|
||||||
} catch {
|
return
|
||||||
return
|
}
|
||||||
}
|
await tagsApi.deleteTag(t.id)
|
||||||
await tagsApi.deleteTag(t.id)
|
ElMessage.success('删除成功')
|
||||||
ElMessage.success('删除成功')
|
fetchList()
|
||||||
fetchList()
|
}
|
||||||
}
|
|
||||||
|
onMounted(() => {
|
||||||
onMounted(() => {
|
fetchList()
|
||||||
fetchList()
|
loadGroups()
|
||||||
loadGroups()
|
})
|
||||||
})
|
</script>
|
||||||
</script>
|
|
||||||
|
<template>
|
||||||
<template>
|
<div class="page-container">
|
||||||
<div class="page-container">
|
<div class="page-card">
|
||||||
<div class="page-card">
|
<div class="filter-bar">
|
||||||
<div class="filter-bar">
|
<el-input
|
||||||
<el-input
|
v-model="filter.tagName"
|
||||||
v-model="filter.tagName"
|
placeholder="按名称搜索"
|
||||||
placeholder="按名称搜索"
|
clearable
|
||||||
clearable
|
@keyup.enter="handleSearch"
|
||||||
@keyup.enter="handleSearch"
|
@clear="handleSearch"
|
||||||
@clear="handleSearch"
|
>
|
||||||
>
|
<template #prefix>
|
||||||
<template #prefix>
|
<el-icon><Search /></el-icon>
|
||||||
<el-icon><Search /></el-icon>
|
</template>
|
||||||
</template>
|
</el-input>
|
||||||
</el-input>
|
<el-button type="primary" @click="handleSearch">
|
||||||
<el-button type="primary" @click="handleSearch">
|
<el-icon><Search /></el-icon>
|
||||||
<el-icon><Search /></el-icon>
|
<span>搜索</span>
|
||||||
<span>搜索</span>
|
</el-button>
|
||||||
</el-button>
|
<el-button @click="handleReset">
|
||||||
<el-button @click="handleReset">
|
<el-icon><Refresh /></el-icon>
|
||||||
<el-icon><Refresh /></el-icon>
|
<span>重置</span>
|
||||||
<span>重置</span>
|
</el-button>
|
||||||
</el-button>
|
|
||||||
|
<div class="filter-spacer" />
|
||||||
<div class="filter-spacer" />
|
|
||||||
|
<el-button type="primary" @click="openAddDialog">
|
||||||
<el-button type="primary" @click="openAddDialog">
|
<el-icon><Plus /></el-icon>
|
||||||
<el-icon><Plus /></el-icon>
|
<span>新增标签</span>
|
||||||
<span>新增标签</span>
|
</el-button>
|
||||||
</el-button>
|
</div>
|
||||||
</div>
|
|
||||||
|
<el-table v-loading="loading" :data="list" border stripe>
|
||||||
<el-table v-loading="loading" :data="list" border stripe>
|
<el-table-column label="预览" width="120">
|
||||||
<el-table-column label="预览" width="120">
|
<template #default="{ row }: any">
|
||||||
<template #default="{ row }: { row: Tag }">
|
<span
|
||||||
<span
|
v-if="row.tagColor"
|
||||||
v-if="row.tagColor"
|
class="tag-preview"
|
||||||
class="tag-preview"
|
:style="{ background: row.tagColor, color: row.tagFontColor || '#fff' }"
|
||||||
:style="{ background: row.tagColor, color: row.tagFontColor || '#fff' }"
|
>{{ row.tagName }}</span>
|
||||||
>{{ row.tagName }}</span>
|
<el-tag v-else effect="plain">{{ row.tagName }}</el-tag>
|
||||||
<el-tag v-else effect="plain">{{ row.tagName }}</el-tag>
|
</template>
|
||||||
</template>
|
</el-table-column>
|
||||||
</el-table-column>
|
<el-table-column prop="tagName" label="名称" min-width="200" />
|
||||||
<el-table-column prop="tagName" label="名称" min-width="200" />
|
<el-table-column label="背景色" width="140">
|
||||||
<el-table-column label="背景色" width="140">
|
<template #default="{ row }: any">
|
||||||
<template #default="{ row }: { row: Tag }">
|
<div class="color-cell">
|
||||||
<div class="color-cell">
|
<span class="color-swatch" :style="{ background: row.tagColor || '#d1d5db' }" />
|
||||||
<span class="color-swatch" :style="{ background: row.tagColor || '#d1d5db' }" />
|
<span class="color-hex">{{ row.tagColor || '-' }}</span>
|
||||||
<span class="color-hex">{{ row.tagColor || '-' }}</span>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</template>
|
</el-table-column>
|
||||||
</el-table-column>
|
<el-table-column label="字体色" width="140">
|
||||||
<el-table-column label="字体色" width="140">
|
<template #default="{ row }: any">
|
||||||
<template #default="{ row }: { row: Tag }">
|
<div class="color-cell">
|
||||||
<div class="color-cell">
|
<span class="color-swatch" :style="{ background: row.tagFontColor || '#d1d5db' }" />
|
||||||
<span class="color-swatch" :style="{ background: row.tagFontColor || '#d1d5db' }" />
|
<span class="color-hex">{{ row.tagFontColor || '-' }}</span>
|
||||||
<span class="color-hex">{{ row.tagFontColor || '-' }}</span>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</template>
|
</el-table-column>
|
||||||
</el-table-column>
|
<el-table-column label="所属分组" min-width="140">
|
||||||
<el-table-column label="所属分组" min-width="140">
|
<template #default="{ row }: any">
|
||||||
<template #default="{ row }: { row: Tag }">
|
<span v-if="row.tagGroup">{{ row.tagGroup.groupName }}</span>
|
||||||
<span v-if="row.tagGroup">{{ row.tagGroup.groupName }}</span>
|
<span v-else style="color: #999;">未分组</span>
|
||||||
<span v-else style="color: #999;">未分组</span>
|
</template>
|
||||||
</template>
|
</el-table-column>
|
||||||
</el-table-column>
|
<el-table-column prop="timing" label="定时" min-width="120" />
|
||||||
<el-table-column prop="timing" label="定时" min-width="120" />
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
<el-table-column label="操作" width="180" fixed="right">
|
<template #default="{ row }: any">
|
||||||
<template #default="{ row }: { row: Tag }">
|
<div class="table-actions">
|
||||||
<div class="table-actions">
|
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
<el-icon><Edit /></el-icon>
|
||||||
<el-icon><Edit /></el-icon>
|
<span>编辑</span>
|
||||||
<span>编辑</span>
|
</el-button>
|
||||||
</el-button>
|
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
<el-icon><Delete /></el-icon>
|
||||||
<el-icon><Delete /></el-icon>
|
<span>删除</span>
|
||||||
<span>删除</span>
|
</el-button>
|
||||||
</el-button>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</template>
|
</el-table-column>
|
||||||
</el-table-column>
|
<template #empty>
|
||||||
<template #empty>
|
<el-empty description="暂无标签" />
|
||||||
<el-empty description="暂无标签" />
|
</template>
|
||||||
</template>
|
</el-table>
|
||||||
</el-table>
|
|
||||||
|
<el-pagination
|
||||||
<el-pagination
|
class="pagination"
|
||||||
class="pagination"
|
v-model:current-page="filter.page"
|
||||||
v-model:current-page="filter.page"
|
v-model:page-size="filter.pageSize"
|
||||||
v-model:page-size="filter.pageSize"
|
:total="total"
|
||||||
:total="total"
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
:page-sizes="[10, 20, 50, 100]"
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
@current-change="(p: number) => { filter.page = p; fetchList() }"
|
||||||
@current-change="(p: number) => { filter.page = p; fetchList() }"
|
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
|
||||||
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
|
<el-dialog
|
||||||
<el-dialog
|
v-model="dialogVisible"
|
||||||
v-model="dialogVisible"
|
:title="dialogMode === 'create' ? '新增标签' : '编辑标签'"
|
||||||
:title="dialogMode === 'create' ? '新增标签' : '编辑标签'"
|
width="480px"
|
||||||
width="480px"
|
destroy-on-close
|
||||||
destroy-on-close
|
>
|
||||||
>
|
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
||||||
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
<el-form-item label="名称" prop="tagName">
|
||||||
<el-form-item label="名称" prop="tagName">
|
<el-input v-model="dialogForm.tagName" placeholder="请输入标签名称" />
|
||||||
<el-input v-model="dialogForm.tagName" placeholder="请输入标签名称" />
|
</el-form-item>
|
||||||
</el-form-item>
|
<el-form-item label="背景色">
|
||||||
<el-form-item label="背景色">
|
<el-color-picker v-model="dialogForm.tagColor" />
|
||||||
<el-color-picker v-model="dialogForm.tagColor" />
|
<span class="color-readout">{{ dialogForm.tagColor }}</span>
|
||||||
<span class="color-readout">{{ dialogForm.tagColor }}</span>
|
</el-form-item>
|
||||||
</el-form-item>
|
<el-form-item label="字体色">
|
||||||
<el-form-item label="字体色">
|
<el-color-picker v-model="dialogForm.tagFontColor" />
|
||||||
<el-color-picker v-model="dialogForm.tagFontColor" />
|
<span class="color-readout">{{ dialogForm.tagFontColor }}</span>
|
||||||
<span class="color-readout">{{ dialogForm.tagFontColor }}</span>
|
</el-form-item>
|
||||||
</el-form-item>
|
<el-form-item label="所属分组">
|
||||||
<el-form-item label="所属分组">
|
<el-select v-model="dialogForm.tagGroupId" clearable placeholder="未分组" style="width: 100%">
|
||||||
<el-select v-model="dialogForm.tagGroupId" clearable placeholder="未分组" style="width: 100%">
|
<el-option
|
||||||
<el-option
|
v-for="g in allGroups"
|
||||||
v-for="g in allGroups"
|
:key="g.id"
|
||||||
:key="g.id"
|
:label="g.groupName"
|
||||||
:label="g.groupName"
|
:value="Number(g.id)"
|
||||||
:value="Number(g.id)"
|
/>
|
||||||
/>
|
</el-select>
|
||||||
</el-select>
|
</el-form-item>
|
||||||
</el-form-item>
|
<el-form-item label="定时">
|
||||||
<el-form-item label="定时">
|
<el-input v-model="dialogForm.timing" placeholder="例如 9:00-12:00" />
|
||||||
<el-input v-model="dialogForm.timing" placeholder="例如 9:00-12:00" />
|
</el-form-item>
|
||||||
</el-form-item>
|
</el-form>
|
||||||
</el-form>
|
<template #footer>
|
||||||
<template #footer>
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
<el-button @click="dialogVisible = false">取消</el-button>
|
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
</el-button>
|
||||||
</el-button>
|
</template>
|
||||||
</template>
|
</el-dialog>
|
||||||
</el-dialog>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</template>
|
|
||||||
|
<style scoped>
|
||||||
<style scoped>
|
.filter-spacer {
|
||||||
.filter-spacer {
|
flex: 1;
|
||||||
flex: 1;
|
}
|
||||||
}
|
|
||||||
|
.pagination {
|
||||||
.pagination {
|
margin-top: 16px;
|
||||||
margin-top: 16px;
|
justify-content: flex-end;
|
||||||
justify-content: flex-end;
|
}
|
||||||
}
|
|
||||||
|
.color-cell {
|
||||||
.color-cell {
|
display: flex;
|
||||||
display: flex;
|
align-items: center;
|
||||||
align-items: center;
|
gap: 8px;
|
||||||
gap: 8px;
|
}
|
||||||
}
|
|
||||||
|
.color-swatch {
|
||||||
.color-swatch {
|
display: inline-block;
|
||||||
display: inline-block;
|
width: 20px;
|
||||||
width: 20px;
|
height: 20px;
|
||||||
height: 20px;
|
border-radius: 4px;
|
||||||
border-radius: 4px;
|
border: 1px solid #e5e7eb;
|
||||||
border: 1px solid #e5e7eb;
|
}
|
||||||
}
|
|
||||||
|
.color-hex {
|
||||||
.color-hex {
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
font-size: 12px;
|
||||||
font-size: 12px;
|
color: #6b7280;
|
||||||
color: #6b7280;
|
}
|
||||||
}
|
|
||||||
|
.color-readout {
|
||||||
.color-readout {
|
margin-left: 12px;
|
||||||
margin-left: 12px;
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
font-size: 12px;
|
||||||
font-size: 12px;
|
color: #6b7280;
|
||||||
color: #6b7280;
|
}
|
||||||
}
|
|
||||||
|
.tag-preview {
|
||||||
.tag-preview {
|
display: inline-block;
|
||||||
display: inline-block;
|
padding: 2px 12px;
|
||||||
padding: 2px 12px;
|
border-radius: 4px;
|
||||||
border-radius: 4px;
|
font-size: 13px;
|
||||||
font-size: 13px;
|
white-space: nowrap;
|
||||||
white-space: nowrap;
|
}
|
||||||
}
|
</style>
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
{
|
{
|
||||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
"types": ["vite/client", "node"],
|
"types": ["vite/client", "node"],
|
||||||
|
|
||||||
/* Path aliases */
|
/* Path aliases */
|
||||||
"ignoreDeprecations": "6.0",
|
"ignoreDeprecations": "6.0",
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"]
|
"@/*": ["src/*"]
|
||||||
},
|
},
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*.ts",
|
"src/**/*.ts",
|
||||||
"src/**/*.tsx",
|
"src/**/*.tsx",
|
||||||
"src/**/*.vue",
|
"src/**/*.vue",
|
||||||
"src/auto-imports.d.ts",
|
"src/auto-imports.d.ts",
|
||||||
"src/components.d.ts"
|
"src/components.d.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "./tsconfig.app.json" },
|
{ "path": "./tsconfig.app.json" },
|
||||||
{ "path": "./tsconfig.node.json" }
|
{ "path": "./tsconfig.node.json" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
"target": "es2023",
|
"target": "es2023",
|
||||||
"lib": ["ES2023"],
|
"lib": ["ES2023"],
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["vite.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,47 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import AutoImport from 'unplugin-auto-import/vite'
|
import AutoImport from 'unplugin-auto-import/vite'
|
||||||
import Components from 'unplugin-vue-components/vite'
|
import Components from 'unplugin-vue-components/vite'
|
||||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||||
import { fileURLToPath, URL } from 'node:url'
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
base: '/admin/',
|
||||||
vue(),
|
plugins: [
|
||||||
AutoImport({
|
vue(),
|
||||||
resolvers: [ElementPlusResolver()],
|
AutoImport({
|
||||||
imports: ['vue', 'vue-router', 'pinia'],
|
resolvers: [ElementPlusResolver()],
|
||||||
dts: 'src/auto-imports.d.ts',
|
imports: ['vue', 'vue-router', 'pinia'],
|
||||||
}),
|
dts: 'src/auto-imports.d.ts',
|
||||||
Components({
|
}),
|
||||||
resolvers: [ElementPlusResolver()],
|
Components({
|
||||||
dts: 'src/components.d.ts',
|
resolvers: [ElementPlusResolver()],
|
||||||
}),
|
dts: 'src/components.d.ts',
|
||||||
],
|
}),
|
||||||
resolve: {
|
],
|
||||||
alias: {
|
resolve: {
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
alias: {
|
||||||
},
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
},
|
},
|
||||||
server: {
|
},
|
||||||
host: '0.0.0.0',
|
server: {
|
||||||
port: 5173,
|
host: '0.0.0.0',
|
||||||
proxy: {
|
port: 5173,
|
||||||
'/api': {
|
proxy: {
|
||||||
target: 'http://127.0.0.1:3001',
|
'/api': {
|
||||||
changeOrigin: true,
|
target: 'http://127.0.0.1:3001',
|
||||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
changeOrigin: true,
|
||||||
},
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
'/uploads': {
|
},
|
||||||
target: 'http://127.0.0.1:3001',
|
'/uploads': {
|
||||||
changeOrigin: true,
|
target: 'http://127.0.0.1:3001',
|
||||||
},
|
changeOrigin: true,
|
||||||
'/assets': {
|
},
|
||||||
target: 'http://127.0.0.1:3001',
|
'/assets': {
|
||||||
changeOrigin: true,
|
target: 'http://127.0.0.1:3001',
|
||||||
},
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Prisma connection string (PostgreSQL)
|
||||||
|
DATABASE_URL=postgresql://postgres:CHANGE_ME@localhost:5432/inkreach-official-website
|
||||||
|
|
||||||
|
# JWT signing secret: generate with `node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"`
|
||||||
|
# Must be at least 32 characters.
|
||||||
|
JWT_SECRET=CHANGE_ME_TO_A_STRONG_RANDOM_SECRET
|
||||||
|
|
||||||
|
# Access token lifetime (e.g. 30m, 12h); short-lived, rotated via /auth/refresh
|
||||||
|
TOKEN_EXPIRES_IN=30m
|
||||||
|
|
||||||
|
# Refresh token lifetime (HttpOnly cookie)
|
||||||
|
REFRESH_TOKEN_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# Comma-separated list of allowed CORS origins (leave empty to disable CORS)
|
||||||
|
CORS_ORIGINS=http://localhost:5173
|
||||||
|
|
||||||
|
# Global rate limit per minute (per IP)
|
||||||
|
THROTTLE_LIMIT=120
|
||||||
|
|
||||||
|
PORT=3001
|
||||||
@@ -1,25 +1,25 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
parser: '@typescript-eslint/parser',
|
parser: '@typescript-eslint/parser',
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
project: 'tsconfig.json',
|
project: 'tsconfig.json',
|
||||||
tsconfigRootDir: __dirname,
|
tsconfigRootDir: __dirname,
|
||||||
sourceType: 'module',
|
sourceType: 'module',
|
||||||
},
|
},
|
||||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||||
extends: [
|
extends: [
|
||||||
'plugin:@typescript-eslint/recommended',
|
'plugin:@typescript-eslint/recommended',
|
||||||
'plugin:prettier/recommended',
|
'plugin:prettier/recommended',
|
||||||
],
|
],
|
||||||
root: true,
|
root: true,
|
||||||
env: {
|
env: {
|
||||||
node: true,
|
node: true,
|
||||||
jest: true,
|
jest: true,
|
||||||
},
|
},
|
||||||
ignorePatterns: ['.eslintrc.js'],
|
ignorePatterns: ['.eslintrc.js'],
|
||||||
rules: {
|
rules: {
|
||||||
'@typescript-eslint/interface-name-prefix': 'off',
|
'@typescript-eslint/interface-name-prefix': 'off',
|
||||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1,48 +1,48 @@
|
|||||||
# compiled output
|
# compiled output
|
||||||
/dist
|
/dist
|
||||||
/node_modules
|
/node_modules
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Tests
|
# Tests
|
||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
# IDEs and editors
|
# IDEs and editors
|
||||||
/.idea
|
/.idea
|
||||||
.project
|
.project
|
||||||
.classpath
|
.classpath
|
||||||
.c9/
|
.c9/
|
||||||
*.launch
|
*.launch
|
||||||
.settings/
|
.settings/
|
||||||
*.sublime-workspace
|
*.sublime-workspace
|
||||||
|
|
||||||
# IDE - VSCode
|
# IDE - VSCode
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/settings.json
|
!.vscode/settings.json
|
||||||
!.vscode/tasks.json
|
!.vscode/tasks.json
|
||||||
!.vscode/launch.json
|
!.vscode/launch.json
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
# Prisma
|
# Prisma
|
||||||
# prisma/migrations (keep migrations in VCS)
|
# prisma/migrations (keep migrations in VCS)
|
||||||
|
|
||||||
# TypeScript
|
# TypeScript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Uploads
|
# Uploads
|
||||||
/uploads
|
/uploads
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"trailingComma": "all",
|
"trailingComma": "all",
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"semi": true,
|
"semi": true,
|
||||||
"printWidth": 100
|
"printWidth": 100
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'^@/(.*)$': '<rootDir>/src/$1',
|
'^@/(.*)$': '<rootDir>/src/$1',
|
||||||
},
|
},
|
||||||
testEnvironment: 'node',
|
testEnvironment: 'node',
|
||||||
moduleFileExtensions: ['js', 'json', 'ts'],
|
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||||
rootDir: 'src',
|
rootDir: 'src',
|
||||||
testRegex: '.*\\.spec\\.ts$',
|
testRegex: '.*\\.spec\\.ts$',
|
||||||
transform: {
|
transform: {
|
||||||
'^.+\\.(t|j)s$': 'ts-jest',
|
'^.+\\.(t|j)s$': 'ts-jest',
|
||||||
},
|
},
|
||||||
collectCoverageFrom: ['**/*.(t|j)s'],
|
collectCoverageFrom: ['**/*.(t|j)s'],
|
||||||
coverageDirectory: '../coverage',
|
coverageDirectory: '../coverage',
|
||||||
coveragePathIgnorePatterns: ['/node_modules/', '/test/'],
|
coveragePathIgnorePatterns: ['/node_modules/', '/test/'],
|
||||||
};
|
};
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/nest-cli",
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
"collection": "@nestjs/schematics",
|
"collection": "@nestjs/schematics",
|
||||||
"sourceRoot": "src",
|
"sourceRoot": "src",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"deleteOutDir": true,
|
"deleteOutDir": true,
|
||||||
"webpack": false
|
"webpack": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,13 +34,16 @@
|
|||||||
"@nestjs/platform-express": "^10.3.0",
|
"@nestjs/platform-express": "^10.3.0",
|
||||||
"@nestjs/schedule": "^4.0.0",
|
"@nestjs/schedule": "^4.0.0",
|
||||||
"@nestjs/swagger": "^7.1.17",
|
"@nestjs/swagger": "^7.1.17",
|
||||||
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@prisma/client": "^5.8.0",
|
"@prisma/client": "^5.8.0",
|
||||||
"@types/multer": "^2.2.0",
|
"@types/multer": "^2.2.0",
|
||||||
"axios": "^1.6.5",
|
"axios": "^1.6.5",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
|
"helmet": "^8.3.0",
|
||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
@@ -52,6 +55,7 @@
|
|||||||
"@nestjs/schematics": "^10.0.3",
|
"@nestjs/schematics": "^10.0.3",
|
||||||
"@nestjs/testing": "^10.3.0",
|
"@nestjs/testing": "^10.3.0",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/jest": "^29.5.11",
|
"@types/jest": "^29.5.11",
|
||||||
"@types/node": "^20.10.6",
|
"@types/node": "^20.10.6",
|
||||||
|
|||||||
@@ -1,54 +1,54 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
const countryIcons: Record<string, string> = {
|
const countryIcons: Record<string, string> = {
|
||||||
美国: '/assets/product-center/countries/us.png',
|
美国: '/assets/product-center/countries/us.png',
|
||||||
日本: '/assets/product-center/countries/jp.png',
|
日本: '/assets/product-center/countries/jp.png',
|
||||||
墨西哥: '/assets/product-center/countries/mx.png',
|
墨西哥: '/assets/product-center/countries/mx.png',
|
||||||
巴西: '/assets/product-center/countries/br.png',
|
巴西: '/assets/product-center/countries/br.png',
|
||||||
中东: '/assets/product-center/countries/middle-east.png',
|
中东: '/assets/product-center/countries/middle-east.png',
|
||||||
波兰: '/assets/product-center/countries/pl.png',
|
波兰: '/assets/product-center/countries/pl.png',
|
||||||
西班牙: '/assets/product-center/countries/es.png',
|
西班牙: '/assets/product-center/countries/es.png',
|
||||||
德国: '/assets/product-center/countries/de.png',
|
德国: '/assets/product-center/countries/de.png',
|
||||||
意大利: '/assets/product-center/countries/it.png',
|
意大利: '/assets/product-center/countries/it.png',
|
||||||
英国: '/assets/product-center/countries/gb.png',
|
英国: '/assets/product-center/countries/gb.png',
|
||||||
加拿大: '/assets/product-center/countries/ca.png',
|
加拿大: '/assets/product-center/countries/ca.png',
|
||||||
澳大利亚: '/assets/product-center/countries/au.png',
|
澳大利亚: '/assets/product-center/countries/au.png',
|
||||||
韩国: '/assets/product-center/countries/kr.png',
|
韩国: '/assets/product-center/countries/kr.png',
|
||||||
};
|
};
|
||||||
|
|
||||||
const categoryIcons: Record<string, string> = {
|
const categoryIcons: Record<string, string> = {
|
||||||
男士服装: '/assets/product-center/categories/men.svg',
|
男士服装: '/assets/product-center/categories/men.svg',
|
||||||
女士服装: '/assets/product-center/categories/women.svg',
|
女士服装: '/assets/product-center/categories/women.svg',
|
||||||
儿童服装: '/assets/product-center/categories/children.svg',
|
儿童服装: '/assets/product-center/categories/children.svg',
|
||||||
家居配饰: '/assets/product-center/categories/home.svg',
|
家居配饰: '/assets/product-center/categories/home.svg',
|
||||||
};
|
};
|
||||||
|
|
||||||
async function updateExistingIcons(
|
async function updateExistingIcons(
|
||||||
entries: Record<string, string>,
|
entries: Record<string, string>,
|
||||||
update: (name: string, icon: string) => Promise<{ count: number }>,
|
update: (name: string, icon: string) => Promise<{ count: number }>,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
for (const [name, icon] of Object.entries(entries)) {
|
for (const [name, icon] of Object.entries(entries)) {
|
||||||
const result = await update(name, icon);
|
const result = await update(name, icon);
|
||||||
updated += result.count;
|
updated += result.count;
|
||||||
}
|
}
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const countries = await updateExistingIcons(countryIcons, (countryName, countryIcon) =>
|
const countries = await updateExistingIcons(countryIcons, (countryName, countryIcon) =>
|
||||||
prisma.country.updateMany({ where: { countryName }, data: { countryIcon } }),
|
prisma.country.updateMany({ where: { countryName }, data: { countryIcon } }),
|
||||||
);
|
);
|
||||||
const categories = await updateExistingIcons(categoryIcons, (categoryName, categoryIcon) =>
|
const categories = await updateExistingIcons(categoryIcons, (categoryName, categoryIcon) =>
|
||||||
prisma.category.updateMany({
|
prisma.category.updateMany({
|
||||||
where: { categoryName, parentCategoryId: null },
|
where: { categoryName, parentCategoryId: null },
|
||||||
data: { categoryIcon },
|
data: { categoryIcon },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
console.log(`Configured ${countries} countries and ${categories} root categories.`);
|
console.log(`Configured ${countries} countries and ${categories} root categories.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
.finally(async () => prisma.$disconnect());
|
.finally(async () => prisma.$disconnect());
|
||||||
|
|||||||
@@ -1,187 +1,187 @@
|
|||||||
-- CreateEnum
|
-- CreateEnum
|
||||||
CREATE TYPE "SyncType" AS ENUM ('CATEGORIES', 'PRODUCTS');
|
CREATE TYPE "SyncType" AS ENUM ('CATEGORIES', 'PRODUCTS');
|
||||||
|
|
||||||
-- CreateEnum
|
-- CreateEnum
|
||||||
CREATE TYPE "SyncStatus" AS ENUM ('RUNNING', 'SUCCESS', 'FAILED');
|
CREATE TYPE "SyncStatus" AS ENUM ('RUNNING', 'SUCCESS', 'FAILED');
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "origin_goods" (
|
CREATE TABLE "origin_goods" (
|
||||||
"origin_good_id" BIGSERIAL NOT NULL,
|
"origin_good_id" BIGSERIAL NOT NULL,
|
||||||
"sds_good_id" TEXT NOT NULL,
|
"sds_good_id" TEXT NOT NULL,
|
||||||
"sds_category_id" TEXT,
|
"sds_category_id" TEXT,
|
||||||
"good_name" TEXT,
|
"good_name" TEXT,
|
||||||
"good_image" TEXT,
|
"good_image" TEXT,
|
||||||
"good_price" DECIMAL(12,2),
|
"good_price" DECIMAL(12,2),
|
||||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
"updated_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")
|
CONSTRAINT "origin_goods_pkey" PRIMARY KEY ("origin_good_id")
|
||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "countries" (
|
CREATE TABLE "countries" (
|
||||||
"country_id" BIGSERIAL NOT NULL,
|
"country_id" BIGSERIAL NOT NULL,
|
||||||
"country_name" TEXT NOT NULL,
|
"country_name" TEXT NOT NULL,
|
||||||
"country_icon" TEXT,
|
"country_icon" TEXT,
|
||||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
CONSTRAINT "countries_pkey" PRIMARY KEY ("country_id")
|
CONSTRAINT "countries_pkey" PRIMARY KEY ("country_id")
|
||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "categories" (
|
CREATE TABLE "categories" (
|
||||||
"category_id" BIGSERIAL NOT NULL,
|
"category_id" BIGSERIAL NOT NULL,
|
||||||
"parent_category_id" BIGINT,
|
"parent_category_id" BIGINT,
|
||||||
"category_name" TEXT NOT NULL,
|
"category_name" TEXT NOT NULL,
|
||||||
"category_icon" TEXT,
|
"category_icon" TEXT,
|
||||||
"sds_category_id" TEXT,
|
"sds_category_id" TEXT,
|
||||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
CONSTRAINT "categories_pkey" PRIMARY KEY ("category_id")
|
CONSTRAINT "categories_pkey" PRIMARY KEY ("category_id")
|
||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "tags" (
|
CREATE TABLE "tags" (
|
||||||
"tag_id" BIGSERIAL NOT NULL,
|
"tag_id" BIGSERIAL NOT NULL,
|
||||||
"tag_name" TEXT NOT NULL,
|
"tag_name" TEXT NOT NULL,
|
||||||
"tag_color" TEXT,
|
"tag_color" TEXT,
|
||||||
"timing" TEXT,
|
"timing" TEXT,
|
||||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
CONSTRAINT "tags_pkey" PRIMARY KEY ("tag_id")
|
CONSTRAINT "tags_pkey" PRIMARY KEY ("tag_id")
|
||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "positions" (
|
CREATE TABLE "positions" (
|
||||||
"position_id" BIGSERIAL NOT NULL,
|
"position_id" BIGSERIAL NOT NULL,
|
||||||
"index_val" INTEGER NOT NULL,
|
"index_val" INTEGER NOT NULL,
|
||||||
"country_id" BIGINT,
|
"country_id" BIGINT,
|
||||||
"category_id" BIGINT,
|
"category_id" BIGINT,
|
||||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
CONSTRAINT "positions_pkey" PRIMARY KEY ("position_id")
|
CONSTRAINT "positions_pkey" PRIMARY KEY ("position_id")
|
||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "goods" (
|
CREATE TABLE "goods" (
|
||||||
"good_id" BIGSERIAL NOT NULL,
|
"good_id" BIGSERIAL NOT NULL,
|
||||||
"origin_good_id" BIGINT NOT NULL,
|
"origin_good_id" BIGINT NOT NULL,
|
||||||
"country_id" BIGINT NOT NULL,
|
"country_id" BIGINT NOT NULL,
|
||||||
"category_id" BIGINT NOT NULL,
|
"category_id" BIGINT NOT NULL,
|
||||||
"tag_id" BIGINT,
|
"tag_id" BIGINT,
|
||||||
"position_id" BIGINT,
|
"position_id" BIGINT,
|
||||||
"good_name" TEXT NOT NULL,
|
"good_name" TEXT NOT NULL,
|
||||||
"good_priority" INTEGER NOT NULL DEFAULT 0,
|
"good_priority" INTEGER NOT NULL DEFAULT 0,
|
||||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
CONSTRAINT "goods_pkey" PRIMARY KEY ("good_id")
|
CONSTRAINT "goods_pkey" PRIMARY KEY ("good_id")
|
||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "users" (
|
CREATE TABLE "users" (
|
||||||
"id" BIGSERIAL NOT NULL,
|
"id" BIGSERIAL NOT NULL,
|
||||||
"username" TEXT NOT NULL,
|
"username" TEXT NOT NULL,
|
||||||
"password_hash" TEXT NOT NULL,
|
"password_hash" TEXT NOT NULL,
|
||||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||||
);
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "sync_logs" (
|
CREATE TABLE "sync_logs" (
|
||||||
"id" BIGSERIAL NOT NULL,
|
"id" BIGSERIAL NOT NULL,
|
||||||
"type" "SyncType" NOT NULL,
|
"type" "SyncType" NOT NULL,
|
||||||
"status" "SyncStatus" NOT NULL,
|
"status" "SyncStatus" NOT NULL,
|
||||||
"message" TEXT,
|
"message" TEXT,
|
||||||
"started_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"started_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
"finished_at" TIMESTAMPTZ(6),
|
"finished_at" TIMESTAMPTZ(6),
|
||||||
|
|
||||||
CONSTRAINT "sync_logs_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "sync_logs_pkey" PRIMARY KEY ("id")
|
||||||
);
|
);
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "origin_goods_sds_good_id_key" ON "origin_goods"("sds_good_id");
|
CREATE UNIQUE INDEX "origin_goods_sds_good_id_key" ON "origin_goods"("sds_good_id");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "origin_goods_sds_category_id_idx" ON "origin_goods"("sds_category_id");
|
CREATE INDEX "origin_goods_sds_category_id_idx" ON "origin_goods"("sds_category_id");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "countries_country_name_key" ON "countries"("country_name");
|
CREATE UNIQUE INDEX "countries_country_name_key" ON "countries"("country_name");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "categories_sds_category_id_key" ON "categories"("sds_category_id");
|
CREATE UNIQUE INDEX "categories_sds_category_id_key" ON "categories"("sds_category_id");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "categories_parent_category_id_idx" ON "categories"("parent_category_id");
|
CREATE INDEX "categories_parent_category_id_idx" ON "categories"("parent_category_id");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "tags_tag_name_key" ON "tags"("tag_name");
|
CREATE UNIQUE INDEX "tags_tag_name_key" ON "tags"("tag_name");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "positions_country_id_idx" ON "positions"("country_id");
|
CREATE INDEX "positions_country_id_idx" ON "positions"("country_id");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "positions_category_id_idx" ON "positions"("category_id");
|
CREATE INDEX "positions_category_id_idx" ON "positions"("category_id");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "positions_country_id_category_id_idx" ON "positions"("country_id", "category_id");
|
CREATE INDEX "positions_country_id_category_id_idx" ON "positions"("country_id", "category_id");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "goods_origin_good_id_idx" ON "goods"("origin_good_id");
|
CREATE INDEX "goods_origin_good_id_idx" ON "goods"("origin_good_id");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "goods_country_id_idx" ON "goods"("country_id");
|
CREATE INDEX "goods_country_id_idx" ON "goods"("country_id");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "goods_category_id_idx" ON "goods"("category_id");
|
CREATE INDEX "goods_category_id_idx" ON "goods"("category_id");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "goods_tag_id_idx" ON "goods"("tag_id");
|
CREATE INDEX "goods_tag_id_idx" ON "goods"("tag_id");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "goods_position_id_idx" ON "goods"("position_id");
|
CREATE INDEX "goods_position_id_idx" ON "goods"("position_id");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "goods_good_priority_idx" ON "goods"("good_priority" DESC);
|
CREATE INDEX "goods_good_priority_idx" ON "goods"("good_priority" DESC);
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "goods_country_id_good_priority_idx" ON "goods"("country_id", "good_priority" DESC);
|
CREATE INDEX "goods_country_id_good_priority_idx" ON "goods"("country_id", "good_priority" DESC);
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "goods_category_id_country_id_idx" ON "goods"("category_id", "country_id");
|
CREATE INDEX "goods_category_id_country_id_idx" ON "goods"("category_id", "country_id");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
|
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "sync_logs_type_started_at_idx" ON "sync_logs"("type", "started_at");
|
CREATE INDEX "sync_logs_type_started_at_idx" ON "sync_logs"("type", "started_at");
|
||||||
|
|
||||||
-- AddForeignKey
|
-- 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;
|
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
|
-- 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;
|
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
|
-- 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;
|
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
|
-- 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;
|
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
|
-- 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;
|
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
|
-- 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;
|
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
|
-- 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;
|
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
|
-- 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;
|
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;
|
||||||
|
|||||||
@@ -1,53 +1,53 @@
|
|||||||
-- AlterTable
|
-- AlterTable
|
||||||
ALTER TABLE "tags" ADD COLUMN "tag_group_id" BIGINT,
|
ALTER TABLE "tags" ADD COLUMN "tag_group_id" BIGINT,
|
||||||
ADD COLUMN "sort_order" INTEGER NOT NULL DEFAULT 0;
|
ADD COLUMN "sort_order" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "tag_groups" (
|
CREATE TABLE "tag_groups" (
|
||||||
"tag_group_id" BIGSERIAL NOT NULL,
|
"tag_group_id" BIGSERIAL NOT NULL,
|
||||||
"group_name" TEXT NOT NULL,
|
"group_name" TEXT NOT NULL,
|
||||||
"group_icon" TEXT,
|
"group_icon" TEXT,
|
||||||
"group_color" TEXT,
|
"group_color" TEXT,
|
||||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
CONSTRAINT "tag_groups_pkey" PRIMARY KEY ("tag_group_id")
|
CONSTRAINT "tag_groups_pkey" PRIMARY KEY ("tag_group_id")
|
||||||
);
|
);
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "tag_groups_group_name_key" ON "tag_groups"("group_name");
|
CREATE UNIQUE INDEX "tag_groups_group_name_key" ON "tag_groups"("group_name");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "tags_tag_group_id_idx" ON "tags"("tag_group_id");
|
CREATE INDEX "tags_tag_group_id_idx" ON "tags"("tag_group_id");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "tags_tag_group_id_sort_order_idx" ON "tags"("tag_group_id", "sort_order");
|
CREATE INDEX "tags_tag_group_id_sort_order_idx" ON "tags"("tag_group_id", "sort_order");
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "tags" ADD CONSTRAINT "tags_tag_group_id_fkey" FOREIGN KEY ("tag_group_id") REFERENCES "tag_groups"("tag_group_id") ON DELETE SET NULL ON UPDATE NO ACTION;
|
ALTER TABLE "tags" ADD CONSTRAINT "tags_tag_group_id_fkey" FOREIGN KEY ("tag_group_id") REFERENCES "tag_groups"("tag_group_id") ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||||
|
|
||||||
-- Seed: insert 3 default tag groups
|
-- Seed: insert 3 default tag groups
|
||||||
INSERT INTO "tag_groups" ("group_name", "sort_order", "created_at", "updated_at") VALUES
|
INSERT INTO "tag_groups" ("group_name", "sort_order", "created_at", "updated_at") VALUES
|
||||||
('物流渠道', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
('物流渠道', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||||
('印刷位置', 2, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
('印刷位置', 2, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||||
('印刷工艺', 3, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
|
('印刷工艺', 3, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
|
||||||
|
|
||||||
-- Seed: assign existing tags to groups
|
-- Seed: assign existing tags to groups
|
||||||
UPDATE "tags" SET "tag_group_id" = (SELECT "tag_group_id" FROM "tag_groups" WHERE "group_name" = '物流渠道')
|
UPDATE "tags" SET "tag_group_id" = (SELECT "tag_group_id" FROM "tag_groups" WHERE "group_name" = '物流渠道')
|
||||||
WHERE "tag_name" IN ('包邮', '不包邮');
|
WHERE "tag_name" IN ('包邮', '不包邮');
|
||||||
UPDATE "tags" SET "tag_group_id" = (SELECT "tag_group_id" FROM "tag_groups" WHERE "group_name" = '印刷位置')
|
UPDATE "tags" SET "tag_group_id" = (SELECT "tag_group_id" FROM "tag_groups" WHERE "group_name" = '印刷位置')
|
||||||
WHERE "tag_name" IN ('单面印', '双面印');
|
WHERE "tag_name" IN ('单面印', '双面印');
|
||||||
UPDATE "tags" SET "tag_group_id" = (SELECT "tag_group_id" FROM "tag_groups" WHERE "group_name" = '印刷工艺')
|
UPDATE "tags" SET "tag_group_id" = (SELECT "tag_group_id" FROM "tag_groups" WHERE "group_name" = '印刷工艺')
|
||||||
WHERE "tag_name" IN ('烫画', '直喷', '不打印');
|
WHERE "tag_name" IN ('烫画', '直喷', '不打印');
|
||||||
|
|
||||||
-- Seed: assign sortOrder within each group by tag_id
|
-- Seed: assign sortOrder within each group by tag_id
|
||||||
WITH ordered AS (
|
WITH ordered AS (
|
||||||
SELECT "tag_id",
|
SELECT "tag_id",
|
||||||
ROW_NUMBER() OVER (PARTITION BY "tag_group_id" ORDER BY "tag_id") AS rn
|
ROW_NUMBER() OVER (PARTITION BY "tag_group_id" ORDER BY "tag_id") AS rn
|
||||||
FROM "tags"
|
FROM "tags"
|
||||||
WHERE "tag_group_id" IS NOT NULL
|
WHERE "tag_group_id" IS NOT NULL
|
||||||
)
|
)
|
||||||
UPDATE "tags" SET "sort_order" = ordered.rn
|
UPDATE "tags" SET "sort_order" = ordered.rn
|
||||||
FROM ordered
|
FROM ordered
|
||||||
WHERE "tags"."tag_id" = ordered."tag_id";
|
WHERE "tags"."tag_id" = ordered."tag_id";
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "origin_goods" ADD COLUMN "delisted" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- Sync database with schema.prisma (missing columns/table from earlier iterations)
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "goods" ADD COLUMN "good_image" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "tags" ADD COLUMN "tag_font_color" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "good_tags" (
|
||||||
|
"good_id" BIGINT NOT NULL,
|
||||||
|
"tag_id" BIGINT NOT NULL,
|
||||||
|
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "good_tags_pkey" PRIMARY KEY ("good_id","tag_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "good_tags_tag_id_idx" ON "good_tags"("tag_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "good_tags" ADD CONSTRAINT "good_tags_good_id_fkey" FOREIGN KEY ("good_id") REFERENCES "goods"("good_id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
|
ALTER TABLE "good_tags" ADD CONSTRAINT "good_tags_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "tags"("tag_id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE TYPE "OriginGoodSource" AS ENUM ('SDS', 'CUSTOM');
|
||||||
|
|
||||||
|
ALTER TABLE "origin_goods"
|
||||||
|
ADD COLUMN "source" "OriginGoodSource" NOT NULL DEFAULT 'SDS';
|
||||||
|
|
||||||
|
CREATE INDEX "origin_goods_source_idx" ON "origin_goods"("source");
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Create enum for user roles
|
||||||
|
CREATE TYPE "Role" AS ENUM ('ADMIN');
|
||||||
|
|
||||||
|
-- Add role column, existing users become ADMIN
|
||||||
|
ALTER TABLE "users" ADD COLUMN "role" "Role" NOT NULL DEFAULT 'ADMIN';
|
||||||
|
|
||||||
|
-- Token version for JWT revocation (logout bumps it)
|
||||||
|
ALTER TABLE "users" ADD COLUMN "token_version" INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "good_origin_goods" (
|
||||||
|
"good_id" BIGINT NOT NULL,
|
||||||
|
"origin_good_id" BIGINT NOT NULL,
|
||||||
|
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "good_origin_goods_pkey" PRIMARY KEY ("good_id","origin_good_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "good_origin_goods_origin_good_id_idx" ON "good_origin_goods"("origin_good_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "good_origin_goods" ADD CONSTRAINT "good_origin_goods_good_id_fkey" FOREIGN KEY ("good_id") REFERENCES "goods"("good_id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "good_origin_goods" ADD CONSTRAINT "good_origin_goods_origin_good_id_fkey" FOREIGN KEY ("origin_good_id") REFERENCES "origin_goods"("origin_good_id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
|
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
@@ -14,6 +15,11 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Origin Goods ----------
|
// ---------- Origin Goods ----------
|
||||||
|
enum OriginGoodSource {
|
||||||
|
SDS
|
||||||
|
CUSTOM
|
||||||
|
}
|
||||||
|
|
||||||
model OriginGood {
|
model OriginGood {
|
||||||
id BigInt @id @default(autoincrement()) @map("origin_good_id")
|
id BigInt @id @default(autoincrement()) @map("origin_good_id")
|
||||||
sdsGoodId String @unique @map("sds_good_id")
|
sdsGoodId String @unique @map("sds_good_id")
|
||||||
@@ -23,14 +29,17 @@ model OriginGood {
|
|||||||
goodImage String? @map("good_image")
|
goodImage String? @map("good_image")
|
||||||
goodPrice Decimal? @map("good_price") @db.Decimal(12, 2)
|
goodPrice Decimal? @map("good_price") @db.Decimal(12, 2)
|
||||||
delisted Boolean @default(false)
|
delisted Boolean @default(false)
|
||||||
|
source OriginGoodSource @default(SDS)
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
goods Good[]
|
goods Good[]
|
||||||
detail OriginGoodDetail?
|
detail OriginGoodDetail?
|
||||||
variants OriginGoodVariant[]
|
variants OriginGoodVariant[]
|
||||||
|
mergedIntoGoods GoodOriginGood[]
|
||||||
|
|
||||||
@@index([sdsCategoryId])
|
@@index([sdsCategoryId])
|
||||||
|
@@index([source])
|
||||||
@@map("origin_goods")
|
@@map("origin_goods")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,6 +216,7 @@ model Good {
|
|||||||
tag Tag? @relation(fields: [tagId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
tag Tag? @relation(fields: [tagId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||||
position Position? @relation(fields: [positionId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
position Position? @relation(fields: [positionId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||||
goodTags GoodTag[]
|
goodTags GoodTag[]
|
||||||
|
mergedOriginGoods GoodOriginGood[]
|
||||||
|
|
||||||
@@index([originGoodId])
|
@@index([originGoodId])
|
||||||
@@index([countryId])
|
@@index([countryId])
|
||||||
@@ -233,11 +243,33 @@ model GoodTag {
|
|||||||
@@map("good_tags")
|
@@map("good_tags")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Good-OriginGood Junction (merged secondary sources, M:N) ----------
|
||||||
|
// Primary source stays on goods.origin_good_id and is NOT stored here.
|
||||||
|
model GoodOriginGood {
|
||||||
|
goodId BigInt @map("good_id")
|
||||||
|
originGoodId BigInt @map("origin_good_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
|
good Good @relation(fields: [goodId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@id([goodId, originGoodId])
|
||||||
|
@@index([originGoodId])
|
||||||
|
@@map("good_origin_goods")
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Users (admin authentication) ----------
|
// ---------- Users (admin authentication) ----------
|
||||||
|
enum Role {
|
||||||
|
ADMIN
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
username String @unique
|
username String @unique
|
||||||
passwordHash String @map("password_hash")
|
passwordHash String @map("password_hash")
|
||||||
|
role Role @default(ADMIN)
|
||||||
|
// Bumped on logout / revocation; JWTs carrying an older version are rejected.
|
||||||
|
tokenVersion Int @default(0) @map("token_version")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
@@ -1,5 +1,5 @@
|
|||||||
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<g id="毛毯">
|
<g id="毛毯">
|
||||||
<path id="Vector" d="M4.88633 1.98665C5.33119 1.97156 5.8398 1.98301 6.28931 1.98301L8.87332 1.98303L16.492 1.98247C17.189 1.98315 17.7065 2.1872 18.0772 2.81271C18.3143 3.2131 18.2842 3.61555 18.2841 4.05998L18.2832 4.9648C18.2977 4.96406 18.3123 4.96311 18.327 4.96317C18.946 4.96544 19.5651 4.96377 20.1842 4.96464C20.35 4.96487 20.5158 4.9649 20.6812 4.97965C21.3291 5.03911 21.9365 5.32106 22.4002 5.77761C22.9854 6.35146 23.2455 7.05899 23.2511 7.8691L23.2513 15.9239L23.2516 18.1581C23.2517 18.5872 23.2748 19.2749 23.1974 19.6785C23.0883 20.23 22.8164 20.7362 22.417 21.1318C22.2073 21.3409 21.9682 21.518 21.7072 21.6575C20.8557 22.1159 20.0448 22.0235 19.1195 22.0226H8.94568L6.28116 22.0228C5.66064 22.0231 4.88519 22.0568 4.28608 21.9464C3.4868 21.8008 2.74539 21.4309 2.14822 20.88C1.30557 20.1054 0.806162 19.027 0.760423 17.8834C0.744748 17.5372 0.750489 17.1759 0.750662 16.8281L0.750771 15.2893L0.750812 10.4876L0.750558 7.56548C0.750482 6.91186 0.712701 6.06771 0.837323 5.44551C0.997082 4.66425 1.36911 3.94214 1.91253 3.35852C2.68497 2.52818 3.75329 2.03535 4.88633 1.98665ZM1.50862 15.253C2.14832 14.3061 3.14214 13.6566 4.26615 13.4506C4.77379 13.3528 5.22789 13.3733 5.74026 13.3733L7.22112 13.3737L12.4984 13.3738L15.4964 13.3742L16.3552 13.3732C16.8991 13.3726 17.0379 13.3561 17.5334 13.5986V6.61544V4.56193C17.5335 4.30358 17.549 3.72192 17.5225 3.49302C17.5006 3.30198 17.4139 3.12419 17.2769 2.98926C16.9857 2.69911 16.6673 2.73987 16.2868 2.74018L15.5406 2.7411L13.0675 2.74118L5.14866 2.74166C5.07448 2.74247 5.00032 2.74432 4.92619 2.7472C3.92002 2.80855 3.077 3.17975 2.40127 3.93833C1.97103 4.42125 1.68113 5.01268 1.563 5.64857C1.47184 6.14183 1.50058 6.87789 1.50091 7.39632L1.50111 9.95416L1.50107 13.4367L1.50105 14.5679C1.50109 14.7909 1.49296 15.0324 1.50862 15.253ZM8.99453 18.9369L16.2925 18.9372L18.6071 18.9366L19.3445 18.9363C19.5484 18.9363 19.9029 18.898 20.056 19.0438C20.1299 19.1145 20.1708 19.2128 20.1691 19.3151C20.1667 19.4209 20.1207 19.5211 20.0418 19.5917C19.9477 19.6764 19.8889 19.6822 19.7676 19.6861C19.4157 19.6972 19.0567 19.6923 18.7049 19.6923L16.8063 19.6924H11.0456L6.92691 19.6928L5.64741 19.6927C5.38641 19.6927 5.09412 19.7061 4.83641 19.6802C3.90898 19.587 3.25249 18.7671 3.34189 17.8533C3.38568 17.4162 3.6029 17.0151 3.94493 16.7394C4.20443 16.5318 4.51749 16.402 4.84778 16.3651C5.04529 16.3417 5.27545 16.3472 5.47726 16.3473L6.33113 16.3475L9.20144 16.3477L14.7026 16.3475C15.6297 16.3475 16.6073 16.3314 17.5311 16.3491L17.5349 15.4343C17.535 15.2346 17.5476 14.9099 17.5039 14.73C17.4709 14.5924 17.402 14.4661 17.3042 14.3639C17.0397 14.0929 16.7182 14.1312 16.371 14.1312L15.6384 14.1317H13.1882L5.32143 14.1324C5.24029 14.1331 5.15918 14.1352 5.07812 14.1387C4.01313 14.198 3.10203 14.573 2.38161 15.3836C1.76329 16.0772 1.44917 16.99 1.50965 17.9172C1.56819 18.8651 2.00149 19.7507 2.71393 20.3786C3.22367 20.8289 3.85296 21.1221 4.52573 21.2224C4.85955 21.2734 5.19037 21.2625 5.52735 21.2625L6.74896 21.2624L11.0247 21.2619L17.3304 21.2618L19.3445 21.2623C19.7137 21.2624 20.0973 21.2727 20.4665 21.2537C21.0698 21.2227 21.64 20.8929 22.0275 20.4408C22.7492 19.599 22.6019 18.3111 21.764 17.5974C21.451 17.33 21.0641 17.164 20.6547 17.1215C20.4169 17.0944 20.0379 17.1047 19.7895 17.1046H18.3545L13.6232 17.105L7.80963 17.1052L5.98936 17.1046C5.66054 17.1045 5.3165 17.0978 4.98927 17.1128C4.72169 17.1251 4.46675 17.2485 4.29114 17.4549C4.13868 17.6329 4.06436 17.8648 4.08495 18.0982C4.10752 18.3431 4.22707 18.5688 4.41697 18.725C4.72065 18.9786 5.05403 18.9374 5.4256 18.9375H6.19901L8.99453 18.9369ZM18.2839 16.3473C18.5978 16.3331 19.0591 16.3473 19.3878 16.3477C19.8266 16.3481 20.4738 16.3234 20.8846 16.3909C21.1822 16.4405 21.4698 16.5372 21.7369 16.6773C21.9299 16.7794 22.1104 16.9035 22.2749 17.0472C22.3199 17.0859 22.4581 17.2153 22.4969 17.2411C22.5077 16.9956 22.5012 16.7118 22.501 16.4625L22.5009 15.1418L22.5006 11.0416L22.5007 8.79514C22.5011 8.43763 22.5203 7.7808 22.4675 7.45223C22.403 7.05879 22.2247 6.69285 21.9545 6.3996C21.09 5.45927 20.0243 5.7749 18.8906 5.72076C18.7008 5.71171 18.4688 5.7379 18.2855 5.71609C18.2818 6.29028 18.2808 6.8645 18.2829 7.4387L18.2832 10.5551L18.2839 16.3473Z" fill="var(--fill-0, black)"/>
|
<path id="Vector" d="M4.88633 1.98665C5.33119 1.97156 5.8398 1.98301 6.28931 1.98301L8.87332 1.98303L16.492 1.98247C17.189 1.98315 17.7065 2.1872 18.0772 2.81271C18.3143 3.2131 18.2842 3.61555 18.2841 4.05998L18.2832 4.9648C18.2977 4.96406 18.3123 4.96311 18.327 4.96317C18.946 4.96544 19.5651 4.96377 20.1842 4.96464C20.35 4.96487 20.5158 4.9649 20.6812 4.97965C21.3291 5.03911 21.9365 5.32106 22.4002 5.77761C22.9854 6.35146 23.2455 7.05899 23.2511 7.8691L23.2513 15.9239L23.2516 18.1581C23.2517 18.5872 23.2748 19.2749 23.1974 19.6785C23.0883 20.23 22.8164 20.7362 22.417 21.1318C22.2073 21.3409 21.9682 21.518 21.7072 21.6575C20.8557 22.1159 20.0448 22.0235 19.1195 22.0226H8.94568L6.28116 22.0228C5.66064 22.0231 4.88519 22.0568 4.28608 21.9464C3.4868 21.8008 2.74539 21.4309 2.14822 20.88C1.30557 20.1054 0.806162 19.027 0.760423 17.8834C0.744748 17.5372 0.750489 17.1759 0.750662 16.8281L0.750771 15.2893L0.750812 10.4876L0.750558 7.56548C0.750482 6.91186 0.712701 6.06771 0.837323 5.44551C0.997082 4.66425 1.36911 3.94214 1.91253 3.35852C2.68497 2.52818 3.75329 2.03535 4.88633 1.98665ZM1.50862 15.253C2.14832 14.3061 3.14214 13.6566 4.26615 13.4506C4.77379 13.3528 5.22789 13.3733 5.74026 13.3733L7.22112 13.3737L12.4984 13.3738L15.4964 13.3742L16.3552 13.3732C16.8991 13.3726 17.0379 13.3561 17.5334 13.5986V6.61544V4.56193C17.5335 4.30358 17.549 3.72192 17.5225 3.49302C17.5006 3.30198 17.4139 3.12419 17.2769 2.98926C16.9857 2.69911 16.6673 2.73987 16.2868 2.74018L15.5406 2.7411L13.0675 2.74118L5.14866 2.74166C5.07448 2.74247 5.00032 2.74432 4.92619 2.7472C3.92002 2.80855 3.077 3.17975 2.40127 3.93833C1.97103 4.42125 1.68113 5.01268 1.563 5.64857C1.47184 6.14183 1.50058 6.87789 1.50091 7.39632L1.50111 9.95416L1.50107 13.4367L1.50105 14.5679C1.50109 14.7909 1.49296 15.0324 1.50862 15.253ZM8.99453 18.9369L16.2925 18.9372L18.6071 18.9366L19.3445 18.9363C19.5484 18.9363 19.9029 18.898 20.056 19.0438C20.1299 19.1145 20.1708 19.2128 20.1691 19.3151C20.1667 19.4209 20.1207 19.5211 20.0418 19.5917C19.9477 19.6764 19.8889 19.6822 19.7676 19.6861C19.4157 19.6972 19.0567 19.6923 18.7049 19.6923L16.8063 19.6924H11.0456L6.92691 19.6928L5.64741 19.6927C5.38641 19.6927 5.09412 19.7061 4.83641 19.6802C3.90898 19.587 3.25249 18.7671 3.34189 17.8533C3.38568 17.4162 3.6029 17.0151 3.94493 16.7394C4.20443 16.5318 4.51749 16.402 4.84778 16.3651C5.04529 16.3417 5.27545 16.3472 5.47726 16.3473L6.33113 16.3475L9.20144 16.3477L14.7026 16.3475C15.6297 16.3475 16.6073 16.3314 17.5311 16.3491L17.5349 15.4343C17.535 15.2346 17.5476 14.9099 17.5039 14.73C17.4709 14.5924 17.402 14.4661 17.3042 14.3639C17.0397 14.0929 16.7182 14.1312 16.371 14.1312L15.6384 14.1317H13.1882L5.32143 14.1324C5.24029 14.1331 5.15918 14.1352 5.07812 14.1387C4.01313 14.198 3.10203 14.573 2.38161 15.3836C1.76329 16.0772 1.44917 16.99 1.50965 17.9172C1.56819 18.8651 2.00149 19.7507 2.71393 20.3786C3.22367 20.8289 3.85296 21.1221 4.52573 21.2224C4.85955 21.2734 5.19037 21.2625 5.52735 21.2625L6.74896 21.2624L11.0247 21.2619L17.3304 21.2618L19.3445 21.2623C19.7137 21.2624 20.0973 21.2727 20.4665 21.2537C21.0698 21.2227 21.64 20.8929 22.0275 20.4408C22.7492 19.599 22.6019 18.3111 21.764 17.5974C21.451 17.33 21.0641 17.164 20.6547 17.1215C20.4169 17.0944 20.0379 17.1047 19.7895 17.1046H18.3545L13.6232 17.105L7.80963 17.1052L5.98936 17.1046C5.66054 17.1045 5.3165 17.0978 4.98927 17.1128C4.72169 17.1251 4.46675 17.2485 4.29114 17.4549C4.13868 17.6329 4.06436 17.8648 4.08495 18.0982C4.10752 18.3431 4.22707 18.5688 4.41697 18.725C4.72065 18.9786 5.05403 18.9374 5.4256 18.9375H6.19901L8.99453 18.9369ZM18.2839 16.3473C18.5978 16.3331 19.0591 16.3473 19.3878 16.3477C19.8266 16.3481 20.4738 16.3234 20.8846 16.3909C21.1822 16.4405 21.4698 16.5372 21.7369 16.6773C21.9299 16.7794 22.1104 16.9035 22.2749 17.0472C22.3199 17.0859 22.4581 17.2153 22.4969 17.2411C22.5077 16.9956 22.5012 16.7118 22.501 16.4625L22.5009 15.1418L22.5006 11.0416L22.5007 8.79514C22.5011 8.43763 22.5203 7.7808 22.4675 7.45223C22.403 7.05879 22.2247 6.69285 21.9545 6.3996C21.09 5.45927 20.0243 5.7749 18.8906 5.72076C18.7008 5.71171 18.4688 5.7379 18.2855 5.71609C18.2818 6.29028 18.2808 6.8645 18.2829 7.4387L18.2832 10.5551L18.2839 16.3473Z" fill="var(--fill-0, black)"/>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.8 KiB |
@@ -1,8 +1,8 @@
|
|||||||
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 16.9444 21.2465" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 16.9444 21.2465" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<g id="Group 40">
|
<g id="Group 40">
|
||||||
<path id="Vector" d="M4.60937 9.39516C4.83089 9.41263 5.19797 9.40155 5.42769 9.40155L6.94956 9.40133C8.71577 9.40094 10.5191 9.38265 12.2815 9.40662C12.9508 11.3307 14.3581 13.4287 15.5839 15.0446C15.8822 15.4378 16.7974 16.3807 16.9444 16.6953C16.6383 17.2507 15.7458 17.7321 15.2033 18.0528C14.5811 18.4207 14.5649 18.082 14.2723 17.5918C14.0964 17.2971 13.9313 16.9779 13.7623 16.6745L12.1526 13.8128C11.8994 13.3696 11.5575 12.7309 11.217 12.3736C11.7063 14.5828 12.9504 16.8791 14.0188 18.8723C14.1871 19.1861 13.9309 19.3898 13.694 19.5564C13.4559 19.7238 12.6714 20.2953 12.3918 20.2153C12.2449 20.0934 12.0742 19.5867 11.9879 19.4213C11.7179 18.9037 10.0267 14.7534 9.73361 14.6621C9.63034 14.8155 9.77541 15.2831 9.80986 15.4684C10.1336 17.2094 10.7771 18.8659 11.4514 20.4962C11.5693 20.9293 9.64682 21.1744 9.30522 21.2057C8.24188 21.3076 7.16888 21.2186 6.13686 20.9428C5.9456 20.8923 5.66846 20.8251 5.5108 20.6989C5.46617 20.6631 5.41691 20.5888 5.43238 20.5306C5.54204 20.1186 5.80851 19.6085 5.95323 19.214C6.38496 18.0368 7.18965 15.777 7.16438 14.5671C6.91075 14.8989 6.6099 15.5769 6.42632 15.9742C6.019 16.8664 5.62029 17.7625 5.23025 18.6625C5.02017 19.1417 4.74544 19.823 4.49277 20.2564C4.44175 20.2465 4.39117 20.2343 4.34122 20.2198C3.94495 20.1042 2.97693 19.496 2.76767 19.1178C2.92234 18.6804 3.44618 17.8446 3.66926 17.3829C4.44438 15.7788 5.25941 14.108 5.66802 12.3696C4.94019 13.1618 2.49025 18.0769 2.21709 18.2064C2.15024 18.238 2.05775 18.2215 1.99036 18.1973C1.55572 18.0414 0.201822 17.2085 0.0193916 16.8083C-0.1163 16.5107 0.497699 15.9933 0.69442 15.7758C2.28029 14.0222 3.77967 11.6073 4.60937 9.39516Z" fill="var(--fill-0, #000000)"/>
|
<path id="Vector" d="M4.60937 9.39516C4.83089 9.41263 5.19797 9.40155 5.42769 9.40155L6.94956 9.40133C8.71577 9.40094 10.5191 9.38265 12.2815 9.40662C12.9508 11.3307 14.3581 13.4287 15.5839 15.0446C15.8822 15.4378 16.7974 16.3807 16.9444 16.6953C16.6383 17.2507 15.7458 17.7321 15.2033 18.0528C14.5811 18.4207 14.5649 18.082 14.2723 17.5918C14.0964 17.2971 13.9313 16.9779 13.7623 16.6745L12.1526 13.8128C11.8994 13.3696 11.5575 12.7309 11.217 12.3736C11.7063 14.5828 12.9504 16.8791 14.0188 18.8723C14.1871 19.1861 13.9309 19.3898 13.694 19.5564C13.4559 19.7238 12.6714 20.2953 12.3918 20.2153C12.2449 20.0934 12.0742 19.5867 11.9879 19.4213C11.7179 18.9037 10.0267 14.7534 9.73361 14.6621C9.63034 14.8155 9.77541 15.2831 9.80986 15.4684C10.1336 17.2094 10.7771 18.8659 11.4514 20.4962C11.5693 20.9293 9.64682 21.1744 9.30522 21.2057C8.24188 21.3076 7.16888 21.2186 6.13686 20.9428C5.9456 20.8923 5.66846 20.8251 5.5108 20.6989C5.46617 20.6631 5.41691 20.5888 5.43238 20.5306C5.54204 20.1186 5.80851 19.6085 5.95323 19.214C6.38496 18.0368 7.18965 15.777 7.16438 14.5671C6.91075 14.8989 6.6099 15.5769 6.42632 15.9742C6.019 16.8664 5.62029 17.7625 5.23025 18.6625C5.02017 19.1417 4.74544 19.823 4.49277 20.2564C4.44175 20.2465 4.39117 20.2343 4.34122 20.2198C3.94495 20.1042 2.97693 19.496 2.76767 19.1178C2.92234 18.6804 3.44618 17.8446 3.66926 17.3829C4.44438 15.7788 5.25941 14.108 5.66802 12.3696C4.94019 13.1618 2.49025 18.0769 2.21709 18.2064C2.15024 18.238 2.05775 18.2215 1.99036 18.1973C1.55572 18.0414 0.201822 17.2085 0.0193916 16.8083C-0.1163 16.5107 0.497699 15.9933 0.69442 15.7758C2.28029 14.0222 3.77967 11.6073 4.60937 9.39516Z" fill="var(--fill-0, #000000)"/>
|
||||||
<path id="Vector_2" d="M4.62793 0.0189548C4.95631 0.0052677 5.36925 0.0045545 5.69706 0.0201891C6.26604 0.0473438 5.85348 1.03493 6.14374 1.42184C6.3697 1.72304 6.60509 1.99478 6.84768 2.28177C7.27209 2.78427 7.69139 3.29099 8.10563 3.80189C9.04203 4.96817 9.85815 6.15223 10.759 7.30756L9.78755 7.31091L4.67883 7.30962C4.61824 6.73838 4.55875 6.30262 4.40945 5.73701C4.12639 4.6645 3.51922 3.65092 3.76517 2.51157C3.87777 1.99001 4.27615 1.65282 4.41615 1.17841C4.48472 0.946111 4.42237 0.412836 4.49314 0.150148C4.51632 0.0640753 4.55721 0.0571635 4.62793 0.0189548Z" fill="var(--fill-0, #000000)"/>
|
<path id="Vector_2" d="M4.62793 0.0189548C4.95631 0.0052677 5.36925 0.0045545 5.69706 0.0201891C6.26604 0.0473438 5.85348 1.03493 6.14374 1.42184C6.3697 1.72304 6.60509 1.99478 6.84768 2.28177C7.27209 2.78427 7.69139 3.29099 8.10563 3.80189C9.04203 4.96817 9.85815 6.15223 10.759 7.30756L9.78755 7.31091L4.67883 7.30962C4.61824 6.73838 4.55875 6.30262 4.40945 5.73701C4.12639 4.6645 3.51922 3.65092 3.76517 2.51157C3.87777 1.99001 4.27615 1.65282 4.41615 1.17841C4.48472 0.946111 4.42237 0.412836 4.49314 0.150148C4.51632 0.0640753 4.55721 0.0571635 4.62793 0.0189548Z" fill="var(--fill-0, #000000)"/>
|
||||||
<path id="Vector_3" d="M11.061 0.016756C11.3249 0.0125319 12.1975 -0.0521733 12.3509 0.104885C12.4479 0.279115 12.3539 0.945805 12.4893 1.27531C12.6708 1.71711 12.9577 1.96767 13.086 2.42878C13.4027 3.56613 12.7585 4.65918 12.4803 5.74364C12.3332 6.31713 12.2859 6.72258 12.2107 7.30762C11.8729 7.31264 11.5351 7.31283 11.1973 7.30816C11.1476 7.22593 11.0856 7.14044 11.0303 7.06084C10.5544 6.38636 10.0687 5.71895 9.57305 5.05887C9.31283 4.71083 8.96544 4.22429 8.67958 3.91519C8.93354 3.69005 10.7286 1.47014 10.7952 1.27473C10.9056 0.950687 10.8169 0.581355 10.8834 0.247187C10.9088 0.11967 10.9592 0.085164 11.061 0.016756Z" fill="var(--fill-0, #000000)"/>
|
<path id="Vector_3" d="M11.061 0.016756C11.3249 0.0125319 12.1975 -0.0521733 12.3509 0.104885C12.4479 0.279115 12.3539 0.945805 12.4893 1.27531C12.6708 1.71711 12.9577 1.96767 13.086 2.42878C13.4027 3.56613 12.7585 4.65918 12.4803 5.74364C12.3332 6.31713 12.2859 6.72258 12.2107 7.30762C11.8729 7.31264 11.5351 7.31283 11.1973 7.30816C11.1476 7.22593 11.0856 7.14044 11.0303 7.06084C10.5544 6.38636 10.0687 5.71895 9.57305 5.05887C9.31283 4.71083 8.96544 4.22429 8.67958 3.91519C8.93354 3.69005 10.7286 1.47014 10.7952 1.27473C10.9056 0.950687 10.8169 0.581355 10.8834 0.247187C10.9088 0.11967 10.9592 0.085164 11.061 0.016756Z" fill="var(--fill-0, #000000)"/>
|
||||||
<path id="Vector_4" d="M4.75302 7.85951L12.2149 7.8591C12.2154 8.05077 12.2291 8.77411 12.2066 8.92294L12.1674 8.93525C10.0843 8.9821 7.97963 8.91638 5.8947 8.94109C5.51017 8.94565 5.11366 8.92598 4.73124 8.94817C4.77096 8.6908 4.75466 8.13501 4.75302 7.85951Z" fill="var(--fill-0, #000000)"/>
|
<path id="Vector_4" d="M4.75302 7.85951L12.2149 7.8591C12.2154 8.05077 12.2291 8.77411 12.2066 8.92294L12.1674 8.93525C10.0843 8.9821 7.97963 8.91638 5.8947 8.94109C5.51017 8.94565 5.11366 8.92598 4.73124 8.94817C4.77096 8.6908 4.75466 8.13501 4.75302 7.85951Z" fill="var(--fill-0, #000000)"/>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Export all application tables from the local (source) database to a JSON
|
||||||
|
* file, preserving column types for the matching import-data.mjs script.
|
||||||
|
*
|
||||||
|
* Usage (from apps/api, against the local DB in .env):
|
||||||
|
* node scripts/export-data.mjs <output.json>
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { writeFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const TABLES = [
|
||||||
|
'users',
|
||||||
|
'countries',
|
||||||
|
'categories',
|
||||||
|
'tag_groups',
|
||||||
|
'tags',
|
||||||
|
'positions',
|
||||||
|
'origin_goods',
|
||||||
|
'origin_good_variants',
|
||||||
|
'origin_good_details',
|
||||||
|
'goods',
|
||||||
|
'good_tags',
|
||||||
|
'sync_logs',
|
||||||
|
];
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// Serialize values losslessly; import side uses information_schema to restore types.
|
||||||
|
function serialize(value) {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
if (typeof value === 'bigint') return value.toString();
|
||||||
|
if (value instanceof Date) return value.toISOString();
|
||||||
|
if (typeof value === 'object' && Buffer.isBuffer(value)) return value.toString('base64');
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value); // jsonb
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const out = process.argv[2];
|
||||||
|
if (!out) {
|
||||||
|
console.error('Usage: node scripts/export-data.mjs <output.json>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dump = { exportedAt: new Date().toISOString(), tables: {} };
|
||||||
|
for (const table of TABLES) {
|
||||||
|
const rows = await prisma.$queryRawUnsafe(`SELECT * FROM "${table}"`);
|
||||||
|
dump.tables[table] = rows.map((row) => {
|
||||||
|
const o = {};
|
||||||
|
for (const [k, v] of Object.entries(row)) o[k] = serialize(v);
|
||||||
|
return o;
|
||||||
|
});
|
||||||
|
console.log(`${table}: ${rows.length} rows`);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(out, JSON.stringify(dump));
|
||||||
|
console.log(`Wrote ${out}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Import a dump produced by export-data.mjs into the current database.
|
||||||
|
* Tables are truncated first (order-independent via session_replication_role)
|
||||||
|
* and columns are cast back to their real types using information_schema.
|
||||||
|
*
|
||||||
|
* Usage (inside the api container):
|
||||||
|
* node scripts/import-data.mjs <dump.json>
|
||||||
|
*/
|
||||||
|
import { PrismaClient, Prisma } from '@prisma/client';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
function toLiteral(value, udtName) {
|
||||||
|
if (value === null) return Prisma.sql`NULL`;
|
||||||
|
const target = Prisma.raw(`"${udtName}"`);
|
||||||
|
switch (udtName) {
|
||||||
|
case 'int2':
|
||||||
|
case 'int4':
|
||||||
|
case 'int8':
|
||||||
|
return Prisma.sql`${BigInt(value)}::${target}`;
|
||||||
|
case 'float4':
|
||||||
|
case 'float8':
|
||||||
|
case 'numeric':
|
||||||
|
return Prisma.sql`${Number(value)}::${target}`;
|
||||||
|
case 'bool':
|
||||||
|
return Prisma.sql`${!!value}::${target}`;
|
||||||
|
case 'timestamptz':
|
||||||
|
case 'timestamp':
|
||||||
|
return Prisma.sql`${new Date(value).toISOString()}::${target}`;
|
||||||
|
case 'date':
|
||||||
|
return Prisma.sql`${String(value)}::${target}`;
|
||||||
|
case 'jsonb':
|
||||||
|
case 'json':
|
||||||
|
return Prisma.sql`${typeof value === 'string' ? value : JSON.stringify(value)}::${target}`;
|
||||||
|
case 'bytea':
|
||||||
|
return Prisma.sql`${Buffer.from(value, 'base64')}::bytea`;
|
||||||
|
default:
|
||||||
|
// text, varchar, enums and anything else: pass as text and cast
|
||||||
|
return Prisma.sql`${String(value)}::${target}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const file = process.argv[2];
|
||||||
|
if (!file) {
|
||||||
|
console.error('Usage: node scripts/import-data.mjs <dump.json>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const dump = JSON.parse(readFileSync(file, 'utf8'));
|
||||||
|
|
||||||
|
// Suspend FK checks during bulk load (postgres superuser not required for
|
||||||
|
// session_replication_role in the compose postgres where app user owns db).
|
||||||
|
await prisma.$executeRawUnsafe(`SET session_replication_role = replica`);
|
||||||
|
|
||||||
|
const summary = {};
|
||||||
|
for (const [table, rows] of Object.entries(dump.tables)) {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
summary[table] = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "${table}" CASCADE`);
|
||||||
|
|
||||||
|
const colTypes = {};
|
||||||
|
const info = await prisma.$queryRawUnsafe(
|
||||||
|
`SELECT column_name, udt_name FROM information_schema.columns WHERE table_name = '${table}'`,
|
||||||
|
);
|
||||||
|
for (const c of info) colTypes[c.column_name] = c.udt_name;
|
||||||
|
|
||||||
|
const columns = Object.keys(rows[0]);
|
||||||
|
const colList = Prisma.raw(columns.map((c) => `"${c}"`).join(', '));
|
||||||
|
const CHUNK = 200;
|
||||||
|
for (let i = 0; i < rows.length; i += CHUNK) {
|
||||||
|
const tuples = rows.slice(i, i + CHUNK).map(
|
||||||
|
(r) =>
|
||||||
|
Prisma.sql`(${Prisma.join(
|
||||||
|
columns.map((c) => toLiteral(r[c], colTypes[c])),
|
||||||
|
)})`,
|
||||||
|
);
|
||||||
|
await prisma.$executeRaw(
|
||||||
|
Prisma.sql`INSERT INTO ${Prisma.raw(`"${table}"`)} (${colList}) VALUES ${Prisma.join(tuples)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Keep sequences ahead of imported serial ids
|
||||||
|
// Keep serial sequences ahead of imported ids
|
||||||
|
const idCol = columns.find(
|
||||||
|
(c) => c === 'id' || c === `${table.replace(/s$/, '')}_id`,
|
||||||
|
);
|
||||||
|
if (idCol) {
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
`SELECT setval(pg_get_serial_sequence('"${table}"', '${idCol}'), COALESCE((SELECT MAX("${idCol}") FROM "${table}"), 1))`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
summary[table] = rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$executeRawUnsafe(`SET session_replication_role = DEFAULT`);
|
||||||
|
console.log('Imported:', JSON.stringify(summary, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Set the admin credentials for the production deployment:
|
||||||
|
* rename/disable any existing admin and create/update user `inkreach`.
|
||||||
|
*
|
||||||
|
* Usage (inside the api container): node scripts/set-admin.mjs
|
||||||
|
* Reads NEW_ADMIN_USER / NEW_ADMIN_PASSWORD from env.
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const username = process.env.NEW_ADMIN_USER;
|
||||||
|
const password = process.env.NEW_ADMIN_PASSWORD;
|
||||||
|
if (!username || !password) {
|
||||||
|
console.error('NEW_ADMIN_USER / NEW_ADMIN_PASSWORD must be set');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
await prisma.user.upsert({
|
||||||
|
where: { username },
|
||||||
|
create: { username, passwordHash },
|
||||||
|
update: { passwordHash, tokenVersion: { increment: 1 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove every other admin so only `inkreach` can sign in.
|
||||||
|
const others = await prisma.user.deleteMany({
|
||||||
|
where: { username: { not: username } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = await prisma.user.count();
|
||||||
|
console.log(`Admin '${username}' set. Demoted ${others.count} other user(s). Total users: ${total}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { RolesGuard } from './auth/guards/roles.guard';
|
||||||
import { CountriesModule } from './countries/countries.module';
|
import { CountriesModule } from './countries/countries.module';
|
||||||
import { CategoriesModule } from './categories/categories.module';
|
import { CategoriesModule } from './categories/categories.module';
|
||||||
import { TagsModule } from './tags/tags.module';
|
import { TagsModule } from './tags/tags.module';
|
||||||
@@ -18,6 +21,14 @@ import { UploadModule } from './upload/upload.module';
|
|||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
}),
|
}),
|
||||||
|
// Global rate limiting: 120 req/min per IP. Stricter limits are set
|
||||||
|
// per-endpoint with @Throttle (auth, upload).
|
||||||
|
ThrottlerModule.forRoot([
|
||||||
|
{
|
||||||
|
ttl: 60_000,
|
||||||
|
limit: Number(process.env.THROTTLE_LIMIT ?? 120),
|
||||||
|
},
|
||||||
|
]),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
CountriesModule,
|
CountriesModule,
|
||||||
@@ -31,5 +42,17 @@ import { UploadModule } from './upload/upload.module';
|
|||||||
PublicModule,
|
PublicModule,
|
||||||
UploadModule,
|
UploadModule,
|
||||||
],
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: ThrottlerGuard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Enforces the ADMIN role on every authenticated route unless the
|
||||||
|
// route widens access with @Roles(...).
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: RolesGuard,
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
|
|
||||||
import {
|
import {
|
||||||
ApiOperation,
|
Body,
|
||||||
ApiResponse,
|
Controller,
|
||||||
ApiTags,
|
Get,
|
||||||
} from '@nestjs/swagger';
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UnauthorizedException,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
|
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import {
|
import { LoginResponseDto, UserPublicDto } from './dto/auth-response.dto';
|
||||||
LoginResponseDto,
|
import type { AuthenticatedUser } from './strategies/jwt.strategy';
|
||||||
UserPublicDto,
|
|
||||||
} from './dto/auth-response.dto';
|
const ACCESS_TOKEN_COOKIE = 'ir_at';
|
||||||
|
const REFRESH_TOKEN_COOKIE = 'ir_rt';
|
||||||
|
const isProd = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
@ApiTags('auth')
|
@ApiTags('auth')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
@@ -19,19 +31,102 @@ export class AuthController {
|
|||||||
|
|
||||||
@Post('register')
|
@Post('register')
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@ApiOperation({ summary: 'Register a new admin user' })
|
@Throttle({ default: { limit: 5, ttl: 60_000 } })
|
||||||
|
@ApiOperation({ summary: 'Register the first admin user (bootstrap only)' })
|
||||||
@ApiResponse({ status: 201, type: UserPublicDto })
|
@ApiResponse({ status: 201, type: UserPublicDto })
|
||||||
@ApiResponse({ status: 409, description: 'Username already exists' })
|
@ApiResponse({ status: 409, description: 'Username already exists' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Registration is disabled once a user exists' })
|
||||||
register(@Body() dto: RegisterDto): Promise<UserPublicDto> {
|
register(@Body() dto: RegisterDto): Promise<UserPublicDto> {
|
||||||
return this.authService.register(dto) as unknown as Promise<UserPublicDto>;
|
return this.authService.register(dto) as unknown as Promise<UserPublicDto>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'Login and obtain a JWT' })
|
@Throttle({ default: { limit: 5, ttl: 60_000 } })
|
||||||
|
@ApiOperation({ summary: 'Login and obtain access + refresh tokens' })
|
||||||
@ApiResponse({ status: 200, type: LoginResponseDto })
|
@ApiResponse({ status: 200, type: LoginResponseDto })
|
||||||
@ApiResponse({ status: 401, description: 'Invalid credentials' })
|
@ApiResponse({ status: 401, description: 'Invalid credentials' })
|
||||||
login(@Body() dto: LoginDto): Promise<LoginResponseDto> {
|
async login(
|
||||||
return this.authService.login(dto) as unknown as Promise<LoginResponseDto>;
|
@Body() dto: LoginDto,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
): Promise<LoginResponseDto> {
|
||||||
|
const result = await this.authService.login(dto);
|
||||||
|
// HttpOnly cookies are the primary session channel for the admin SPA
|
||||||
|
// (XSS cannot read them). The access token is also returned in the
|
||||||
|
// body for non-browser API clients.
|
||||||
|
res.cookie(ACCESS_TOKEN_COOKIE, result.accessToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: isProd,
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
res.cookie(REFRESH_TOKEN_COOKIE, result.refreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: isProd,
|
||||||
|
// Only ever sent to /auth/refresh and /auth/logout
|
||||||
|
path: '/auth',
|
||||||
|
});
|
||||||
|
// The refresh token deliberately stays HttpOnly-only.
|
||||||
|
return {
|
||||||
|
accessToken: result.accessToken,
|
||||||
|
user: result.user,
|
||||||
|
} as unknown as LoginResponseDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('refresh')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||||
|
@ApiOperation({ summary: 'Rotate the refresh token cookie' })
|
||||||
|
@ApiResponse({ status: 200, type: LoginResponseDto })
|
||||||
|
@ApiResponse({ status: 401, description: 'Invalid refresh token' })
|
||||||
|
async refresh(
|
||||||
|
@Req() req: Request,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
): Promise<LoginResponseDto> {
|
||||||
|
const token = req.cookies?.[REFRESH_TOKEN_COOKIE];
|
||||||
|
if (!token) {
|
||||||
|
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/auth' });
|
||||||
|
throw new UnauthorizedException('Missing refresh token');
|
||||||
|
}
|
||||||
|
const result = await this.authService.refresh(token);
|
||||||
|
res.cookie(ACCESS_TOKEN_COOKIE, result.accessToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: isProd,
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
res.cookie(REFRESH_TOKEN_COOKIE, result.refreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: isProd,
|
||||||
|
path: '/auth',
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
accessToken: result.accessToken,
|
||||||
|
user: result.user,
|
||||||
|
} as unknown as LoginResponseDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiOperation({ summary: 'Current authenticated user' })
|
||||||
|
@ApiResponse({ status: 200, type: UserPublicDto })
|
||||||
|
me(@Req() req: Request & { user: AuthenticatedUser }): Promise<UserPublicDto> {
|
||||||
|
return this.authService.me(req.user.id) as unknown as Promise<UserPublicDto>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('logout')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiOperation({ summary: 'Revoke all tokens of the current user' })
|
||||||
|
async logout(
|
||||||
|
@Req() req: Request & { user: AuthenticatedUser },
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
): Promise<{ success: true }> {
|
||||||
|
await this.authService.logout(req.user.id);
|
||||||
|
res.clearCookie(ACCESS_TOKEN_COOKIE, { path: '/' });
|
||||||
|
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/auth' });
|
||||||
|
return { success: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,14 @@ import { JwtStrategy } from './strategies/jwt.strategy';
|
|||||||
if (!secret) {
|
if (!secret) {
|
||||||
throw new Error('JWT_SECRET must be configured');
|
throw new Error('JWT_SECRET must be configured');
|
||||||
}
|
}
|
||||||
|
if (secret.length < 32) {
|
||||||
|
throw new Error('JWT_SECRET must be at least 32 characters (use a strong random value)');
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
secret,
|
secret,
|
||||||
signOptions: { expiresIn: '7d' },
|
signOptions: {
|
||||||
|
expiresIn: config.get<string>('TOKEN_EXPIRES_IN') ?? '7d',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,102 +1,167 @@
|
|||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule, JwtService } from '@nestjs/jwt';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConflictException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||||
import { ConflictException, UnauthorizedException } from '@nestjs/common';
|
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
describe('AuthService', () => {
|
describe('AuthService', () => {
|
||||||
let service: AuthService;
|
let service: AuthService;
|
||||||
let prisma: PrismaService;
|
let jwt: JwtService;
|
||||||
const createdUsernames: string[] = [];
|
let prisma: {
|
||||||
|
user: {
|
||||||
|
count: jest.Mock;
|
||||||
|
findUnique: jest.Mock;
|
||||||
|
create: jest.Mock;
|
||||||
|
update: jest.Mock;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const HASH = bcrypt.hashSync('plain-pwd', 10);
|
||||||
|
const dbUser = {
|
||||||
|
id: 1n,
|
||||||
|
username: 'alice',
|
||||||
|
passwordHash: HASH,
|
||||||
|
role: 'ADMIN' as const,
|
||||||
|
tokenVersion: 0,
|
||||||
|
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
};
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
prisma = {
|
||||||
|
user: {
|
||||||
|
count: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: 'test-secret',
|
secret: 'a'.repeat(32),
|
||||||
signOptions: { expiresIn: '1h' },
|
signOptions: { expiresIn: '1h' },
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
providers: [AuthService, PrismaService],
|
providers: [AuthService, { provide: PrismaService, useValue: prisma }],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = moduleRef.get(AuthService);
|
service = moduleRef.get(AuthService);
|
||||||
prisma = moduleRef.get(PrismaService);
|
jwt = moduleRef.get(JwtService);
|
||||||
await prisma.onModuleInit();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
beforeEach(() => {
|
||||||
// Cleanup created test users
|
jest.clearAllMocks();
|
||||||
if (createdUsernames.length) {
|
|
||||||
await prisma.user.deleteMany({
|
|
||||||
where: { username: { in: createdUsernames } },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await prisma.onModuleDestroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be defined', () => {
|
|
||||||
expect(service).toBeDefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('register', () => {
|
describe('register', () => {
|
||||||
it('creates a new user and stores a hashed password', async () => {
|
it('creates the first user with a hashed password', async () => {
|
||||||
const username = `test_reg_${Date.now()}`;
|
prisma.user.count.mockResolvedValueOnce(0);
|
||||||
createdUsernames.push(username);
|
prisma.user.findUnique.mockResolvedValueOnce(null);
|
||||||
|
prisma.user.create.mockResolvedValueOnce(dbUser);
|
||||||
|
|
||||||
const user = await service.register({ username, password: 'plain-pwd' });
|
const user = await service.register({ username: 'alice', password: 'plain-pwd' });
|
||||||
|
|
||||||
expect(user.username).toBe(username);
|
expect(user.username).toBe('alice');
|
||||||
expect(user.id).toBeTruthy();
|
const created = prisma.user.create.mock.calls[0][0].data;
|
||||||
|
expect(created.passwordHash).not.toBe('plain-pwd');
|
||||||
|
await expect(bcrypt.compare('plain-pwd', created.passwordHash)).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
const stored = await prisma.user.findUnique({ where: { username } });
|
it('refuses registration once a user exists (bootstrap lock)', async () => {
|
||||||
expect(stored).not.toBeNull();
|
prisma.user.count.mockResolvedValueOnce(1);
|
||||||
expect(stored?.passwordHash).not.toBe('plain-pwd');
|
await expect(
|
||||||
const matches = await bcrypt.compare('plain-pwd', stored!.passwordHash);
|
service.register({ username: 'mallory', password: 'evil-pwd' }),
|
||||||
expect(matches).toBe(true);
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws ConflictException for duplicate usernames', async () => {
|
it('throws ConflictException for duplicate usernames', async () => {
|
||||||
const username = `test_dup_${Date.now()}`;
|
prisma.user.count.mockResolvedValueOnce(0);
|
||||||
createdUsernames.push(username);
|
prisma.user.findUnique.mockResolvedValueOnce(dbUser);
|
||||||
|
|
||||||
await service.register({ username, password: 'pwd1234' });
|
|
||||||
await expect(
|
await expect(
|
||||||
service.register({ username, password: 'pwd5678' }),
|
service.register({ username: 'alice', password: 'pwd5678' }),
|
||||||
).rejects.toBeInstanceOf(ConflictException);
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('login', () => {
|
describe('login', () => {
|
||||||
it('returns an access token for valid credentials', async () => {
|
it('returns access + refresh tokens with tokenVersion and type', async () => {
|
||||||
const username = `test_login_${Date.now()}`;
|
prisma.user.findUnique.mockResolvedValueOnce(dbUser);
|
||||||
createdUsernames.push(username);
|
const result = await service.login({ username: 'alice', password: 'plain-pwd' });
|
||||||
await service.register({ username, password: 'correct-pwd' });
|
|
||||||
|
|
||||||
const result = await service.login({ username, password: 'correct-pwd' });
|
expect(result.user.username).toBe('alice');
|
||||||
expect(result.accessToken).toEqual(expect.any(String));
|
const access = jwt.decode(result.accessToken) as Record<string, unknown>;
|
||||||
const parts = result.accessToken.split('.');
|
expect(access.typ).toBe('access');
|
||||||
expect(parts.length).toBe(3);
|
expect(access.tv).toBe(0);
|
||||||
expect(result.user.username).toBe(username);
|
const refresh = jwt.decode(result.refreshToken) as Record<string, unknown>;
|
||||||
|
expect(refresh.typ).toBe('refresh');
|
||||||
|
expect(refresh.tv).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws UnauthorizedException for wrong password', async () => {
|
it('throws UnauthorizedException for wrong password', async () => {
|
||||||
const username = `test_wrong_${Date.now()}`;
|
prisma.user.findUnique.mockResolvedValueOnce(dbUser);
|
||||||
createdUsernames.push(username);
|
|
||||||
await service.register({ username, password: 'right-pwd' });
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.login({ username, password: 'wrong-pwd' }),
|
service.login({ username: 'alice', password: 'wrong-pwd' }),
|
||||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws UnauthorizedException for unknown user', async () => {
|
it('throws UnauthorizedException for unknown user', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValueOnce(null);
|
||||||
await expect(
|
await expect(
|
||||||
service.login({ username: 'no-such-user-xyz', password: 'whatever' }),
|
service.login({ username: 'no-such-user', password: 'whatever' }),
|
||||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('refresh', () => {
|
||||||
|
it('rotates a valid refresh token', async () => {
|
||||||
|
const refreshToken = await jwt.signAsync({
|
||||||
|
sub: '1',
|
||||||
|
username: 'alice',
|
||||||
|
role: 'ADMIN',
|
||||||
|
tv: 0,
|
||||||
|
typ: 'refresh',
|
||||||
|
});
|
||||||
|
prisma.user.findUnique.mockResolvedValueOnce(dbUser);
|
||||||
|
|
||||||
|
const result = await service.refresh(refreshToken);
|
||||||
|
expect(result.user.username).toBe('alice');
|
||||||
|
expect(result.accessToken).not.toBe(refreshToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects access tokens used as refresh tokens', async () => {
|
||||||
|
const accessToken = await jwt.signAsync({
|
||||||
|
sub: '1',
|
||||||
|
username: 'alice',
|
||||||
|
role: 'ADMIN',
|
||||||
|
tv: 0,
|
||||||
|
typ: 'access',
|
||||||
|
});
|
||||||
|
await expect(service.refresh(accessToken)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects refresh tokens with a stale tokenVersion (revoked)', async () => {
|
||||||
|
const refreshToken = await jwt.signAsync({
|
||||||
|
sub: '1',
|
||||||
|
username: 'alice',
|
||||||
|
role: 'ADMIN',
|
||||||
|
tv: 0,
|
||||||
|
typ: 'refresh',
|
||||||
|
});
|
||||||
|
// User logged out elsewhere: tokenVersion bumped to 1
|
||||||
|
prisma.user.findUnique.mockResolvedValueOnce({ ...dbUser, tokenVersion: 1 });
|
||||||
|
await expect(service.refresh(refreshToken)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('logout', () => {
|
||||||
|
it('bumps tokenVersion to revoke all tokens', async () => {
|
||||||
|
prisma.user.update.mockResolvedValueOnce({ ...dbUser, tokenVersion: 1 });
|
||||||
|
await service.logout(1n);
|
||||||
|
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 1n },
|
||||||
|
data: { tokenVersion: { increment: 1 } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ConflictException,
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -13,16 +14,25 @@ import type { JwtPayload } from './strategies/jwt.strategy';
|
|||||||
export interface PublicUser {
|
export interface PublicUser {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
role: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResult {
|
export interface LoginResult {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
user: PublicUser;
|
user: PublicUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BCRYPT_ROUNDS = 10;
|
const BCRYPT_ROUNDS = 10;
|
||||||
const TOKEN_EXPIRES_IN = '7d';
|
const ACCESS_TOKEN_EXPIRES_IN = process.env.TOKEN_EXPIRES_IN ?? '30m';
|
||||||
|
const REFRESH_TOKEN_EXPIRES_IN = process.env.REFRESH_TOKEN_EXPIRES_IN ?? '7d';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compared against when the username does not exist so that login takes
|
||||||
|
* the same time either way (prevents user enumeration via timing).
|
||||||
|
*/
|
||||||
|
const DUMMY_HASH = '$2b$10$l232BFW3u63Mhfx0BatxUOLtw.qEofG9fNYjLsh2zce7MdIKDAIR6';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -32,10 +42,15 @@ export class AuthService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers a brand-new admin user. Throws {@link ConflictException}
|
* Bootstrap-only registration: allowed just while the instance has no
|
||||||
* if the username is already taken.
|
* users. Once an admin exists the endpoint refuses to create accounts
|
||||||
|
* (use database seeding / an operator flow instead).
|
||||||
*/
|
*/
|
||||||
async register(dto: RegisterDto): Promise<PublicUser> {
|
async register(dto: RegisterDto): Promise<PublicUser> {
|
||||||
|
const userCount = await this.prisma.user.count();
|
||||||
|
if (userCount > 0) {
|
||||||
|
throw new ForbiddenException('Registration is disabled');
|
||||||
|
}
|
||||||
const existing = await this.prisma.user.findUnique({
|
const existing = await this.prisma.user.findUnique({
|
||||||
where: { username: dto.username },
|
where: { username: dto.username },
|
||||||
});
|
});
|
||||||
@@ -50,37 +65,118 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies credentials and returns a signed JWT.
|
* Verifies credentials and returns signed access + refresh tokens.
|
||||||
|
* Both tokens embed the user's tokenVersion so bumping it on the user
|
||||||
|
* row (logout / revocation) invalidates them immediately.
|
||||||
*/
|
*/
|
||||||
async login(dto: LoginDto): Promise<LoginResult> {
|
async login(dto: LoginDto): Promise<LoginResult> {
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { username: dto.username },
|
where: { username: dto.username },
|
||||||
});
|
});
|
||||||
|
// Always run a bcrypt compare (against a dummy hash when the user is
|
||||||
|
// unknown) so response timing cannot be used to enumerate usernames.
|
||||||
|
const ok = await bcrypt.compare(dto.password, user?.passwordHash ?? DUMMY_HASH);
|
||||||
|
if (!user || !ok) {
|
||||||
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
accessToken: await this.signAccessToken(user),
|
||||||
|
refreshToken: await this.signRefreshToken(user),
|
||||||
|
user: this.toPublic(user),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotates a refresh token: the old refresh token becomes invalid as
|
||||||
|
* soon as tokenVersion is bumped (logout, revocation).
|
||||||
|
*/
|
||||||
|
async refresh(refreshToken: string): Promise<LoginResult> {
|
||||||
|
let payload: JwtPayload;
|
||||||
|
try {
|
||||||
|
payload = await this.jwt.verifyAsync(refreshToken);
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
|
}
|
||||||
|
if (payload.typ !== 'refresh') {
|
||||||
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
|
}
|
||||||
|
const user = await this.prisma.user
|
||||||
|
.findUnique({ where: { id: BigInt(payload.sub) } })
|
||||||
|
.catch(() => null);
|
||||||
|
if (!user || user.tokenVersion !== payload.tv) {
|
||||||
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
accessToken: await this.signAccessToken(user),
|
||||||
|
refreshToken: await this.signRefreshToken(user),
|
||||||
|
user: this.toPublic(user),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revokes all tokens of a user by bumping tokenVersion.
|
||||||
|
*/
|
||||||
|
async logout(userId: bigint): Promise<void> {
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { tokenVersion: { increment: 1 } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async me(userId: bigint): Promise<PublicUser> {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException('Invalid credentials');
|
throw new UnauthorizedException();
|
||||||
}
|
|
||||||
const ok = await bcrypt.compare(dto.password, user.passwordHash);
|
|
||||||
if (!ok) {
|
|
||||||
throw new UnauthorizedException('Invalid credentials');
|
|
||||||
}
|
}
|
||||||
|
return this.toPublic(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async signAccessToken(user: {
|
||||||
|
id: bigint;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
tokenVersion: number;
|
||||||
|
}): Promise<string> {
|
||||||
const payload: JwtPayload = {
|
const payload: JwtPayload = {
|
||||||
sub: user.id.toString(),
|
sub: user.id.toString(),
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
tv: user.tokenVersion,
|
||||||
|
typ: 'access',
|
||||||
};
|
};
|
||||||
const accessToken = await this.jwt.signAsync(payload, {
|
return this.jwt.signAsync(payload, {
|
||||||
expiresIn: TOKEN_EXPIRES_IN,
|
expiresIn: ACCESS_TOKEN_EXPIRES_IN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async signRefreshToken(user: {
|
||||||
|
id: bigint;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
tokenVersion: number;
|
||||||
|
}): Promise<string> {
|
||||||
|
const payload: JwtPayload = {
|
||||||
|
sub: user.id.toString(),
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
tv: user.tokenVersion,
|
||||||
|
typ: 'refresh',
|
||||||
|
};
|
||||||
|
return this.jwt.signAsync(payload, {
|
||||||
|
expiresIn: REFRESH_TOKEN_EXPIRES_IN,
|
||||||
});
|
});
|
||||||
return { accessToken, user: this.toPublic(user) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private toPublic(user: {
|
private toPublic(user: {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
username: string;
|
username: string;
|
||||||
|
role: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}): PublicUser {
|
}): PublicUser {
|
||||||
return {
|
return {
|
||||||
id: user.id.toString(),
|
id: user.id.toString(),
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
createdAt: user.createdAt.toISOString(),
|
createdAt: user.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restricts a route to the given roles. When omitted, any
|
||||||
|
* authenticated user with an ADMIN role passes the RolesGuard.
|
||||||
|
*/
|
||||||
|
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { ForbiddenException } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { RolesGuard } from './roles.guard';
|
||||||
|
|
||||||
|
describe('RolesGuard', () => {
|
||||||
|
let guard: RolesGuard;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
guard = new RolesGuard(new Reflector());
|
||||||
|
});
|
||||||
|
|
||||||
|
const context = (user: unknown, roles?: string[]) =>
|
||||||
|
({
|
||||||
|
switchToHttp: () => ({ getRequest: () => ({ user }) }),
|
||||||
|
getHandler: () => (roles ? { __roles: roles } : {}),
|
||||||
|
getClass: () => ({}),
|
||||||
|
}) as never;
|
||||||
|
|
||||||
|
it('passes public routes (no authenticated user)', () => {
|
||||||
|
expect(guard.canActivate(context(undefined))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes ADMIN users by default', () => {
|
||||||
|
expect(guard.canActivate(context({ id: 1n, username: 'a', role: 'ADMIN' }))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks users without the ADMIN role', () => {
|
||||||
|
expect(() => guard.canActivate(context({ id: 1n, username: 'a', role: 'VIEWER' }))).toThrow(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||||
|
import type { AuthenticatedUser } from '../strategies/jwt.strategy';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role-based access control. Applied globally: any authenticated user
|
||||||
|
* reaching a protected route must hold the ADMIN role unless the route
|
||||||
|
* declares a wider set with @Roles(...). Routes without a JwtAuthGuard
|
||||||
|
* (public endpoints) have no `request.user` and are skipped here — their
|
||||||
|
* openness is decided by the controller's own guards.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RolesGuard implements CanActivate {
|
||||||
|
constructor(private readonly reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const request = context.switchToHttp().getRequest<{
|
||||||
|
user?: AuthenticatedUser;
|
||||||
|
}>();
|
||||||
|
if (!request.user) {
|
||||||
|
return true; // public route — no JwtAuthGuard in front
|
||||||
|
}
|
||||||
|
const required = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]) ?? ['ADMIN'];
|
||||||
|
if (!required.includes(request.user.role)) {
|
||||||
|
throw new ForbiddenException('Insufficient role');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,26 +2,51 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
|
export const ACCESS_TOKEN_COOKIE = 'ir_at';
|
||||||
|
|
||||||
|
export interface AuthenticatedUser {
|
||||||
|
id: bigint;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shape of the JWT we issue.
|
* Shape of the JWT we issue.
|
||||||
*
|
*
|
||||||
* `sub` is the user ID as a string (bigints are serialized to strings in JSON).
|
* `sub` is the user ID as a string (bigints are serialized to strings in JSON).
|
||||||
|
* `typ` distinguishes access tokens from refresh tokens; `tv` is the user's
|
||||||
|
* tokenVersion and `role` drives the RolesGuard.
|
||||||
*/
|
*/
|
||||||
export interface JwtPayload {
|
export interface JwtPayload {
|
||||||
sub: string;
|
sub: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
role?: string;
|
||||||
|
tv?: number;
|
||||||
|
typ?: 'access' | 'refresh';
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
constructor(config: ConfigService) {
|
constructor(
|
||||||
|
config: ConfigService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {
|
||||||
const secret = config.get<string>('JWT_SECRET');
|
const secret = config.get<string>('JWT_SECRET');
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
throw new Error('JWT_SECRET is not configured');
|
throw new Error('JWT_SECRET is not configured');
|
||||||
}
|
}
|
||||||
|
if (secret.length < 32) {
|
||||||
|
throw new Error('JWT_SECRET must be at least 32 characters');
|
||||||
|
}
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
// Access tokens are accepted from the HttpOnly cookie (browser) or
|
||||||
|
// the Authorization header (non-browser API clients).
|
||||||
|
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||||
|
ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
(req) => req?.cookies?.[ACCESS_TOKEN_COOKIE] ?? null,
|
||||||
|
]),
|
||||||
ignoreExpiration: false,
|
ignoreExpiration: false,
|
||||||
secretOrKey: secret,
|
secretOrKey: secret,
|
||||||
});
|
});
|
||||||
@@ -29,12 +54,28 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs on every authenticated request. The returned object becomes
|
* Runs on every authenticated request. The returned object becomes
|
||||||
* `request.user` for downstream controllers.
|
* `request.user` for downstream controllers. The user and its
|
||||||
|
* tokenVersion are re-checked in the database so tokens of deleted
|
||||||
|
* users, logged-out users, or refresh tokens stop working immediately.
|
||||||
*/
|
*/
|
||||||
validate(payload: JwtPayload): { id: bigint; username: string } {
|
async validate(payload: JwtPayload): Promise<AuthenticatedUser> {
|
||||||
if (!payload?.sub || !payload.username) {
|
if (!payload?.sub || !payload.username) {
|
||||||
throw new UnauthorizedException('Invalid token payload');
|
throw new UnauthorizedException('Invalid token payload');
|
||||||
}
|
}
|
||||||
return { id: BigInt(payload.sub), username: payload.username };
|
// Refresh tokens must never be accepted as API credentials.
|
||||||
|
if (payload.typ === 'refresh') {
|
||||||
|
throw new UnauthorizedException('Invalid token type');
|
||||||
|
}
|
||||||
|
const user = await this.prisma.user
|
||||||
|
.findUnique({ where: { id: BigInt(payload.sub) } })
|
||||||
|
.catch(() => null);
|
||||||
|
if (
|
||||||
|
!user ||
|
||||||
|
user.username !== payload.username ||
|
||||||
|
(payload.tv !== undefined && user.tokenVersion !== payload.tv)
|
||||||
|
) {
|
||||||
|
throw new UnauthorizedException('Invalid token');
|
||||||
|
}
|
||||||
|
return { id: user.id, username: user.username, role: user.role };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,67 +1,67 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { CategoriesService } from './categories.service';
|
import { CategoriesService } from './categories.service';
|
||||||
import { CreateCategoryDto } from './dto/create-category.dto';
|
import { CreateCategoryDto } from './dto/create-category.dto';
|
||||||
import { UpdateCategoryDto } from './dto/update-category.dto';
|
import { UpdateCategoryDto } from './dto/update-category.dto';
|
||||||
|
|
||||||
@ApiTags('categories')
|
@ApiTags('categories')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('categories')
|
@Controller('categories')
|
||||||
export class CategoriesController {
|
export class CategoriesController {
|
||||||
constructor(private readonly service: CategoriesService) {}
|
constructor(private readonly service: CategoriesService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'Return categories as a tree' })
|
@ApiOperation({ summary: 'Return categories as a tree' })
|
||||||
findAll() {
|
findAll() {
|
||||||
return this.service.findAll();
|
return this.service.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('flat')
|
@Get('flat')
|
||||||
@ApiOperation({ summary: 'Return categories as a flat list' })
|
@ApiOperation({ summary: 'Return categories as a flat list' })
|
||||||
findFlat() {
|
findFlat() {
|
||||||
return this.service.findFlat();
|
return this.service.findFlat();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get one category' })
|
@ApiOperation({ summary: 'Get one category' })
|
||||||
findOne(@Param('id', ParseIntPipe) id: string) {
|
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||||
return this.service.findOne(BigInt(id));
|
return this.service.findOne(BigInt(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: 'Create a category' })
|
@ApiOperation({ summary: 'Create a category' })
|
||||||
create(@Body() dto: CreateCategoryDto) {
|
create(@Body() dto: CreateCategoryDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@ApiOperation({ summary: 'Update a category' })
|
@ApiOperation({ summary: 'Update a category' })
|
||||||
update(
|
update(
|
||||||
@Param('id', ParseIntPipe) id: string,
|
@Param('id', ParseIntPipe) id: string,
|
||||||
@Body() dto: UpdateCategoryDto,
|
@Body() dto: UpdateCategoryDto,
|
||||||
) {
|
) {
|
||||||
return this.service.update(BigInt(id), dto);
|
return this.service.update(BigInt(id), dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@ApiOperation({ summary: 'Delete a category' })
|
@ApiOperation({ summary: 'Delete a category' })
|
||||||
remove(@Param('id', ParseIntPipe) id: string) {
|
remove(@Param('id', ParseIntPipe) id: string) {
|
||||||
return this.service.remove(BigInt(id));
|
return this.service.remove(BigInt(id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { CategoriesController } from './categories.controller';
|
import { CategoriesController } from './categories.controller';
|
||||||
import { CategoriesService } from './categories.service';
|
import { CategoriesService } from './categories.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [CategoriesController],
|
controllers: [CategoriesController],
|
||||||
providers: [CategoriesService],
|
providers: [CategoriesService],
|
||||||
exports: [CategoriesService],
|
exports: [CategoriesService],
|
||||||
})
|
})
|
||||||
export class CategoriesModule {}
|
export class CategoriesModule {}
|
||||||
|
|||||||
@@ -1,103 +1,103 @@
|
|||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { CategoriesService } from './categories.service';
|
import { CategoriesService } from './categories.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
describe('CategoriesService', () => {
|
describe('CategoriesService', () => {
|
||||||
let service: CategoriesService;
|
let service: CategoriesService;
|
||||||
let prisma: PrismaService;
|
let prisma: PrismaService;
|
||||||
const createdNames: string[] = [];
|
const createdNames: string[] = [];
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
providers: [CategoriesService, PrismaService],
|
providers: [CategoriesService, PrismaService],
|
||||||
}).compile();
|
}).compile();
|
||||||
service = moduleRef.get(CategoriesService);
|
service = moduleRef.get(CategoriesService);
|
||||||
prisma = moduleRef.get(PrismaService);
|
prisma = moduleRef.get(PrismaService);
|
||||||
await prisma.onModuleInit();
|
await prisma.onModuleInit();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
if (createdNames.length) {
|
if (createdNames.length) {
|
||||||
await prisma.category.deleteMany({
|
await prisma.category.deleteMany({
|
||||||
where: { categoryName: { in: createdNames } },
|
where: { categoryName: { in: createdNames } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await prisma.onModuleDestroy();
|
await prisma.onModuleDestroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
expect(service).toBeDefined();
|
expect(service).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates root + child + grandchild and assembles them into a tree', async () => {
|
it('creates root + child + grandchild and assembles them into a tree', async () => {
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
const rootName = `Root ${stamp}`;
|
const rootName = `Root ${stamp}`;
|
||||||
const childName = `Child ${stamp}`;
|
const childName = `Child ${stamp}`;
|
||||||
const leafName = `Leaf ${stamp}`;
|
const leafName = `Leaf ${stamp}`;
|
||||||
createdNames.push(rootName, childName, leafName);
|
createdNames.push(rootName, childName, leafName);
|
||||||
|
|
||||||
const root = await service.create({ categoryName: rootName });
|
const root = await service.create({ categoryName: rootName });
|
||||||
const child = await service.create({
|
const child = await service.create({
|
||||||
categoryName: childName,
|
categoryName: childName,
|
||||||
parentCategoryId: Number(root.id),
|
parentCategoryId: Number(root.id),
|
||||||
});
|
});
|
||||||
const leaf = await service.create({
|
const leaf = await service.create({
|
||||||
categoryName: leafName,
|
categoryName: leafName,
|
||||||
parentCategoryId: Number(child.id),
|
parentCategoryId: Number(child.id),
|
||||||
});
|
});
|
||||||
|
|
||||||
const tree = await service.findAll();
|
const tree = await service.findAll();
|
||||||
const findNode = (
|
const findNode = (
|
||||||
list: Array<{ id: string; children: Array<{ id: string }> }>,
|
list: Array<{ id: string; children: Array<{ id: string }> }>,
|
||||||
id: string,
|
id: string,
|
||||||
): { id: string; children: Array<{ id: string }> } | undefined => {
|
): { id: string; children: Array<{ id: string }> } | undefined => {
|
||||||
for (const n of list) {
|
for (const n of list) {
|
||||||
if (n.id === id) return n;
|
if (n.id === id) return n;
|
||||||
const inner = findNode(n.children as any, id);
|
const inner = findNode(n.children as any, id);
|
||||||
if (inner) return inner;
|
if (inner) return inner;
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
const rootNode = findNode(tree as any, root.id.toString());
|
const rootNode = findNode(tree as any, root.id.toString());
|
||||||
expect(rootNode).toBeDefined();
|
expect(rootNode).toBeDefined();
|
||||||
const childNode = findNode(rootNode!.children as any, child.id.toString());
|
const childNode = findNode(rootNode!.children as any, child.id.toString());
|
||||||
expect(childNode).toBeDefined();
|
expect(childNode).toBeDefined();
|
||||||
const leafNode = findNode(childNode!.children as any, leaf.id.toString());
|
const leafNode = findNode(childNode!.children as any, leaf.id.toString());
|
||||||
expect(leafNode).toBeDefined();
|
expect(leafNode).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects deletion when children exist', async () => {
|
it('rejects deletion when children exist', async () => {
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
const parent = await service.create({ categoryName: `Parent ${stamp}` });
|
const parent = await service.create({ categoryName: `Parent ${stamp}` });
|
||||||
createdNames.push(parent.categoryName);
|
createdNames.push(parent.categoryName);
|
||||||
const child = await service.create({
|
const child = await service.create({
|
||||||
categoryName: `Child of ${stamp}`,
|
categoryName: `Child of ${stamp}`,
|
||||||
parentCategoryId: Number(parent.id),
|
parentCategoryId: Number(parent.id),
|
||||||
});
|
});
|
||||||
createdNames.push(child.categoryName);
|
createdNames.push(child.categoryName);
|
||||||
|
|
||||||
await expect(service.remove(parent.id)).rejects.toBeInstanceOf(
|
await expect(service.remove(parent.id)).rejects.toBeInstanceOf(
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws NotFoundException for unknown id', async () => {
|
it('throws NotFoundException for unknown id', async () => {
|
||||||
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
|
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns a flat list and a single node', async () => {
|
it('returns a flat list and a single node', async () => {
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
const c = await service.create({ categoryName: `Flat ${stamp}` });
|
const c = await service.create({ categoryName: `Flat ${stamp}` });
|
||||||
createdNames.push(c.categoryName);
|
createdNames.push(c.categoryName);
|
||||||
const flat = await service.findFlat();
|
const flat = await service.findFlat();
|
||||||
expect(flat.some((row) => row.id === c.id)).toBe(true);
|
expect(flat.some((row) => row.id === c.id)).toBe(true);
|
||||||
const single = await service.findOne(c.id);
|
const single = await service.findOne(c.id);
|
||||||
expect(single.categoryName).toBe(c.categoryName);
|
expect(single.categoryName).toBe(c.categoryName);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,137 +1,137 @@
|
|||||||
import { Category as PrismaCategory, Prisma } from '@prisma/client';
|
import { Category as PrismaCategory, Prisma } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CreateCategoryDto } from './dto/create-category.dto';
|
import { CreateCategoryDto } from './dto/create-category.dto';
|
||||||
import { UpdateCategoryDto } from './dto/update-category.dto';
|
import { UpdateCategoryDto } from './dto/update-category.dto';
|
||||||
import { CategoryNodeDto } from './dto/category-node.dto';
|
import { CategoryNodeDto } from './dto/category-node.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CategoriesService {
|
export class CategoriesService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async findAll(): Promise<CategoryNodeDto[]> {
|
async findAll(): Promise<CategoryNodeDto[]> {
|
||||||
const all = await this.prisma.category.findMany({
|
const all = await this.prisma.category.findMany({
|
||||||
orderBy: [{ id: 'asc' }],
|
orderBy: [{ id: 'asc' }],
|
||||||
});
|
});
|
||||||
return this.buildTree(all);
|
return this.buildTree(all);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findFlat() {
|
async findFlat() {
|
||||||
return this.prisma.category.findMany({
|
return this.prisma.category.findMany({
|
||||||
orderBy: [{ id: 'asc' }],
|
orderBy: [{ id: 'asc' }],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: bigint) {
|
async findOne(id: bigint) {
|
||||||
const c = await this.prisma.category.findUnique({ where: { id } });
|
const c = await this.prisma.category.findUnique({ where: { id } });
|
||||||
if (!c) throw new NotFoundException(`Category ${id} not found`);
|
if (!c) throw new NotFoundException(`Category ${id} not found`);
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateCategoryDto) {
|
async create(dto: CreateCategoryDto) {
|
||||||
if (dto.parentCategoryId !== undefined && dto.parentCategoryId !== null) {
|
if (dto.parentCategoryId !== undefined && dto.parentCategoryId !== null) {
|
||||||
// Validate the parent exists to produce a clean 404 instead of FK error.
|
// Validate the parent exists to produce a clean 404 instead of FK error.
|
||||||
await this.findOne(BigInt(dto.parentCategoryId));
|
await this.findOne(BigInt(dto.parentCategoryId));
|
||||||
}
|
}
|
||||||
return this.prisma.category.create({
|
return this.prisma.category.create({
|
||||||
data: {
|
data: {
|
||||||
categoryName: dto.categoryName,
|
categoryName: dto.categoryName,
|
||||||
categoryIcon: dto.categoryIcon ?? null,
|
categoryIcon: dto.categoryIcon ?? null,
|
||||||
parentCategoryId:
|
parentCategoryId:
|
||||||
dto.parentCategoryId === undefined || dto.parentCategoryId === null
|
dto.parentCategoryId === undefined || dto.parentCategoryId === null
|
||||||
? null
|
? null
|
||||||
: BigInt(dto.parentCategoryId),
|
: BigInt(dto.parentCategoryId),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: bigint, dto: UpdateCategoryDto) {
|
async update(id: bigint, dto: UpdateCategoryDto) {
|
||||||
await this.findOne(id);
|
await this.findOne(id);
|
||||||
if (dto.parentCategoryId !== undefined && dto.parentCategoryId !== null) {
|
if (dto.parentCategoryId !== undefined && dto.parentCategoryId !== null) {
|
||||||
// Prevent self-parenting & cycles.
|
// Prevent self-parenting & cycles.
|
||||||
if (BigInt(dto.parentCategoryId) === id) {
|
if (BigInt(dto.parentCategoryId) === id) {
|
||||||
throw new BadRequestException('A category cannot be its own parent');
|
throw new BadRequestException('A category cannot be its own parent');
|
||||||
}
|
}
|
||||||
await this.findOne(BigInt(dto.parentCategoryId));
|
await this.findOne(BigInt(dto.parentCategoryId));
|
||||||
}
|
}
|
||||||
const data: Prisma.CategoryUpdateInput = {};
|
const data: Prisma.CategoryUpdateInput = {};
|
||||||
if (dto.categoryName !== undefined) data.categoryName = dto.categoryName;
|
if (dto.categoryName !== undefined) data.categoryName = dto.categoryName;
|
||||||
if (dto.categoryIcon !== undefined) data.categoryIcon = dto.categoryIcon;
|
if (dto.categoryIcon !== undefined) data.categoryIcon = dto.categoryIcon;
|
||||||
if (dto.parentCategoryId !== undefined) {
|
if (dto.parentCategoryId !== undefined) {
|
||||||
data.parent = dto.parentCategoryId === null
|
data.parent = dto.parentCategoryId === null
|
||||||
? { disconnect: true }
|
? { disconnect: true }
|
||||||
: { connect: { id: BigInt(dto.parentCategoryId) } };
|
: { connect: { id: BigInt(dto.parentCategoryId) } };
|
||||||
}
|
}
|
||||||
return this.prisma.category.update({ where: { id }, data });
|
return this.prisma.category.update({ where: { id }, data });
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: bigint) {
|
async remove(id: bigint) {
|
||||||
await this.findOne(id);
|
await this.findOne(id);
|
||||||
const childCount = await this.prisma.category.count({
|
const childCount = await this.prisma.category.count({
|
||||||
where: { parentCategoryId: id },
|
where: { parentCategoryId: id },
|
||||||
});
|
});
|
||||||
if (childCount > 0) {
|
if (childCount > 0) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Category has children and cannot be deleted',
|
'Category has children and cannot be deleted',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return await this.prisma.category.delete({ where: { id } });
|
return await this.prisma.category.delete({ where: { id } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (this.isForeignKeyViolation(err)) {
|
if (this.isForeignKeyViolation(err)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Category is referenced by goods or positions and cannot be deleted',
|
'Category is referenced by goods or positions and cannot be deleted',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private isForeignKeyViolation(err: unknown): boolean {
|
private isForeignKeyViolation(err: unknown): boolean {
|
||||||
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
return err.code === 'P2003';
|
return err.code === 'P2003';
|
||||||
}
|
}
|
||||||
if (err instanceof Prisma.PrismaClientUnknownRequestError) {
|
if (err instanceof Prisma.PrismaClientUnknownRequestError) {
|
||||||
const msg = err.message ?? '';
|
const msg = err.message ?? '';
|
||||||
return (
|
return (
|
||||||
msg.includes('foreign key constraint') ||
|
msg.includes('foreign key constraint') ||
|
||||||
msg.includes('RESTRICT') ||
|
msg.includes('RESTRICT') ||
|
||||||
msg.includes('violates')
|
msg.includes('violates')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a tree in-memory from a flat list. Top-level nodes have
|
* Builds a tree in-memory from a flat list. Top-level nodes have
|
||||||
* `parentCategoryId = null`.
|
* `parentCategoryId = null`.
|
||||||
*/
|
*/
|
||||||
private buildTree(
|
private buildTree(
|
||||||
rows: PrismaCategory[],
|
rows: PrismaCategory[],
|
||||||
): CategoryNodeDto[] {
|
): CategoryNodeDto[] {
|
||||||
const byId = new Map<bigint, CategoryNodeDto>();
|
const byId = new Map<bigint, CategoryNodeDto>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
byId.set(row.id, CategoryNodeDto.from(row, []));
|
byId.set(row.id, CategoryNodeDto.from(row, []));
|
||||||
}
|
}
|
||||||
const roots: CategoryNodeDto[] = [];
|
const roots: CategoryNodeDto[] = [];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const node = byId.get(row.id)!;
|
const node = byId.get(row.id)!;
|
||||||
if (row.parentCategoryId === null) {
|
if (row.parentCategoryId === null) {
|
||||||
roots.push(node);
|
roots.push(node);
|
||||||
} else {
|
} else {
|
||||||
const parent = byId.get(row.parentCategoryId);
|
const parent = byId.get(row.parentCategoryId);
|
||||||
if (parent) {
|
if (parent) {
|
||||||
parent.children.push(node);
|
parent.children.push(node);
|
||||||
} else {
|
} else {
|
||||||
// Orphan (parent row missing) — surface as a root.
|
// Orphan (parent row missing) — surface as a root.
|
||||||
roots.push(node);
|
roots.push(node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return roots;
|
return roots;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import type { Category as PrismaCategory } from '@prisma/client';
|
import type { Category as PrismaCategory } from '@prisma/client';
|
||||||
|
|
||||||
export class CategoryNodeDto {
|
export class CategoryNodeDto {
|
||||||
@ApiProperty({ description: 'Category ID (bigint serialized as string)' })
|
@ApiProperty({ description: 'Category ID (bigint serialized as string)' })
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
categoryName!: string;
|
categoryName!: string;
|
||||||
|
|
||||||
@ApiProperty({ nullable: true })
|
@ApiProperty({ nullable: true })
|
||||||
categoryIcon!: string | null;
|
categoryIcon!: string | null;
|
||||||
|
|
||||||
@ApiProperty({ nullable: true })
|
@ApiProperty({ nullable: true })
|
||||||
sdsCategoryId!: string | null;
|
sdsCategoryId!: string | null;
|
||||||
|
|
||||||
@ApiProperty({ nullable: true, description: 'Parent category ID' })
|
@ApiProperty({ nullable: true, description: 'Parent category ID' })
|
||||||
parentCategoryId!: string | null;
|
parentCategoryId!: string | null;
|
||||||
|
|
||||||
@ApiProperty({ type: [CategoryNodeDto] })
|
@ApiProperty({ type: [CategoryNodeDto] })
|
||||||
children!: CategoryNodeDto[];
|
children!: CategoryNodeDto[];
|
||||||
|
|
||||||
static from(category: PrismaCategory, children: CategoryNodeDto[] = []): CategoryNodeDto {
|
static from(category: PrismaCategory, children: CategoryNodeDto[] = []): CategoryNodeDto {
|
||||||
return {
|
return {
|
||||||
id: category.id.toString(),
|
id: category.id.toString(),
|
||||||
categoryName: category.categoryName,
|
categoryName: category.categoryName,
|
||||||
categoryIcon: category.categoryIcon,
|
categoryIcon: category.categoryIcon,
|
||||||
sdsCategoryId: category.sdsCategoryId,
|
sdsCategoryId: category.sdsCategoryId,
|
||||||
parentCategoryId: category.parentCategoryId
|
parentCategoryId: category.parentCategoryId
|
||||||
? category.parentCategoryId.toString()
|
? category.parentCategoryId.toString()
|
||||||
: null,
|
: null,
|
||||||
children,
|
children,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import {
|
import {
|
||||||
IsInt,
|
IsInt,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
Min,
|
Min,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class CreateCategoryDto {
|
export class CreateCategoryDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
categoryName!: string;
|
categoryName!: string;
|
||||||
|
|
||||||
@ApiProperty({ required: false, nullable: true })
|
@ApiProperty({ required: false, nullable: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
categoryIcon?: string;
|
categoryIcon?: string;
|
||||||
|
|
||||||
@ApiProperty({ required: false, nullable: true, description: 'Parent category ID' })
|
@ApiProperty({ required: false, nullable: true, description: 'Parent category ID' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
parentCategoryId?: number;
|
parentCategoryId?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import {
|
import {
|
||||||
IsInt,
|
IsInt,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
Min,
|
Min,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class UpdateCategoryDto {
|
export class UpdateCategoryDto {
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
categoryName?: string;
|
categoryName?: string;
|
||||||
|
|
||||||
@ApiProperty({ required: false, nullable: true })
|
@ApiProperty({ required: false, nullable: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
categoryIcon?: string | null;
|
categoryIcon?: string | null;
|
||||||
|
|
||||||
@ApiProperty({ required: false, nullable: true })
|
@ApiProperty({ required: false, nullable: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
parentCategoryId?: number | null;
|
parentCategoryId?: number | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pulls the authenticated user out of the request, as populated by
|
* Pulls the authenticated user out of the request, as populated by
|
||||||
* the JWT strategy.
|
* the JWT strategy.
|
||||||
*/
|
*/
|
||||||
export const CurrentUser = createParamDecorator(
|
export const CurrentUser = createParamDecorator(
|
||||||
(data: keyof { id: bigint; username: string } | undefined, ctx: ExecutionContext) => {
|
(data: keyof { id: bigint; username: string } | undefined, ctx: ExecutionContext) => {
|
||||||
const request = ctx.switchToHttp().getRequest<{ user?: { id: bigint; username: string } }>();
|
const request = ctx.switchToHttp().getRequest<{ user?: { id: bigint; username: string } }>();
|
||||||
const user = request.user;
|
const user = request.user;
|
||||||
return data ? user?.[data] : user;
|
return data ? user?.[data] : user;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,73 +1,92 @@
|
|||||||
import {
|
import {
|
||||||
ArgumentsHost,
|
ArgumentsHost,
|
||||||
Catch,
|
Catch,
|
||||||
ExceptionFilter,
|
ExceptionFilter,
|
||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Logger,
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global HTTP exception filter.
|
* Global HTTP exception filter.
|
||||||
*
|
*
|
||||||
* Normalizes the error envelope to:
|
* Normalizes the error envelope to:
|
||||||
* ```json
|
* ```json
|
||||||
* {
|
* {
|
||||||
* "statusCode": 400,
|
* "statusCode": 400,
|
||||||
* "message": "...",
|
* "message": "...",
|
||||||
* "error": "...",
|
* "error": "...",
|
||||||
* "timestamp": "ISO-8601",
|
* "timestamp": "ISO-8601",
|
||||||
* "path": "..."
|
* "path": "..."
|
||||||
* }
|
* }
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
@Catch()
|
@Catch()
|
||||||
export class HttpExceptionFilter implements ExceptionFilter {
|
export class HttpExceptionFilter implements ExceptionFilter {
|
||||||
private readonly logger = new Logger(HttpExceptionFilter.name);
|
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||||
|
|
||||||
catch(exception: unknown, host: ArgumentsHost): void {
|
catch(exception: unknown, host: ArgumentsHost): void {
|
||||||
const ctx = host.switchToHttp();
|
const ctx = host.switchToHttp();
|
||||||
const response = ctx.getResponse<Response>();
|
const response = ctx.getResponse<Response>();
|
||||||
const request = ctx.getRequest<Request>();
|
const request = ctx.getRequest<Request>();
|
||||||
|
|
||||||
const status =
|
const status =
|
||||||
exception instanceof HttpException
|
exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
? exception.getStatus()
|
|
||||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
// Malformed bigint/number inputs (e.g. `BigInt("abc")`) are client
|
||||||
|
// errors — map them to 400 instead of leaking a 500.
|
||||||
let message: string | string[] = 'Internal server error';
|
if (
|
||||||
let error = 'InternalServerError';
|
status === HttpStatus.INTERNAL_SERVER_ERROR &&
|
||||||
|
exception instanceof Error &&
|
||||||
if (exception instanceof HttpException) {
|
/Cannot convert .+ to (a BigInt|number)/i.test(exception.message)
|
||||||
const resp = exception.getResponse();
|
) {
|
||||||
if (typeof resp === 'string') {
|
response.status(HttpStatus.BAD_REQUEST).json({
|
||||||
message = resp;
|
statusCode: HttpStatus.BAD_REQUEST,
|
||||||
} else if (typeof resp === 'object' && resp !== null) {
|
message: 'Invalid numeric identifier',
|
||||||
const obj = resp as Record<string, unknown>;
|
error: 'BadRequestError',
|
||||||
message = (obj.message as string | string[]) ?? exception.message;
|
timestamp: new Date().toISOString(),
|
||||||
error = (obj.error as string) ?? exception.name;
|
path: request.url,
|
||||||
} else {
|
});
|
||||||
message = exception.message;
|
return;
|
||||||
}
|
}
|
||||||
} else if (exception instanceof Error) {
|
|
||||||
message = exception.message;
|
let message: string | string[] = 'Internal server error';
|
||||||
error = exception.name;
|
let error = 'InternalServerError';
|
||||||
}
|
|
||||||
|
if (exception instanceof HttpException) {
|
||||||
if (status >= 500) {
|
const resp = exception.getResponse();
|
||||||
this.logger.error(
|
if (typeof resp === 'string') {
|
||||||
`${request.method} ${request.url} -> ${status} ${message}`,
|
message = resp;
|
||||||
exception instanceof Error ? exception.stack : undefined,
|
} 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;
|
||||||
response.status(status).json({
|
} else {
|
||||||
statusCode: status,
|
message = exception.message;
|
||||||
message,
|
}
|
||||||
error,
|
} else if (exception instanceof Error) {
|
||||||
timestamp: new Date().toISOString(),
|
// Unexpected errors (Prisma, driver, ...) may contain SQL or
|
||||||
path: request.url,
|
// connection details — never send them to the client.
|
||||||
});
|
this.logger.error(
|
||||||
}
|
`${request.method} ${request.url} -> ${status} ${exception.message}`,
|
||||||
}
|
exception.stack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status >= 500 && exception instanceof HttpException) {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import {
|
import {
|
||||||
CallHandler,
|
CallHandler,
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
Injectable,
|
Injectable,
|
||||||
NestInterceptor,
|
NestInterceptor,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Observable, map } from 'rxjs';
|
import { Observable, map } from 'rxjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps every successful response into `{ data, success: true }`.
|
* Wraps every successful response into `{ data, success: true }`.
|
||||||
*
|
*
|
||||||
* Exceptions still flow through the global filter and are not wrapped.
|
* Exceptions still flow through the global filter and are not wrapped.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TransformInterceptor implements NestInterceptor {
|
export class TransformInterceptor implements NestInterceptor {
|
||||||
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||||
return next.handle().pipe(map((data) => ({ data, success: true })));
|
return next.handle().pipe(map((data) => ({ data, success: true })));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +1,61 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { CountriesService } from './countries.service';
|
import { CountriesService } from './countries.service';
|
||||||
import { CreateCountryDto } from './dto/create-country.dto';
|
import { CreateCountryDto } from './dto/create-country.dto';
|
||||||
import { UpdateCountryDto } from './dto/update-country.dto';
|
import { UpdateCountryDto } from './dto/update-country.dto';
|
||||||
|
|
||||||
@ApiTags('countries')
|
@ApiTags('countries')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('countries')
|
@Controller('countries')
|
||||||
export class CountriesController {
|
export class CountriesController {
|
||||||
constructor(private readonly service: CountriesService) {}
|
constructor(private readonly service: CountriesService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'List all countries' })
|
@ApiOperation({ summary: 'List all countries' })
|
||||||
findAll() {
|
findAll() {
|
||||||
return this.service.findAll();
|
return this.service.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get one country' })
|
@ApiOperation({ summary: 'Get one country' })
|
||||||
findOne(@Param('id', ParseIntPipe) id: string) {
|
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||||
return this.service.findOne(BigInt(id));
|
return this.service.findOne(BigInt(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: 'Create a country' })
|
@ApiOperation({ summary: 'Create a country' })
|
||||||
create(@Body() dto: CreateCountryDto) {
|
create(@Body() dto: CreateCountryDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@ApiOperation({ summary: 'Update a country' })
|
@ApiOperation({ summary: 'Update a country' })
|
||||||
update(
|
update(
|
||||||
@Param('id', ParseIntPipe) id: string,
|
@Param('id', ParseIntPipe) id: string,
|
||||||
@Body() dto: UpdateCountryDto,
|
@Body() dto: UpdateCountryDto,
|
||||||
) {
|
) {
|
||||||
return this.service.update(BigInt(id), dto);
|
return this.service.update(BigInt(id), dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@ApiOperation({ summary: 'Delete a country' })
|
@ApiOperation({ summary: 'Delete a country' })
|
||||||
remove(@Param('id', ParseIntPipe) id: string) {
|
remove(@Param('id', ParseIntPipe) id: string) {
|
||||||
return this.service.remove(BigInt(id));
|
return this.service.remove(BigInt(id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { CountriesController } from './countries.controller';
|
import { CountriesController } from './countries.controller';
|
||||||
import { CountriesService } from './countries.service';
|
import { CountriesService } from './countries.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [CountriesController],
|
controllers: [CountriesController],
|
||||||
providers: [CountriesService],
|
providers: [CountriesService],
|
||||||
exports: [CountriesService],
|
exports: [CountriesService],
|
||||||
})
|
})
|
||||||
export class CountriesModule {}
|
export class CountriesModule {}
|
||||||
|
|||||||
@@ -1,115 +1,115 @@
|
|||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { CountriesService } from './countries.service';
|
import { CountriesService } from './countries.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
describe('CountriesService', () => {
|
describe('CountriesService', () => {
|
||||||
let service: CountriesService;
|
let service: CountriesService;
|
||||||
let prisma: PrismaService;
|
let prisma: PrismaService;
|
||||||
const created: string[] = [];
|
const created: string[] = [];
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
providers: [CountriesService, PrismaService],
|
providers: [CountriesService, PrismaService],
|
||||||
}).compile();
|
}).compile();
|
||||||
service = moduleRef.get(CountriesService);
|
service = moduleRef.get(CountriesService);
|
||||||
prisma = moduleRef.get(PrismaService);
|
prisma = moduleRef.get(PrismaService);
|
||||||
await prisma.onModuleInit();
|
await prisma.onModuleInit();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
if (created.length) {
|
if (created.length) {
|
||||||
// Delete goods/origin_goods first so we can remove the countries.
|
// Delete goods/origin_goods first so we can remove the countries.
|
||||||
await prisma.good.deleteMany({
|
await prisma.good.deleteMany({
|
||||||
where: { country: { countryName: { in: created } } },
|
where: { country: { countryName: { in: created } } },
|
||||||
});
|
});
|
||||||
await prisma.position.deleteMany({
|
await prisma.position.deleteMany({
|
||||||
where: { country: { countryName: { in: created } } },
|
where: { country: { countryName: { in: created } } },
|
||||||
});
|
});
|
||||||
await prisma.country.deleteMany({
|
await prisma.country.deleteMany({
|
||||||
where: { countryName: { in: created } },
|
where: { countryName: { in: created } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await prisma.onModuleDestroy();
|
await prisma.onModuleDestroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
expect(service).toBeDefined();
|
expect(service).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates and reads back a country', async () => {
|
it('creates and reads back a country', async () => {
|
||||||
const name = `Test Country ${Date.now()}`;
|
const name = `Test Country ${Date.now()}`;
|
||||||
created.push(name);
|
created.push(name);
|
||||||
|
|
||||||
const createdRow = await service.create({ countryName: name });
|
const createdRow = await service.create({ countryName: name });
|
||||||
expect(createdRow.countryName).toBe(name);
|
expect(createdRow.countryName).toBe(name);
|
||||||
|
|
||||||
const fetched = await service.findOne(createdRow.id);
|
const fetched = await service.findOne(createdRow.id);
|
||||||
expect(fetched.countryName).toBe(name);
|
expect(fetched.countryName).toBe(name);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects duplicate names with ConflictException', async () => {
|
it('rejects duplicate names with ConflictException', async () => {
|
||||||
const name = `Dup Country ${Date.now()}`;
|
const name = `Dup Country ${Date.now()}`;
|
||||||
created.push(name);
|
created.push(name);
|
||||||
await service.create({ countryName: name });
|
await service.create({ countryName: name });
|
||||||
await expect(service.create({ countryName: name })).rejects.toBeInstanceOf(
|
await expect(service.create({ countryName: name })).rejects.toBeInstanceOf(
|
||||||
ConflictException,
|
ConflictException,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws NotFoundException for unknown id', async () => {
|
it('throws NotFoundException for unknown id', async () => {
|
||||||
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
|
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws BadRequestException when deleting a country referenced by goods', async () => {
|
it('throws BadRequestException when deleting a country referenced by goods', async () => {
|
||||||
const name = `Ref Country ${Date.now()}`;
|
const name = `Ref Country ${Date.now()}`;
|
||||||
created.push(name);
|
created.push(name);
|
||||||
const country = await service.create({ countryName: name });
|
const country = await service.create({ countryName: name });
|
||||||
|
|
||||||
// Need a category + origin good to satisfy the foreign keys before
|
// Need a category + origin good to satisfy the foreign keys before
|
||||||
// we can attach a good that references the country.
|
// we can attach a good that references the country.
|
||||||
const category = await prisma.category.create({
|
const category = await prisma.category.create({
|
||||||
data: { categoryName: `Cat ${Date.now()}` },
|
data: { categoryName: `Cat ${Date.now()}` },
|
||||||
});
|
});
|
||||||
const originGood = await prisma.originGood.create({
|
const originGood = await prisma.originGood.create({
|
||||||
data: { sdsGoodId: `sds-${Date.now()}-${Math.random()}` },
|
data: { sdsGoodId: `sds-${Date.now()}-${Math.random()}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.good.create({
|
await prisma.good.create({
|
||||||
data: {
|
data: {
|
||||||
originGoodId: originGood.id,
|
originGoodId: originGood.id,
|
||||||
countryId: country.id,
|
countryId: country.id,
|
||||||
categoryId: category.id,
|
categoryId: category.id,
|
||||||
goodName: 'sample',
|
goodName: 'sample',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(service.remove(country.id)).rejects.toBeInstanceOf(
|
await expect(service.remove(country.id)).rejects.toBeInstanceOf(
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
);
|
);
|
||||||
|
|
||||||
// cleanup
|
// cleanup
|
||||||
await prisma.good.deleteMany({ where: { countryId: country.id } });
|
await prisma.good.deleteMany({ where: { countryId: country.id } });
|
||||||
await prisma.originGood.delete({ where: { id: originGood.id } });
|
await prisma.originGood.delete({ where: { id: originGood.id } });
|
||||||
await prisma.category.delete({ where: { id: category.id } });
|
await prisma.category.delete({ where: { id: category.id } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates fields and deletes when unreferenced', async () => {
|
it('updates fields and deletes when unreferenced', async () => {
|
||||||
const name = `Upd Country ${Date.now()}`;
|
const name = `Upd Country ${Date.now()}`;
|
||||||
created.push(name);
|
created.push(name);
|
||||||
const c = await service.create({ countryName: name });
|
const c = await service.create({ countryName: name });
|
||||||
const updated = await service.update(c.id, { countryName: `${name}-v2` });
|
const updated = await service.update(c.id, { countryName: `${name}-v2` });
|
||||||
expect(updated.countryName).toBe(`${name}-v2`);
|
expect(updated.countryName).toBe(`${name}-v2`);
|
||||||
created[created.indexOf(name)] = `${name}-v2`;
|
created[created.indexOf(name)] = `${name}-v2`;
|
||||||
|
|
||||||
await service.remove(c.id);
|
await service.remove(c.id);
|
||||||
const idx = created.indexOf(`${name}-v2`);
|
const idx = created.indexOf(`${name}-v2`);
|
||||||
if (idx !== -1) created.splice(idx, 1);
|
if (idx !== -1) created.splice(idx, 1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,100 +1,100 @@
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CreateCountryDto } from './dto/create-country.dto';
|
import { CreateCountryDto } from './dto/create-country.dto';
|
||||||
import { UpdateCountryDto } from './dto/update-country.dto';
|
import { UpdateCountryDto } from './dto/update-country.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CountriesService {
|
export class CountriesService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
findAll() {
|
findAll() {
|
||||||
return this.prisma.country.findMany({ orderBy: { id: 'asc' } });
|
return this.prisma.country.findMany({ orderBy: { id: 'asc' } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: bigint) {
|
async findOne(id: bigint) {
|
||||||
const country = await this.prisma.country.findUnique({ where: { id } });
|
const country = await this.prisma.country.findUnique({ where: { id } });
|
||||||
if (!country) {
|
if (!country) {
|
||||||
throw new NotFoundException(`Country ${id} not found`);
|
throw new NotFoundException(`Country ${id} not found`);
|
||||||
}
|
}
|
||||||
return country;
|
return country;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateCountryDto) {
|
async create(dto: CreateCountryDto) {
|
||||||
try {
|
try {
|
||||||
return await this.prisma.country.create({
|
return await this.prisma.country.create({
|
||||||
data: {
|
data: {
|
||||||
countryName: dto.countryName,
|
countryName: dto.countryName,
|
||||||
countryIcon: dto.countryIcon ?? null,
|
countryIcon: dto.countryIcon ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (
|
if (
|
||||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
err.code === 'P2002'
|
err.code === 'P2002'
|
||||||
) {
|
) {
|
||||||
throw new ConflictException('Country name already exists');
|
throw new ConflictException('Country name already exists');
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: bigint, dto: UpdateCountryDto) {
|
async update(id: bigint, dto: UpdateCountryDto) {
|
||||||
await this.findOne(id);
|
await this.findOne(id);
|
||||||
try {
|
try {
|
||||||
return await this.prisma.country.update({
|
return await this.prisma.country.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
countryName: dto.countryName,
|
countryName: dto.countryName,
|
||||||
countryIcon: dto.countryIcon === undefined ? undefined : dto.countryIcon,
|
countryIcon: dto.countryIcon === undefined ? undefined : dto.countryIcon,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (
|
if (
|
||||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
err.code === 'P2002'
|
err.code === 'P2002'
|
||||||
) {
|
) {
|
||||||
throw new ConflictException('Country name already exists');
|
throw new ConflictException('Country name already exists');
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: bigint) {
|
async remove(id: bigint) {
|
||||||
await this.findOne(id);
|
await this.findOne(id);
|
||||||
try {
|
try {
|
||||||
return await this.prisma.country.delete({ where: { id } });
|
return await this.prisma.country.delete({ where: { id } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (this.isForeignKeyViolation(err)) {
|
if (this.isForeignKeyViolation(err)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Country is referenced by goods or positions and cannot be deleted',
|
'Country is referenced by goods or positions and cannot be deleted',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private isForeignKeyViolation(err: unknown): boolean {
|
private isForeignKeyViolation(err: unknown): boolean {
|
||||||
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
// P2003 = FK constraint violation
|
// P2003 = FK constraint violation
|
||||||
return err.code === 'P2003';
|
return err.code === 'P2003';
|
||||||
}
|
}
|
||||||
// Fallback: Prisma sometimes surfaces FK violations as UnknownRequestError
|
// Fallback: Prisma sometimes surfaces FK violations as UnknownRequestError
|
||||||
// when the constraint check happens server-side before the typed error
|
// when the constraint check happens server-side before the typed error
|
||||||
// is mapped (e.g. cascading RESTRICT).
|
// is mapped (e.g. cascading RESTRICT).
|
||||||
if (err instanceof Prisma.PrismaClientUnknownRequestError) {
|
if (err instanceof Prisma.PrismaClientUnknownRequestError) {
|
||||||
const msg = err.message ?? '';
|
const msg = err.message ?? '';
|
||||||
return (
|
return (
|
||||||
msg.includes('foreign key constraint') ||
|
msg.includes('foreign key constraint') ||
|
||||||
msg.includes('RESTRICT') ||
|
msg.includes('RESTRICT') ||
|
||||||
msg.includes('violates')
|
msg.includes('violates')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class CreateCountryDto {
|
export class CreateCountryDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
countryName!: string;
|
countryName!: string;
|
||||||
|
|
||||||
@ApiProperty({ required: false, nullable: true })
|
@ApiProperty({ required: false, nullable: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
countryIcon?: string;
|
countryIcon?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsOptional, IsString } from 'class-validator';
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateCountryDto {
|
export class UpdateCountryDto {
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
countryName?: string;
|
countryName?: string;
|
||||||
|
|
||||||
@ApiProperty({ required: false, nullable: true })
|
@ApiProperty({ required: false, nullable: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
countryIcon?: string | null;
|
countryIcon?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +1,68 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import {
|
import {
|
||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
IsArray,
|
IsArray,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
Min,
|
Min,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
export class BatchCreateItemDto {
|
export class BatchCreateItemDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
originGoodId!: number;
|
originGoodId!: number;
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false, nullable: true, type: [Number], description: '副源原产品 ID,不含主源' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsArray()
|
||||||
@Min(0)
|
@IsInt({ each: true })
|
||||||
priority?: number;
|
@Min(1, { each: true })
|
||||||
}
|
mergedOriginGoodIds?: number[];
|
||||||
|
|
||||||
export class BatchCreateGoodDto {
|
@ApiProperty({ required: false })
|
||||||
@ApiProperty({ type: [BatchCreateItemDto] })
|
@IsOptional()
|
||||||
@IsArray()
|
@IsInt()
|
||||||
@ArrayMinSize(1)
|
@Min(0)
|
||||||
@ValidateNested({ each: true })
|
priority?: number;
|
||||||
@Type(() => BatchCreateItemDto)
|
}
|
||||||
items!: BatchCreateItemDto[];
|
|
||||||
|
export class BatchCreateGoodDto {
|
||||||
@ApiProperty()
|
@ApiProperty({ type: [BatchCreateItemDto] })
|
||||||
@IsInt()
|
@IsArray()
|
||||||
@Min(1)
|
@ArrayMinSize(1)
|
||||||
countryId!: number;
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => BatchCreateItemDto)
|
||||||
@ApiProperty()
|
items!: BatchCreateItemDto[];
|
||||||
@IsInt()
|
|
||||||
@Min(1)
|
@ApiProperty()
|
||||||
categoryId!: number;
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
@ApiProperty({ required: false, nullable: true, type: [Number] })
|
countryId!: number;
|
||||||
@IsOptional()
|
|
||||||
@IsArray()
|
@ApiProperty()
|
||||||
@IsInt({ each: true })
|
@IsInt()
|
||||||
@Min(1, { each: true })
|
@Min(1)
|
||||||
tagIds?: number[];
|
categoryId!: number;
|
||||||
|
|
||||||
@ApiProperty({ required: false, nullable: true })
|
@ApiProperty({ required: false, nullable: true, type: [Number] })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsArray()
|
||||||
@Min(1)
|
@IsInt({ each: true })
|
||||||
positionId?: number;
|
@Min(1, { each: true })
|
||||||
|
tagIds?: number[];
|
||||||
@ApiProperty({ required: false, default: 0 })
|
|
||||||
@IsOptional()
|
@ApiProperty({ required: false, nullable: true })
|
||||||
@IsInt()
|
@IsOptional()
|
||||||
@Min(0)
|
@IsInt()
|
||||||
defaultPriority?: number;
|
@Min(1)
|
||||||
|
positionId?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, default: 0 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
defaultPriority?: number;
|
||||||
}
|
}
|
||||||
@@ -1,30 +1,30 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import {
|
import {
|
||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
IsArray,
|
IsArray,
|
||||||
IsInt,
|
IsInt,
|
||||||
Min,
|
Min,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
export class PriorityItemDto {
|
export class PriorityItemDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
id!: number;
|
id!: number;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
priority!: number;
|
priority!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BatchPriorityDto {
|
export class BatchPriorityDto {
|
||||||
@ApiProperty({ type: [PriorityItemDto] })
|
@ApiProperty({ type: [PriorityItemDto] })
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ArrayMinSize(1)
|
@ArrayMinSize(1)
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => PriorityItemDto)
|
@Type(() => PriorityItemDto)
|
||||||
items!: PriorityItemDto[];
|
items!: PriorityItemDto[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,64 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import {
|
import {
|
||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
IsArray,
|
IsArray,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
Min,
|
Min,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class CreateGoodDto {
|
export class CreateGoodDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
goodName!: string;
|
goodName!: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
originGoodId!: number;
|
originGoodId!: number;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty({ required: false, nullable: true, type: [Number], description: '副源原产品 ID,不含主源' })
|
||||||
@IsInt()
|
@IsOptional()
|
||||||
@Min(1)
|
@IsArray()
|
||||||
countryId!: number;
|
@IsInt({ each: true })
|
||||||
|
@Min(1, { each: true })
|
||||||
@ApiProperty()
|
mergedOriginGoodIds?: number[];
|
||||||
@IsInt()
|
|
||||||
@Min(1)
|
@ApiProperty()
|
||||||
categoryId!: number;
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
@ApiProperty({ required: false, nullable: true, type: [Number] })
|
countryId!: number;
|
||||||
@IsOptional()
|
|
||||||
@IsArray()
|
@ApiProperty()
|
||||||
@ArrayMinSize(1)
|
@IsInt()
|
||||||
@IsInt({ each: true })
|
@Min(1)
|
||||||
@Min(1, { each: true })
|
categoryId!: number;
|
||||||
tagIds?: number[];
|
|
||||||
|
@ApiProperty({ required: false, nullable: true, type: [Number] })
|
||||||
@ApiProperty({ required: false, nullable: true })
|
@IsOptional()
|
||||||
@IsOptional()
|
@IsArray()
|
||||||
@IsInt()
|
@ArrayMinSize(1)
|
||||||
@Min(1)
|
@IsInt({ each: true })
|
||||||
positionId?: number;
|
@Min(1, { each: true })
|
||||||
|
tagIds?: number[];
|
||||||
@ApiProperty({ required: false, default: 0 })
|
|
||||||
@IsOptional()
|
@ApiProperty({ required: false, nullable: true })
|
||||||
@IsInt()
|
@IsOptional()
|
||||||
@Min(0)
|
@IsInt()
|
||||||
goodPriority?: number;
|
@Min(1)
|
||||||
|
positionId?: number;
|
||||||
@ApiProperty({ required: false, nullable: true })
|
|
||||||
@IsOptional()
|
@ApiProperty({ required: false, default: 0 })
|
||||||
@IsString()
|
@IsOptional()
|
||||||
goodImage?: string;
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
goodPriority?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
goodImage?: string;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import { ApiProperty, OmitType, PartialType } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsInt,
|
||||||
|
IsNumberString,
|
||||||
|
IsObject,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Min,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { CreateGoodDto } from './create-good.dto';
|
||||||
|
|
||||||
|
export class CustomGoodDetailDto {
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
productCode?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
englishName?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
blankDesignUrl?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
detailsPageVideoUrl?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
textureName?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
productionCycleHours?: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true, example: '208.000' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumberString()
|
||||||
|
minWeightG?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
productionProcess?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
materialDescription?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
reminder?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
productPerformance?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
applicableScenarios?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
washingInstructions?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
specialDescription?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
designExplanation?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
designArea?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
pictureRequest?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true, type: Object })
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
sizeChart?: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true, type: Object })
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
packageSpecs?: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true, type: Object })
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
options?: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true, type: Object })
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
media?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CustomGoodVariantDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
sku!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sizeName?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sizeId?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
colorName?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
colorHex?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
colorId?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
imageUrl?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true, example: '28.00' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumberString()
|
||||||
|
price?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumberString()
|
||||||
|
originalPrice?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumberString()
|
||||||
|
weightG?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumberString()
|
||||||
|
boxLengthCm?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumberString()
|
||||||
|
boxWidthCm?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumberString()
|
||||||
|
boxHeightCm?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true, type: Object })
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
designData?: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, default: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
enabled?: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, default: 0 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateCustomGoodDto extends OmitType(CreateGoodDto, [
|
||||||
|
'originGoodId',
|
||||||
|
] as const) {
|
||||||
|
@ApiProperty({ required: false, nullable: true, example: '28.00' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumberString()
|
||||||
|
goodPrice?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, type: CustomGoodDetailDto })
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => CustomGoodDetailDto)
|
||||||
|
detail?: CustomGoodDetailDto;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, type: [CustomGoodVariantDto] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CustomGoodVariantDto)
|
||||||
|
variants?: CustomGoodVariantDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateCustomGoodContentDto extends PartialType(
|
||||||
|
OmitType(CreateCustomGoodDto, [
|
||||||
|
'countryId',
|
||||||
|
'categoryId',
|
||||||
|
'tagIds',
|
||||||
|
'positionId',
|
||||||
|
'goodPriority',
|
||||||
|
] as const),
|
||||||
|
) {}
|
||||||
@@ -1,211 +1,282 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import type { Good as PrismaGood } from '@prisma/client';
|
import type { Good as PrismaGood } from '@prisma/client';
|
||||||
|
|
||||||
export interface GoodRelations {
|
export interface GoodRelations {
|
||||||
country?: { id: bigint; countryName: string; countryIcon: string | null } | null;
|
country?: { id: bigint; countryName: string; countryIcon: string | null } | null;
|
||||||
category?: { id: bigint; categoryName: string; categoryIcon: string | null } | null;
|
category?: { id: bigint; categoryName: string; categoryIcon: string | null } | null;
|
||||||
tag?: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } | null;
|
tag?: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } | null;
|
||||||
position?: { id: bigint; indexVal: number } | null;
|
position?: { id: bigint; indexVal: number } | null;
|
||||||
originGood?: {
|
originGood?: {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
sdsGoodId: string;
|
sdsGoodId: string;
|
||||||
goodName: string | null;
|
source: 'SDS' | 'CUSTOM';
|
||||||
goodImage: string | null;
|
goodName: string | null;
|
||||||
goodPrice: unknown;
|
goodImage: string | null;
|
||||||
detail?: {
|
goodPrice: unknown;
|
||||||
productCode: string | null;
|
detail?: {
|
||||||
syncedAt: Date;
|
productCode: string | null;
|
||||||
sizeChart: unknown;
|
syncedAt: Date;
|
||||||
packageSpecs: unknown;
|
sizeChart: unknown;
|
||||||
[key: string]: unknown;
|
packageSpecs: unknown;
|
||||||
} | null;
|
[key: string]: unknown;
|
||||||
variants?: Array<{
|
} | null;
|
||||||
sdsVariantId: string;
|
variants?: Array<{
|
||||||
sku: string;
|
sdsVariantId: string;
|
||||||
sizeName: string | null;
|
sku: string;
|
||||||
colorName: string | null;
|
sizeName: string | null;
|
||||||
colorHex: string | null;
|
colorName: string | null;
|
||||||
price: unknown;
|
colorHex: string | null;
|
||||||
enabled: boolean;
|
price: unknown;
|
||||||
[key: string]: unknown;
|
enabled: boolean;
|
||||||
}>;
|
[key: string]: unknown;
|
||||||
_count?: { variants: number };
|
}>;
|
||||||
} | null;
|
_count?: { variants: number };
|
||||||
goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } }[];
|
} | null;
|
||||||
}
|
goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } }[];
|
||||||
|
mergedOriginGoods?: Array<{
|
||||||
export class GoodDto {
|
originGood: {
|
||||||
@ApiProperty()
|
id: bigint;
|
||||||
id!: string;
|
sdsGoodId: string;
|
||||||
|
source: 'SDS' | 'CUSTOM';
|
||||||
@ApiProperty()
|
goodName: string | null;
|
||||||
goodName!: string;
|
goodImage: string | null;
|
||||||
|
goodPrice: unknown;
|
||||||
@ApiProperty({ nullable: true })
|
detail?: { syncedAt: Date } | Record<string, unknown> | null;
|
||||||
goodImage!: string | null;
|
variants?: Array<{ [key: string]: unknown }>;
|
||||||
|
_count?: { variants: number };
|
||||||
@ApiProperty()
|
};
|
||||||
goodPriority!: number;
|
}>;
|
||||||
|
}
|
||||||
@ApiProperty()
|
|
||||||
countryId!: string;
|
export interface MergedOriginGoodSummary {
|
||||||
|
id: string;
|
||||||
@ApiProperty()
|
sdsGoodId: string;
|
||||||
categoryId!: string;
|
source: 'SDS' | 'CUSTOM';
|
||||||
|
isCustom: boolean;
|
||||||
@ApiProperty({ nullable: true })
|
goodName: string | null;
|
||||||
tagId!: string | null;
|
goodImage: string | null;
|
||||||
|
goodPrice: string | null;
|
||||||
@ApiProperty({ nullable: true })
|
hasDetail: boolean;
|
||||||
positionId!: string | null;
|
variantCount: number;
|
||||||
|
}
|
||||||
@ApiProperty()
|
|
||||||
originGoodId!: string;
|
export class GoodDto {
|
||||||
|
@ApiProperty()
|
||||||
@ApiProperty()
|
id!: string;
|
||||||
createdAt!: string;
|
|
||||||
|
@ApiProperty()
|
||||||
@ApiProperty()
|
goodName!: string;
|
||||||
updatedAt!: string;
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
@ApiProperty({ required: false, nullable: true })
|
goodImage!: string | null;
|
||||||
country?: { id: string; countryName: string; countryIcon: string | null } | null;
|
|
||||||
|
@ApiProperty()
|
||||||
@ApiProperty({ required: false, nullable: true })
|
goodPriority!: number;
|
||||||
category?: { id: string; categoryName: string; categoryIcon: string | null } | null;
|
|
||||||
|
@ApiProperty()
|
||||||
@ApiProperty({ required: false, nullable: true })
|
countryId!: string;
|
||||||
tag?: { id: string; tagName: string; tagColor: string | null; tagFontColor: string | null } | null;
|
|
||||||
|
@ApiProperty()
|
||||||
@ApiProperty({ required: false, type: Array })
|
categoryId!: string;
|
||||||
tags!: Array<{ id: string; tagName: string; tagColor: string | null; tagFontColor: string | null }>;
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
@ApiProperty({ required: false, nullable: true })
|
tagId!: string | null;
|
||||||
position?: { id: string; indexVal: number } | null;
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
@ApiProperty({ required: false, nullable: true })
|
positionId!: string | null;
|
||||||
originGood?: {
|
|
||||||
id: string;
|
@ApiProperty()
|
||||||
sdsGoodId: string;
|
originGoodId!: string;
|
||||||
goodName: string | null;
|
|
||||||
goodImage: string | null;
|
@ApiProperty()
|
||||||
goodPrice: string | null;
|
createdAt!: string;
|
||||||
hasDetail: boolean;
|
|
||||||
detailSyncedAt: string | null;
|
@ApiProperty()
|
||||||
variantCount: number;
|
updatedAt!: string;
|
||||||
sizeRowCount: number;
|
|
||||||
packageRowCount: number;
|
@ApiProperty({ required: false, nullable: true })
|
||||||
productCode: string | null;
|
country?: { id: string; countryName: string; countryIcon: string | null } | null;
|
||||||
} | null;
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
static from(
|
category?: { id: string; categoryName: string; categoryIcon: string | null } | null;
|
||||||
good: PrismaGood,
|
|
||||||
rel: GoodRelations = {},
|
@ApiProperty({ required: false, nullable: true })
|
||||||
): GoodDto {
|
tag?: { id: string; tagName: string; tagColor: string | null; tagFontColor: string | null } | null;
|
||||||
return {
|
|
||||||
id: good.id.toString(),
|
@ApiProperty({ required: false, type: Array })
|
||||||
goodName: good.goodName,
|
tags!: Array<{ id: string; tagName: string; tagColor: string | null; tagFontColor: string | null }>;
|
||||||
goodImage: good.goodImage,
|
|
||||||
goodPriority: good.goodPriority,
|
@ApiProperty({ required: false, type: Array })
|
||||||
countryId: good.countryId.toString(),
|
mergedOriginGoods!: MergedOriginGoodSummary[];
|
||||||
categoryId: good.categoryId.toString(),
|
|
||||||
tagId: good.tagId === null || good.tagId === undefined ? null : good.tagId.toString(),
|
@ApiProperty({ required: false, nullable: true })
|
||||||
positionId: good.positionId === null || good.positionId === undefined ? null : good.positionId.toString(),
|
position?: { id: string; indexVal: number } | null;
|
||||||
originGoodId: good.originGoodId.toString(),
|
|
||||||
createdAt: good.createdAt.toISOString(),
|
@ApiProperty({ required: false, nullable: true })
|
||||||
updatedAt: good.updatedAt.toISOString(),
|
originGood?: {
|
||||||
country: rel.country
|
id: string;
|
||||||
? {
|
sdsGoodId: string;
|
||||||
id: rel.country.id.toString(),
|
source: 'SDS' | 'CUSTOM';
|
||||||
countryName: rel.country.countryName,
|
isCustom: boolean;
|
||||||
countryIcon: rel.country.countryIcon,
|
goodName: string | null;
|
||||||
}
|
goodImage: string | null;
|
||||||
: null,
|
goodPrice: string | null;
|
||||||
category: rel.category
|
hasDetail: boolean;
|
||||||
? {
|
detailSyncedAt: string | null;
|
||||||
id: rel.category.id.toString(),
|
variantCount: number;
|
||||||
categoryName: rel.category.categoryName,
|
sizeRowCount: number;
|
||||||
categoryIcon: rel.category.categoryIcon,
|
packageRowCount: number;
|
||||||
}
|
productCode: string | null;
|
||||||
: null,
|
} | null;
|
||||||
tag: rel.tag
|
|
||||||
? {
|
static from(
|
||||||
id: rel.tag.id.toString(),
|
good: PrismaGood,
|
||||||
tagName: rel.tag.tagName,
|
rel: GoodRelations = {},
|
||||||
tagColor: rel.tag.tagColor,
|
): GoodDto {
|
||||||
tagFontColor: rel.tag.tagFontColor,
|
return {
|
||||||
}
|
id: good.id.toString(),
|
||||||
: null,
|
goodName: good.goodName,
|
||||||
tags: rel.goodTags
|
goodImage: good.goodImage,
|
||||||
? rel.goodTags.map((gt) => ({
|
goodPriority: good.goodPriority,
|
||||||
id: gt.tag.id.toString(),
|
countryId: good.countryId.toString(),
|
||||||
tagName: gt.tag.tagName,
|
categoryId: good.categoryId.toString(),
|
||||||
tagColor: gt.tag.tagColor,
|
tagId: good.tagId === null || good.tagId === undefined ? null : good.tagId.toString(),
|
||||||
tagFontColor: gt.tag.tagFontColor,
|
positionId: good.positionId === null || good.positionId === undefined ? null : good.positionId.toString(),
|
||||||
}))
|
originGoodId: good.originGoodId.toString(),
|
||||||
: [],
|
createdAt: good.createdAt.toISOString(),
|
||||||
position: rel.position
|
updatedAt: good.updatedAt.toISOString(),
|
||||||
? {
|
country: rel.country
|
||||||
id: rel.position.id.toString(),
|
? {
|
||||||
indexVal: rel.position.indexVal,
|
id: rel.country.id.toString(),
|
||||||
}
|
countryName: rel.country.countryName,
|
||||||
: null,
|
countryIcon: rel.country.countryIcon,
|
||||||
originGood: rel.originGood
|
}
|
||||||
? {
|
: null,
|
||||||
id: rel.originGood.id.toString(),
|
category: rel.category
|
||||||
sdsGoodId: rel.originGood.sdsGoodId,
|
? {
|
||||||
goodName: rel.originGood.goodName,
|
id: rel.category.id.toString(),
|
||||||
goodImage: rel.originGood.goodImage,
|
categoryName: rel.category.categoryName,
|
||||||
goodPrice:
|
categoryIcon: rel.category.categoryIcon,
|
||||||
rel.originGood.goodPrice === null ||
|
}
|
||||||
rel.originGood.goodPrice === undefined
|
: null,
|
||||||
? null
|
tag: rel.tag
|
||||||
: (rel.originGood.goodPrice as { toString(): string }).toString(),
|
? {
|
||||||
hasDetail: Boolean(rel.originGood.detail),
|
id: rel.tag.id.toString(),
|
||||||
detailSyncedAt: rel.originGood.detail?.syncedAt.toISOString() ?? null,
|
tagName: rel.tag.tagName,
|
||||||
variantCount: rel.originGood._count?.variants ?? rel.originGood.variants?.length ?? 0,
|
tagColor: rel.tag.tagColor,
|
||||||
sizeRowCount: GoodDto.jsonRows(rel.originGood.detail?.sizeChart),
|
tagFontColor: rel.tag.tagFontColor,
|
||||||
packageRowCount: GoodDto.jsonRows(rel.originGood.detail?.packageSpecs),
|
}
|
||||||
productCode: rel.originGood.detail?.productCode ?? null,
|
: null,
|
||||||
}
|
tags: rel.goodTags
|
||||||
: null,
|
? rel.goodTags.map((gt) => ({
|
||||||
};
|
id: gt.tag.id.toString(),
|
||||||
}
|
tagName: gt.tag.tagName,
|
||||||
|
tagColor: gt.tag.tagColor,
|
||||||
private static jsonRows(value: unknown): number {
|
tagFontColor: gt.tag.tagFontColor,
|
||||||
if (!value || typeof value !== 'object' || !('rows' in value)) return 0;
|
}))
|
||||||
const rows = (value as { rows?: unknown }).rows;
|
: [],
|
||||||
return Array.isArray(rows) ? rows.length : 0;
|
mergedOriginGoods: (rel.mergedOriginGoods ?? []).map((m) => ({
|
||||||
}
|
id: m.originGood.id.toString(),
|
||||||
}
|
sdsGoodId: m.originGood.sdsGoodId,
|
||||||
|
source: m.originGood.source,
|
||||||
export class GoodDetailDto extends GoodDto {
|
isCustom: m.originGood.source === 'CUSTOM',
|
||||||
@ApiProperty({ nullable: true, type: Object })
|
goodName: m.originGood.goodName,
|
||||||
originDetail!: Record<string, unknown> | null;
|
goodImage: m.originGood.goodImage,
|
||||||
|
goodPrice:
|
||||||
@ApiProperty({ type: Array })
|
m.originGood.goodPrice === null || m.originGood.goodPrice === undefined
|
||||||
variants!: Array<Record<string, unknown>>;
|
? null
|
||||||
|
: (m.originGood.goodPrice as { toString(): string }).toString(),
|
||||||
static fromGood(good: PrismaGood, rel: GoodRelations): GoodDetailDto {
|
hasDetail: Boolean(m.originGood.detail),
|
||||||
const base = GoodDto.from(good, rel);
|
variantCount:
|
||||||
const detail = rel.originGood?.detail;
|
m.originGood._count?.variants ?? m.originGood.variants?.length ?? 0,
|
||||||
return {
|
})),
|
||||||
...base,
|
position: rel.position
|
||||||
originDetail: detail ? { ...detail, syncedAt: detail.syncedAt.toISOString() } : null,
|
? {
|
||||||
variants: (rel.originGood?.variants ?? []).map((variant) => ({
|
id: rel.position.id.toString(),
|
||||||
...variant,
|
indexVal: rel.position.indexVal,
|
||||||
price:
|
}
|
||||||
variant.price === null || variant.price === undefined
|
: null,
|
||||||
? null
|
originGood: rel.originGood
|
||||||
: (variant.price as { toString(): string }).toString(),
|
? {
|
||||||
})),
|
id: rel.originGood.id.toString(),
|
||||||
};
|
sdsGoodId: rel.originGood.sdsGoodId,
|
||||||
}
|
source: rel.originGood.source,
|
||||||
}
|
isCustom: rel.originGood.source === 'CUSTOM',
|
||||||
|
goodName: rel.originGood.goodName,
|
||||||
export interface PaginatedGoods {
|
goodImage: rel.originGood.goodImage,
|
||||||
items: GoodDto[];
|
goodPrice:
|
||||||
total: number;
|
rel.originGood.goodPrice === null ||
|
||||||
page: number;
|
rel.originGood.goodPrice === undefined
|
||||||
pageSize: number;
|
? null
|
||||||
}
|
: (rel.originGood.goodPrice as { toString(): string }).toString(),
|
||||||
|
hasDetail: Boolean(rel.originGood.detail),
|
||||||
|
detailSyncedAt: rel.originGood.detail?.syncedAt.toISOString() ?? null,
|
||||||
|
variantCount: rel.originGood._count?.variants ?? rel.originGood.variants?.length ?? 0,
|
||||||
|
sizeRowCount: GoodDto.jsonRows(rel.originGood.detail?.sizeChart),
|
||||||
|
packageRowCount: GoodDto.jsonRows(rel.originGood.detail?.packageSpecs),
|
||||||
|
productCode: rel.originGood.detail?.productCode ?? null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static jsonRows(value: unknown): number {
|
||||||
|
if (!value || typeof value !== 'object' || !('rows' in value)) return 0;
|
||||||
|
const rows = (value as { rows?: unknown }).rows;
|
||||||
|
return Array.isArray(rows) ? rows.length : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GoodDetailDto extends GoodDto {
|
||||||
|
@ApiProperty({ nullable: true, type: Object })
|
||||||
|
originDetail!: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Array })
|
||||||
|
variants!: Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
static fromGood(good: PrismaGood, rel: GoodRelations): GoodDetailDto {
|
||||||
|
const base = GoodDto.from(good, rel);
|
||||||
|
const detail = rel.originGood?.detail;
|
||||||
|
const toAnnotated = (
|
||||||
|
variant: Record<string, unknown>,
|
||||||
|
originGoodId: string,
|
||||||
|
originGoodName: string | null,
|
||||||
|
) => ({
|
||||||
|
...variant,
|
||||||
|
price:
|
||||||
|
variant.price === null || variant.price === undefined
|
||||||
|
? null
|
||||||
|
: (variant.price as unknown as { toString(): string }).toString(),
|
||||||
|
originGoodId,
|
||||||
|
originGoodName,
|
||||||
|
});
|
||||||
|
const primaryId = good.originGoodId.toString();
|
||||||
|
const primaryName = rel.originGood?.goodName ?? null;
|
||||||
|
const mergedVariants = [
|
||||||
|
...(rel.originGood?.variants ?? []).map((variant) =>
|
||||||
|
toAnnotated(variant as Record<string, unknown>, primaryId, primaryName),
|
||||||
|
),
|
||||||
|
...(rel.mergedOriginGoods ?? []).flatMap((m) =>
|
||||||
|
(m.originGood.variants ?? []).map((variant) =>
|
||||||
|
toAnnotated(
|
||||||
|
variant,
|
||||||
|
m.originGood.id.toString(),
|
||||||
|
m.originGood.goodName,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
originDetail: detail ? { ...detail, syncedAt: detail.syncedAt.toISOString() } : null,
|
||||||
|
variants: mergedVariants,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedGoods {
|
||||||
|
items: GoodDto[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,58 +1,58 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsInt,
|
IsInt,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
Max,
|
Max,
|
||||||
Min,
|
Min,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class QueryGoodDto {
|
export class QueryGoodDto {
|
||||||
@ApiProperty({ required: false, default: 1 })
|
@ApiProperty({ required: false, default: 1 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
page: number = 1;
|
page: number = 1;
|
||||||
|
|
||||||
@ApiProperty({ required: false, default: 20 })
|
@ApiProperty({ required: false, default: 20 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
@Max(200)
|
@Max(200)
|
||||||
pageSize: number = 20;
|
pageSize: number = 20;
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
countryId?: number;
|
countryId?: number;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
required: false,
|
required: false,
|
||||||
description: 'Includes all descendants of this category recursively',
|
description: 'Includes all descendants of this category recursively',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
categoryId?: number;
|
categoryId?: number;
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
tagId?: number;
|
tagId?: number;
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
positionId?: number;
|
positionId?: number;
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
}
|
}
|
||||||
|
|||||||