chore: migrate to pnpm workspaces monorepo with Turborepo
- Restructure directories: apps/api, apps/admin, apps/website - Add root pnpm-workspace.yaml, turbo.json, .prettierrc, .gitignore - Rename packages to @inkreach/api, @inkreach/admin, @inkreach/website - Add shared packages: packages/tsconfig, packages/shared-types - Add pnpm.onlyBuiltDependencies for native builds - Update docs: README.md, structs.md - All three projects build successfully
This commit is contained in:
+45
@@ -0,0 +1,45 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist
|
||||||
|
.output
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.data
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# Nuxt
|
||||||
|
.output
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"tabWidth": 2,
|
||||||
|
"semi": true,
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
@@ -1,39 +1,24 @@
|
|||||||
# InkReach Product Center
|
# InkReach Product Center
|
||||||
|
|
||||||
InkReach 官方产品中心项目,包含三个协同工作的子项目:
|
pnpm workspaces + Turborepo monorepo,包含三个协同工作的子项目和共享包:
|
||||||
|
|
||||||
| 子项目 | 技术栈 | 端口 | 角色 |
|
| 子项目 | 包名 | 技术栈 | 端口 | 角色 |
|
||||||
|--------|--------|------|------|
|
|--------|------|--------|------|------|
|
||||||
| `inkreach-official-nestjs` | 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) |
|
||||||
| `inkreach-official-admin` | Vue 3 + Vite + Element Plus + Pinia | 5173 | 后台管理(登录 / 商品 / 品类 / 国家 / 标签 / 坑位 / 同步) |
|
| `apps/admin` | `@inkreach/admin` | Vue 3 + Vite + Element Plus + Pinia | 5173 | 后台管理(登录 / 商品 / 品类 / 国家 / 标签 / 坑位 / 同步) |
|
||||||
| `inkreach-official-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/shared-types` | 共享类型定义(PaginatedResult, BackendEnvelope, 实体接口) |
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 1. 启动顺序
|
### 1. 安装依赖
|
||||||
|
|
||||||
后端必须先于前两个子项目启动,否则前端代理会失败。
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Terminal 1:后端(需要 PostgreSQL)
|
pnpm install
|
||||||
cd inkreach-official-nestjs
|
|
||||||
npm install
|
|
||||||
npm run prisma:generate
|
|
||||||
npm run prisma:migrate
|
|
||||||
npm run start:dev
|
|
||||||
# → http://localhost:3001 · Swagger: http://localhost:3001/api/docs
|
|
||||||
|
|
||||||
# Terminal 2:官网
|
|
||||||
cd inkreach-official-website
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
# → http://localhost:3000
|
|
||||||
|
|
||||||
# Terminal 3:后台
|
|
||||||
cd inkreach-official-admin
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
# → http://localhost:5173
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 环境变量
|
### 2. 环境变量
|
||||||
@@ -44,7 +29,7 @@ npm run dev
|
|||||||
DATABASE_URL=postgresql://postgres:<password>@localhost:5432/inkreach-official
|
DATABASE_URL=postgresql://postgres:<password>@localhost:5432/inkreach-official
|
||||||
```
|
```
|
||||||
|
|
||||||
后端额外需要的变量(在 `inkreach-official-nestjs/.env` 中):
|
后端额外需要的变量(在 `apps/api/.env` 中):
|
||||||
|
|
||||||
```env
|
```env
|
||||||
JWT_SECRET=<your-secret>
|
JWT_SECRET=<your-secret>
|
||||||
@@ -53,17 +38,44 @@ SDS_API_KEY=<your-key>
|
|||||||
PORT=3001
|
PORT=3001
|
||||||
```
|
```
|
||||||
|
|
||||||
官网默认通过 `NUXT_PUBLIC_BACKEND_URL=http://localhost:3001` 指向后端,可写入 `inkreach-official-website/.env` 覆盖。
|
官网通过 `apps/website/.env` 的 `NUXT_PUBLIC_BACKEND_URL=http://localhost:3001` 指向后端。
|
||||||
|
|
||||||
后台无需额外环境变量;Vite 代理已把 `/api/*` 转给 `http://localhost:3001`。
|
后台无需额外环境变量;Vite 代理已把 `/api/*` 转给 `http://localhost:3001`。
|
||||||
|
|
||||||
|
### 3. 启动
|
||||||
|
|
||||||
|
启动顺序:先启动后端,再启动另两个。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1:后端(需要 PostgreSQL)
|
||||||
|
cd apps/api
|
||||||
|
pnpm prisma:generate
|
||||||
|
pnpm prisma:migrate
|
||||||
|
pnpm start:dev
|
||||||
|
# → http://localhost:3001 · Swagger: http://localhost:3001/api/docs
|
||||||
|
|
||||||
|
# Terminal 2:官网
|
||||||
|
pnpm --filter @inkreach/website dev
|
||||||
|
# → http://localhost:3000
|
||||||
|
|
||||||
|
# Terminal 3:后台
|
||||||
|
pnpm --filter @inkreach/admin dev
|
||||||
|
# → http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
或者使用 Turborepo 同时启动所有开发服务器:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev # turbo run dev
|
||||||
|
```
|
||||||
|
|
||||||
## 端口分配
|
## 端口分配
|
||||||
|
|
||||||
| 端口 | 项目 | 入口 |
|
| 端口 | 项目 | 入口 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 3001 | NestJS 后端 | `inkreach-official-nestjs/src/main.ts` |
|
| 3001 | NestJS 后端 | `apps/api/src/main.ts` |
|
||||||
| 5173 | Vue Admin | `inkreach-official-admin/vite.config.ts` |
|
| 5173 | Vue Admin | `apps/admin/vite.config.ts` |
|
||||||
| 3000 | Nuxt 官网 | `inkreach-official-website/nuxt.config.ts` |
|
| 3000 | Nuxt 官网 | `apps/website/nuxt.config.ts` |
|
||||||
|
|
||||||
## 数据流概览
|
## 数据流概览
|
||||||
|
|
||||||
@@ -81,23 +93,28 @@ PORT=3001
|
|||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 根目录(使用 pnpm workspace filter)
|
||||||
|
pnpm install # 安装所有依赖
|
||||||
|
pnpm dev # 启动所有 dev 服务器 (turbo)
|
||||||
|
pnpm build # 构建所有项目 (turbo)
|
||||||
|
pnpm format # 格式化所有文件 (prettier)
|
||||||
|
|
||||||
# 后端
|
# 后端
|
||||||
cd inkreach-official-nestjs
|
pnpm --filter @inkreach/api start:dev # 开发
|
||||||
npm run start:dev # 开发
|
pnpm --filter @inkreach/api build # 编译
|
||||||
npm run build # 编译
|
pnpm --filter @inkreach/api test # 单元测试
|
||||||
npm run test # 单元测试
|
pnpm --filter @inkreach/api prisma:generate # 生成 Prisma Client
|
||||||
npm run prisma:studio # 打开 Prisma Studio
|
pnpm --filter @inkreach/api prisma:migrate # 运行迁移
|
||||||
|
pnpm --filter @inkreach/api prisma:studio # 打开 Prisma Studio
|
||||||
|
|
||||||
# 官网
|
# 官网
|
||||||
cd inkreach-official-website
|
pnpm --filter @inkreach/website dev # 开发
|
||||||
npm run dev # 开发
|
pnpm --filter @inkreach/website build # 构建
|
||||||
npm run build # 构建
|
pnpm --filter @inkreach/website generate # 静态站点生成
|
||||||
npm run generate # 静态站点生成
|
|
||||||
|
|
||||||
# 后台
|
# 后台
|
||||||
cd inkreach-official-admin
|
pnpm --filter @inkreach/admin dev # 开发
|
||||||
npm run dev # 开发
|
pnpm --filter @inkreach/admin build # 构建
|
||||||
npm run build # 构建(含 vue-tsc 类型检查)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 文档
|
## 文档
|
||||||
@@ -105,5 +122,5 @@ npm run build # 构建(含 vue-tsc 类型检查)
|
|||||||
- 整体目录结构:[`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)
|
||||||
- 官网子项目结构:[`inkreach-official-website/docs/references/structs.md`](inkreach-official-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,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "inkreach-official-admin",
|
"name": "@inkreach/admin",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,33 @@
|
|||||||
|
import request from './request'
|
||||||
|
import type {
|
||||||
|
TagGroup,
|
||||||
|
CreateTagGroupRequest,
|
||||||
|
UpdateTagGroupRequest,
|
||||||
|
ReorderTagGroupsRequest,
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
|
export const tagGroupsApi = {
|
||||||
|
getTagGroupsList: () => {
|
||||||
|
return request.get<any, TagGroup[]>('/tag-groups')
|
||||||
|
},
|
||||||
|
|
||||||
|
getTagGroupById: (id: string) => {
|
||||||
|
return request.get<any, TagGroup>(`/tag-groups/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
createTagGroup: (data: CreateTagGroupRequest) => {
|
||||||
|
return request.post<any, TagGroup>('/tag-groups', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
updateTagGroup: (id: string, data: UpdateTagGroupRequest) => {
|
||||||
|
return request.patch<any, TagGroup>(`/tag-groups/${id}`, data)
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteTagGroup: (id: string) => {
|
||||||
|
return request.delete(`/tag-groups/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
reorderTagGroups: (data: ReorderTagGroupsRequest) => {
|
||||||
|
return request.patch('/tag-groups/sort', data)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
CreateTagRequest,
|
CreateTagRequest,
|
||||||
UpdateTagRequest,
|
UpdateTagRequest,
|
||||||
TagFilter,
|
TagFilter,
|
||||||
|
ReorderTagsRequest,
|
||||||
PaginatedResult,
|
PaginatedResult,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
@@ -32,4 +33,9 @@ export const tagsApi = {
|
|||||||
deleteTag: (id: string) => {
|
deleteTag: (id: string) => {
|
||||||
return request.delete(`/tags/${id}`)
|
return request.delete(`/tags/${id}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Reorder tags (and/or move across groups)
|
||||||
|
reorderTags: (data: ReorderTagsRequest) => {
|
||||||
|
return request.patch('/tags/sort', data)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
@@ -12,18 +12,14 @@ export {}
|
|||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
ElAside: typeof import('element-plus/es')['ElAside']
|
ElAside: typeof import('element-plus/es')['ElAside']
|
||||||
ElBadge: typeof import('element-plus/es')['ElBadge']
|
|
||||||
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']
|
||||||
ElCard: typeof import('element-plus/es')['ElCard']
|
ElCard: typeof import('element-plus/es')['ElCard']
|
||||||
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']
|
||||||
ElCol: typeof import('element-plus/es')['ElCol']
|
|
||||||
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']
|
|
||||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
|
||||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||||
@@ -40,11 +36,9 @@ declare module 'vue' {
|
|||||||
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']
|
||||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
|
||||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||||
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']
|
||||||
ElRow: typeof import('element-plus/es')['ElRow']
|
|
||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
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']
|
||||||
@@ -143,6 +143,9 @@ export interface Tag {
|
|||||||
tagColor?: string | null
|
tagColor?: string | null
|
||||||
tagFontColor?: string | null
|
tagFontColor?: string | null
|
||||||
timing?: string | null
|
timing?: string | null
|
||||||
|
tagGroupId?: string | null
|
||||||
|
sortOrder?: number
|
||||||
|
tagGroup?: TagGroup | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
@@ -152,6 +155,8 @@ export interface CreateTagRequest {
|
|||||||
tagColor?: string
|
tagColor?: string
|
||||||
tagFontColor?: string
|
tagFontColor?: string
|
||||||
timing?: string
|
timing?: string
|
||||||
|
tagGroupId?: number
|
||||||
|
sortOrder?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateTagRequest {
|
export interface UpdateTagRequest {
|
||||||
@@ -159,6 +164,42 @@ export interface UpdateTagRequest {
|
|||||||
tagColor?: string | null
|
tagColor?: string | null
|
||||||
tagFontColor?: string | null
|
tagFontColor?: string | null
|
||||||
timing?: string | null
|
timing?: string | null
|
||||||
|
tagGroupId?: number | null
|
||||||
|
sortOrder?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag Group types
|
||||||
|
export interface TagGroup {
|
||||||
|
id: string
|
||||||
|
groupName: string
|
||||||
|
groupIcon?: string | null
|
||||||
|
groupColor?: string | null
|
||||||
|
sortOrder: number
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
_count?: { tags: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTagGroupRequest {
|
||||||
|
groupName: string
|
||||||
|
groupIcon?: string
|
||||||
|
groupColor?: string
|
||||||
|
sortOrder?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTagGroupRequest {
|
||||||
|
groupName?: string
|
||||||
|
groupIcon?: string | null
|
||||||
|
groupColor?: string | null
|
||||||
|
sortOrder?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReorderTagGroupsRequest {
|
||||||
|
items: Array<{ id: number; sortOrder: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReorderTagsRequest {
|
||||||
|
items: Array<{ id: number; tagGroupId?: number | null; sortOrder: number }>
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position types
|
// Position types
|
||||||
@@ -206,7 +247,7 @@ export interface OriginGoodsTreeNode {
|
|||||||
sdsGoodId: string
|
sdsGoodId: string
|
||||||
configuredCount: number
|
configuredCount: number
|
||||||
configuredCountries: string[]
|
configuredCountries: string[]
|
||||||
configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null }[]
|
configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OriginGoodsTreeCategoryNode {
|
export interface OriginGoodsTreeCategoryNode {
|
||||||
+388
-8
@@ -6,13 +6,14 @@ import {
|
|||||||
FolderAdd, Aim,
|
FolderAdd, Aim,
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import type {
|
import type {
|
||||||
CategoryTree, Country, Tag, Good, Position,
|
CategoryTree, Country, Tag, TagGroup, Good, Position,
|
||||||
OriginGoodsTreeResponse,
|
OriginGoodsTreeResponse,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
import { goodsApi } from '@/api/goods'
|
import { goodsApi } from '@/api/goods'
|
||||||
import { countriesApi } from '@/api/countries'
|
import { countriesApi } from '@/api/countries'
|
||||||
import { categoriesApi } from '@/api/categories'
|
import { categoriesApi } from '@/api/categories'
|
||||||
import { tagsApi } from '@/api/tags'
|
import { tagsApi } from '@/api/tags'
|
||||||
|
import { tagGroupsApi } from '@/api/tag-groups'
|
||||||
import { positionsApi } from '@/api/positions'
|
import { positionsApi } from '@/api/positions'
|
||||||
import { originGoodsApi } from '@/api/origin-goods'
|
import { originGoodsApi } from '@/api/origin-goods'
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ const rightTreeData = ref<any[]>([])
|
|||||||
const allCategories = ref<CategoryTree[]>([])
|
const allCategories = ref<CategoryTree[]>([])
|
||||||
const allCountries = ref<Country[]>([])
|
const allCountries = ref<Country[]>([])
|
||||||
const allTags = ref<Tag[]>([])
|
const allTags = ref<Tag[]>([])
|
||||||
|
const allTagGroups = ref<TagGroup[]>([])
|
||||||
const allGoods = ref<Good[]>([])
|
const allGoods = ref<Good[]>([])
|
||||||
|
|
||||||
const searchKeyword = ref('')
|
const searchKeyword = ref('')
|
||||||
@@ -97,6 +99,7 @@ async function loadAll() {
|
|||||||
.filter((c: any) => !c.sdsCategoryId)
|
.filter((c: any) => !c.sdsCategoryId)
|
||||||
allCountries.value = Array.isArray(countries) ? countries : (countries.items ?? [])
|
allCountries.value = Array.isArray(countries) ? countries : (countries.items ?? [])
|
||||||
allTags.value = Array.isArray(tags) ? tags : (tags.items ?? [])
|
allTags.value = Array.isArray(tags) ? tags : (tags.items ?? [])
|
||||||
|
allTagGroups.value = await tagGroupsApi.getTagGroupsList()
|
||||||
allGoods.value = goodsRes?.items ?? []
|
allGoods.value = goodsRes?.items ?? []
|
||||||
buildRightTree(ogTree)
|
buildRightTree(ogTree)
|
||||||
} catch (e) { console.error('加载失败', e) }
|
} catch (e) { console.error('加载失败', e) }
|
||||||
@@ -600,6 +603,166 @@ async function handleTagEditDelete() {
|
|||||||
async function reloadTags() {
|
async function reloadTags() {
|
||||||
const res = await tagsApi.getTagsList({ page: 1, pageSize: 200 } as any) as any
|
const res = await tagsApi.getTagsList({ page: 1, pageSize: 200 } as any) as any
|
||||||
allTags.value = Array.isArray(res) ? res : (res.items ?? [])
|
allTags.value = Array.isArray(res) ? res : (res.items ?? [])
|
||||||
|
allTagGroups.value = await tagGroupsApi.getTagGroupsList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Group edit modal ───
|
||||||
|
const groupEditVisible = ref(false)
|
||||||
|
const groupEditLoading = ref(false)
|
||||||
|
const groupEditForm = ref<{ id: string; groupName: string }>({ id: '', groupName: '' })
|
||||||
|
|
||||||
|
function openGroupEdit(node: TreeNode): void {
|
||||||
|
// Don't allow editing the virtual "未分组" node
|
||||||
|
if (node.id === 'g-ungrouped') return
|
||||||
|
groupEditForm.value = { id: node.rawId!, groupName: node.label }
|
||||||
|
groupEditVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGroupSave(): Promise<void> {
|
||||||
|
const name = groupEditForm.value.groupName.trim()
|
||||||
|
if (!name) { ElMessage.warning('请输入分组名称'); return }
|
||||||
|
groupEditLoading.value = true
|
||||||
|
try {
|
||||||
|
await tagGroupsApi.updateTagGroup(groupEditForm.value.id, { groupName: name })
|
||||||
|
ElMessage.success('已保存')
|
||||||
|
groupEditVisible.value = false
|
||||||
|
await reloadTags()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || '保存失败')
|
||||||
|
} finally {
|
||||||
|
groupEditLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGroupDelete(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`删除分组「${groupEditForm.value.groupName}」后,组内标签将归为「未分组」。确认删除?`,
|
||||||
|
'确认删除',
|
||||||
|
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
|
||||||
|
)
|
||||||
|
} catch { return }
|
||||||
|
groupEditLoading.value = true
|
||||||
|
try {
|
||||||
|
await tagGroupsApi.deleteTagGroup(groupEditForm.value.id)
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
groupEditVisible.value = false
|
||||||
|
await reloadTags()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || '删除失败')
|
||||||
|
} finally {
|
||||||
|
groupEditLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tag tree (filter dropdown) ───
|
||||||
|
interface TreeNode {
|
||||||
|
id: string
|
||||||
|
rawId: string | null
|
||||||
|
type: 'group' | 'tag'
|
||||||
|
label: string
|
||||||
|
sortOrder?: number
|
||||||
|
disabled?: boolean
|
||||||
|
children?: TreeNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagPopoverVisible = ref(false)
|
||||||
|
const hoveredNodeId = ref<string | null>(null)
|
||||||
|
|
||||||
|
const MAX_VISIBLE_TAGS = 1
|
||||||
|
|
||||||
|
const displayedSelectedTagIds = computed(() =>
|
||||||
|
selectedTagIds.value.slice(0, MAX_VISIBLE_TAGS),
|
||||||
|
)
|
||||||
|
const hiddenSelectedCount = computed(() =>
|
||||||
|
Math.max(0, selectedTagIds.value.length - MAX_VISIBLE_TAGS),
|
||||||
|
)
|
||||||
|
|
||||||
|
function getTagName(id: string): string {
|
||||||
|
return allTags.value.find((t) => t.id === id)?.tagName ?? id
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSelectedTag(id: string): void {
|
||||||
|
const idx = selectedTagIds.value.indexOf(id)
|
||||||
|
if (idx >= 0) selectedTagIds.value.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagTreeData = computed<TreeNode[]>(() => {
|
||||||
|
const groupNodes: TreeNode[] = allTagGroups.value
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
.map((g) => ({
|
||||||
|
id: `g-${g.id}`,
|
||||||
|
rawId: g.id,
|
||||||
|
type: 'group',
|
||||||
|
label: g.groupName,
|
||||||
|
sortOrder: g.sortOrder,
|
||||||
|
disabled: true,
|
||||||
|
children: allTags.value
|
||||||
|
.filter((t) => t.tagGroupId === g.id)
|
||||||
|
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||||
|
.map((t) => ({
|
||||||
|
id: `t-${t.id}`,
|
||||||
|
rawId: t.id,
|
||||||
|
type: 'tag',
|
||||||
|
label: t.tagName,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const ungrouped = allTags.value
|
||||||
|
.filter((t) => !t.tagGroupId)
|
||||||
|
.sort((a, b) => a.tagName.localeCompare(b.tagName))
|
||||||
|
.map((t) => ({
|
||||||
|
id: `t-${t.id}`,
|
||||||
|
rawId: t.id,
|
||||||
|
type: 'tag',
|
||||||
|
label: t.tagName,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (ungrouped.length > 0) {
|
||||||
|
groupNodes.push({
|
||||||
|
id: 'g-ungrouped',
|
||||||
|
rawId: null,
|
||||||
|
type: 'group',
|
||||||
|
label: '未分组',
|
||||||
|
disabled: true,
|
||||||
|
children: ungrouped,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return groupNodes
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleTagInSelection(tagId: string): void {
|
||||||
|
const idx = selectedTagIds.value.indexOf(tagId)
|
||||||
|
if (idx >= 0) selectedTagIds.value.splice(idx, 1)
|
||||||
|
else selectedTagIds.value.push(tagId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTreeNodeClick(data: TreeNode): void {
|
||||||
|
if (data.type === 'group' && !data.disabled) {
|
||||||
|
// Click group = toggle all its tags
|
||||||
|
const tagIds = (data.children ?? []).map((c) => c.rawId!).filter(Boolean)
|
||||||
|
if (tagIds.length === 0) return
|
||||||
|
const allSelected = tagIds.every((id) => selectedTagIds.value.includes(id))
|
||||||
|
if (allSelected) {
|
||||||
|
// Deselect all
|
||||||
|
tagIds.forEach((id) => {
|
||||||
|
const idx = selectedTagIds.value.indexOf(id)
|
||||||
|
if (idx >= 0) selectedTagIds.value.splice(idx, 1)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Select all (add missing ones)
|
||||||
|
tagIds.forEach((id) => {
|
||||||
|
if (!selectedTagIds.value.includes(id)) selectedTagIds.value.push(id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Tag click is handled by its checkbox
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTagEditFromFilterById(id: string): void {
|
||||||
|
const t = allTags.value.find((tag) => tag.id === id)
|
||||||
|
if (t) openTagEditFromFilter(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Country edit (from filter dropdown) ───
|
// ─── Country edit (from filter dropdown) ───
|
||||||
@@ -658,14 +821,84 @@ onMounted(() => loadAll())
|
|||||||
</el-option>
|
</el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
|
|
||||||
<el-select v-model="selectedTagIds" multiple collapse-tags collapse-tags-tooltip size="small" placeholder="标签筛选" style="width: 150px" popper-class="filter-popper">
|
<el-popover
|
||||||
<el-option v-for="t in allTags" :key="t.id" :label="t.tagName" :value="t.id">
|
v-model:visible="tagPopoverVisible"
|
||||||
<div class="filter-opt">
|
placement="bottom-start"
|
||||||
<span class="filter-opt-tag" :style="{ '--c': t.tagColor || '#ccc' }">{{ t.tagName }}</span>
|
:width="280"
|
||||||
<el-button text size="small" :icon="Edit" @click.stop="openTagEditFromFilter(t)" />
|
trigger="click"
|
||||||
|
popper-class="tag-filter-popper"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<div
|
||||||
|
class="tag-select-trigger"
|
||||||
|
:class="{ 'is-filled': selectedTagIds.length > 0 }"
|
||||||
|
>
|
||||||
|
<template v-if="selectedTagIds.length === 0">
|
||||||
|
<span class="placeholder">标签筛选</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<el-tag
|
||||||
|
v-for="id in displayedSelectedTagIds"
|
||||||
|
:key="id"
|
||||||
|
size="small"
|
||||||
|
closable
|
||||||
|
type="info"
|
||||||
|
@close.stop="removeSelectedTag(id)"
|
||||||
|
>
|
||||||
|
{{ getTagName(id) }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-if="hiddenSelectedCount > 0" class="more-tag">+{{ hiddenSelectedCount }}</span>
|
||||||
|
</template>
|
||||||
|
<i class="fa-solid fa-chevron-down arrow"></i>
|
||||||
</div>
|
</div>
|
||||||
</el-option>
|
</template>
|
||||||
</el-select>
|
|
||||||
|
<div class="tag-tree-panel">
|
||||||
|
<el-tree
|
||||||
|
:data="tagTreeData"
|
||||||
|
node-key="id"
|
||||||
|
default-expand-all
|
||||||
|
:props="{ label: 'label', children: 'children' }"
|
||||||
|
@node-click="onTreeNodeClick"
|
||||||
|
>
|
||||||
|
<template #default="{ data }">
|
||||||
|
<div
|
||||||
|
class="tree-row"
|
||||||
|
:class="{
|
||||||
|
'is-group': data.type === 'group',
|
||||||
|
'is-tag': data.type === 'tag',
|
||||||
|
'is-disabled': data.disabled,
|
||||||
|
'is-checked': data.type === 'tag' && selectedTagIds.includes(data.rawId),
|
||||||
|
}"
|
||||||
|
@mouseenter="hoveredNodeId = data.id"
|
||||||
|
@mouseleave="hoveredNodeId = null"
|
||||||
|
>
|
||||||
|
<template v-if="data.type === 'group'">
|
||||||
|
<i class="fa-solid fa-folder node-icon" />
|
||||||
|
<span class="node-label">
|
||||||
|
{{ data.label }}
|
||||||
|
<span v-if="data.children && data.children.length" class="node-count">{{ data.children.length }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-show="hoveredNodeId === data.id" class="node-actions">
|
||||||
|
<el-button text size="small" :icon="Edit" title="编辑分组" @click.stop="openGroupEdit(data)" />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<el-checkbox
|
||||||
|
:model-value="selectedTagIds.includes(data.rawId)"
|
||||||
|
@change="toggleTagInSelection(data.rawId)"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
<span class="node-label">{{ data.label }}</span>
|
||||||
|
<span v-show="hoveredNodeId === data.id" class="node-actions">
|
||||||
|
<el-button text size="small" :icon="Edit" title="编辑标签" @click.stop="openTagEditFromFilterById(data.rawId)" />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-tree>
|
||||||
|
</div>
|
||||||
|
</el-popover>
|
||||||
|
|
||||||
<div class="gv-filter-spacer" />
|
<div class="gv-filter-spacer" />
|
||||||
<el-radio-group v-model="mode" size="small" @change="onModeChange">
|
<el-radio-group v-model="mode" size="small" @change="onModeChange">
|
||||||
@@ -954,6 +1187,25 @@ onMounted(() => loadAll())
|
|||||||
<el-button type="primary" :loading="tagEditLoading" @click="handleTagEditSubmit">保存</el-button>
|
<el-button type="primary" :loading="tagEditLoading" @click="handleTagEditSubmit">保存</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- Group Edit Modal -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="groupEditVisible"
|
||||||
|
:title="`编辑分组「${groupEditForm.groupName}」`"
|
||||||
|
width="420px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<el-form label-width="80px">
|
||||||
|
<el-form-item label="分组名称">
|
||||||
|
<el-input v-model="groupEditForm.groupName" placeholder="请输入分组名称" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button type="danger" :loading="groupEditLoading" @click="handleGroupDelete">删除</el-button>
|
||||||
|
<el-button @click="groupEditVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="groupEditLoading" @click="handleGroupSave">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -1166,4 +1418,132 @@ onMounted(() => loadAll())
|
|||||||
content: ''; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
|
content: ''; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
|
||||||
width: 6px; height: 6px; border-radius: 50%; background: var(--c, #ccc);
|
width: 6px; height: 6px; border-radius: 50%; background: var(--c, #ccc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Tag filter popover ─── */
|
||||||
|
|
||||||
|
/* Custom trigger that looks like el-select */
|
||||||
|
.tag-select-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
width: 180px;
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid #dcdfe6;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #606266;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.tag-select-trigger:hover {
|
||||||
|
border-color: #c0c4cc;
|
||||||
|
}
|
||||||
|
.tag-select-trigger .placeholder {
|
||||||
|
color: #a8abb2;
|
||||||
|
}
|
||||||
|
.tag-select-trigger .arrow {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #c0c4cc;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.tag-select-trigger.is-filled {
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
.tag-select-trigger.is-filled .arrow {
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
.tag-select-trigger .more-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 6px;
|
||||||
|
background: #f4f4f5;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Popover content */
|
||||||
|
.tag-filter-popper {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
.tag-filter-popper .tag-tree-panel {
|
||||||
|
max-height: 380px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.tag-filter-popper .el-tree {
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tree row layout */
|
||||||
|
.tree-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.tree-row.is-group {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
.tree-row.is-group:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
.tree-row.is-tag:hover {
|
||||||
|
background: #f5f7fa;
|
||||||
|
}
|
||||||
|
.tree-row.is-tag.is-checked {
|
||||||
|
background: #fff2e8;
|
||||||
|
color: #ff6a00;
|
||||||
|
}
|
||||||
|
.node-icon {
|
||||||
|
color: #f59e0b;
|
||||||
|
font-size: 12px;
|
||||||
|
width: 14px;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.node-label {
|
||||||
|
flex: 1;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.node-count {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 4px;
|
||||||
|
padding: 0 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #909399;
|
||||||
|
background: #e9e9eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.node-edit-input {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
border: 1px solid #ff6a00;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
outline: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.node-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.node-actions :deep(.el-button) {
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
+33
-3
@@ -4,15 +4,18 @@ 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,
|
||||||
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'
|
||||||
|
|
||||||
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 filter = reactive<Required<TagFilter>>({
|
const filter = reactive<Required<TagFilter>>({
|
||||||
tagName: '',
|
tagName: '',
|
||||||
@@ -33,6 +36,10 @@ async function fetchList() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadGroups() {
|
||||||
|
allGroups.value = await tagGroupsApi.getTagGroupsList()
|
||||||
|
}
|
||||||
|
|
||||||
function handleSearch() {
|
function handleSearch() {
|
||||||
filter.page = 1
|
filter.page = 1
|
||||||
fetchList()
|
fetchList()
|
||||||
@@ -49,11 +56,13 @@ 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 }>({
|
const dialogForm = reactive<CreateTagRequest & { id?: string; tagGroupId?: number | null }>({
|
||||||
|
id: undefined,
|
||||||
tagName: '',
|
tagName: '',
|
||||||
tagColor: '#ff6800',
|
tagColor: '#ff6800',
|
||||||
tagFontColor: '#ffffff',
|
tagFontColor: '#ffffff',
|
||||||
timing: '',
|
timing: '',
|
||||||
|
tagGroupId: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const dialogRules = {
|
const dialogRules = {
|
||||||
@@ -62,7 +71,7 @@ const dialogRules = {
|
|||||||
|
|
||||||
function openAddDialog() {
|
function openAddDialog() {
|
||||||
dialogMode.value = 'create'
|
dialogMode.value = 'create'
|
||||||
Object.assign(dialogForm, { id: undefined, tagName: '', tagColor: '#ff6800', tagFontColor: '#ffffff', timing: '' })
|
Object.assign(dialogForm, { id: undefined, tagName: '', tagColor: '#ff6800', tagFontColor: '#ffffff', timing: '', tagGroupId: null })
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +83,7 @@ function openEditDialog(t: Tag) {
|
|||||||
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,
|
||||||
})
|
})
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
@@ -89,6 +99,7 @@ async function handleSubmit() {
|
|||||||
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,
|
||||||
}
|
}
|
||||||
if (dialogMode.value === 'create') {
|
if (dialogMode.value === 'create') {
|
||||||
await tagsApi.createTag(payload)
|
await tagsApi.createTag(payload)
|
||||||
@@ -120,7 +131,10 @@ async function handleDelete(t: Tag) {
|
|||||||
fetchList()
|
fetchList()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(fetchList)
|
onMounted(() => {
|
||||||
|
fetchList()
|
||||||
|
loadGroups()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -183,6 +197,12 @@ onMounted(fetchList)
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="所属分组" min-width="140">
|
||||||
|
<template #default="{ row }: { row: Tag }">
|
||||||
|
<span v-if="row.tagGroup">{{ row.tagGroup.groupName }}</span>
|
||||||
|
<span v-else style="color: #999;">未分组</span>
|
||||||
|
</template>
|
||||||
|
</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 }: { row: Tag }">
|
<template #default="{ row }: { row: Tag }">
|
||||||
@@ -233,6 +253,16 @@ onMounted(fetchList)
|
|||||||
<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-select v-model="dialogForm.tagGroupId" clearable placeholder="未分组" style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="g in allGroups"
|
||||||
|
:key="g.id"
|
||||||
|
:label="g.groupName"
|
||||||
|
:value="Number(g.id)"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</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>
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"types": ["vite/client", "node"],
|
"types": ["vite/client", "node"],
|
||||||
|
|
||||||
/* Path aliases */
|
/* Path aliases */
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"]
|
"@/*": ["src/*"]
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "inkreach-official-nestjs",
|
"name": "@inkreach/api",
|
||||||
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "NestJS backend for InkReach Product Center",
|
"description": "NestJS backend for InkReach Product Center",
|
||||||
"main": "dist/main.js",
|
"main": "dist/main.js",
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "tags" ADD COLUMN "tag_group_id" BIGINT,
|
||||||
|
ADD COLUMN "sort_order" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "tag_groups" (
|
||||||
|
"tag_group_id" BIGSERIAL NOT NULL,
|
||||||
|
"group_name" TEXT NOT NULL,
|
||||||
|
"group_icon" TEXT,
|
||||||
|
"group_color" TEXT,
|
||||||
|
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"created_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")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "tag_groups_group_name_key" ON "tag_groups"("group_name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "tags_tag_group_id_idx" ON "tags"("tag_group_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "tags_tag_group_id_sort_order_idx" ON "tags"("tag_group_id", "sort_order");
|
||||||
|
|
||||||
|
-- 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;
|
||||||
|
|
||||||
|
-- Seed: insert 3 default tag groups
|
||||||
|
INSERT INTO "tag_groups" ("group_name", "sort_order", "created_at", "updated_at") VALUES
|
||||||
|
('物流渠道', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||||
|
('印刷位置', 2, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||||
|
('印刷工艺', 3, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
|
||||||
|
|
||||||
|
-- Seed: assign existing tags to groups
|
||||||
|
UPDATE "tags" SET "tag_group_id" = (SELECT "tag_group_id" FROM "tag_groups" WHERE "group_name" = '物流渠道')
|
||||||
|
WHERE "tag_name" IN ('包邮', '不包邮');
|
||||||
|
UPDATE "tags" SET "tag_group_id" = (SELECT "tag_group_id" FROM "tag_groups" WHERE "group_name" = '印刷位置')
|
||||||
|
WHERE "tag_name" IN ('单面印', '双面印');
|
||||||
|
UPDATE "tags" SET "tag_group_id" = (SELECT "tag_group_id" FROM "tag_groups" WHERE "group_name" = '印刷工艺')
|
||||||
|
WHERE "tag_name" IN ('烫画', '直喷', '不打印');
|
||||||
|
|
||||||
|
-- Seed: assign sortOrder within each group by tag_id
|
||||||
|
WITH ordered AS (
|
||||||
|
SELECT "tag_id",
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY "tag_group_id" ORDER BY "tag_id") AS rn
|
||||||
|
FROM "tags"
|
||||||
|
WHERE "tag_group_id" IS NOT NULL
|
||||||
|
)
|
||||||
|
UPDATE "tags" SET "sort_order" = ordered.rn
|
||||||
|
FROM ordered
|
||||||
|
WHERE "tags"."tag_id" = ordered."tag_id";
|
||||||
@@ -64,6 +64,21 @@ model Category {
|
|||||||
@@map("categories")
|
@@map("categories")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Tag Groups ----------
|
||||||
|
model TagGroup {
|
||||||
|
id BigInt @id @default(autoincrement()) @map("tag_group_id")
|
||||||
|
groupName String @unique @map("group_name")
|
||||||
|
groupIcon String? @map("group_icon")
|
||||||
|
groupColor String? @map("group_color")
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
|
tags Tag[]
|
||||||
|
|
||||||
|
@@map("tag_groups")
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Tags ----------
|
// ---------- Tags ----------
|
||||||
model Tag {
|
model Tag {
|
||||||
id BigInt @id @default(autoincrement()) @map("tag_id")
|
id BigInt @id @default(autoincrement()) @map("tag_id")
|
||||||
@@ -71,12 +86,17 @@ model Tag {
|
|||||||
tagColor String? @map("tag_color")
|
tagColor String? @map("tag_color")
|
||||||
tagFontColor String? @map("tag_font_color")
|
tagFontColor String? @map("tag_font_color")
|
||||||
timing String?
|
timing String?
|
||||||
|
tagGroupId BigInt? @map("tag_group_id")
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
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[]
|
||||||
goodTags GoodTag[]
|
goodTags GoodTag[]
|
||||||
|
tagGroup TagGroup? @relation(fields: [tagGroupId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@index([tagGroupId])
|
||||||
|
@@index([tagGroupId, sortOrder])
|
||||||
@@map("tags")
|
@@map("tags")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5,6 +5,7 @@ import { AuthModule } from './auth/auth.module';
|
|||||||
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';
|
||||||
|
import { TagGroupsModule } from './tag-groups/tag-groups.module';
|
||||||
import { PositionsModule } from './positions/positions.module';
|
import { PositionsModule } from './positions/positions.module';
|
||||||
import { OriginGoodsModule } from './origin-goods/origin-goods.module';
|
import { OriginGoodsModule } from './origin-goods/origin-goods.module';
|
||||||
import { GoodsModule } from './goods/goods.module';
|
import { GoodsModule } from './goods/goods.module';
|
||||||
@@ -21,6 +22,7 @@ import { PublicModule } from './public/public.module';
|
|||||||
CountriesModule,
|
CountriesModule,
|
||||||
CategoriesModule,
|
CategoriesModule,
|
||||||
TagsModule,
|
TagsModule,
|
||||||
|
TagGroupsModule,
|
||||||
PositionsModule,
|
PositionsModule,
|
||||||
OriginGoodsModule,
|
OriginGoodsModule,
|
||||||
GoodsModule,
|
GoodsModule,
|
||||||
+20
-4
@@ -31,7 +31,7 @@ export interface OriginGoodsTreeNode {
|
|||||||
sdsGoodId: string;
|
sdsGoodId: string;
|
||||||
configuredCount: number;
|
configuredCount: number;
|
||||||
configuredCountries: string[];
|
configuredCountries: string[];
|
||||||
configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null }[];
|
configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A category node in the hierarchical tree, with origin goods as leaves. */
|
/** A category node in the hierarchical tree, with origin goods as leaves. */
|
||||||
@@ -125,7 +125,16 @@ export class OriginGoodsService {
|
|||||||
this.prisma.goodTag.findMany({
|
this.prisma.goodTag.findMany({
|
||||||
select: {
|
select: {
|
||||||
good: { select: { originGoodId: true } },
|
good: { select: { originGoodId: true } },
|
||||||
tag: { select: { tagName: true, tagColor: true, tagFontColor: true } },
|
tag: {
|
||||||
|
select: {
|
||||||
|
tagName: true,
|
||||||
|
tagColor: true,
|
||||||
|
tagFontColor: true,
|
||||||
|
tagGroupId: true,
|
||||||
|
sortOrder: true,
|
||||||
|
tagGroup: { select: { groupName: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
@@ -145,10 +154,17 @@ export class OriginGoodsService {
|
|||||||
else countryMap.set(key, [name]);
|
else countryMap.set(key, [name]);
|
||||||
});
|
});
|
||||||
|
|
||||||
const tagMap = new Map<string, { tagName: string; tagColor: string | null; tagFontColor: string | null }[]>();
|
const tagMap = new Map<string, { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]>();
|
||||||
goodsWithTags.forEach((gt) => {
|
goodsWithTags.forEach((gt) => {
|
||||||
const key = gt.good.originGoodId.toString();
|
const key = gt.good.originGoodId.toString();
|
||||||
const tagInfo = { tagName: gt.tag.tagName, tagColor: gt.tag.tagColor, tagFontColor: gt.tag.tagFontColor };
|
const tagInfo = {
|
||||||
|
tagName: gt.tag.tagName,
|
||||||
|
tagColor: gt.tag.tagColor,
|
||||||
|
tagFontColor: gt.tag.tagFontColor,
|
||||||
|
tagGroupId: gt.tag.tagGroupId?.toString() ?? null,
|
||||||
|
tagGroupName: gt.tag.tagGroup?.groupName ?? null,
|
||||||
|
sortOrder: gt.tag.sortOrder,
|
||||||
|
};
|
||||||
const arr = tagMap.get(key);
|
const arr = tagMap.get(key);
|
||||||
if (arr) {
|
if (arr) {
|
||||||
if (!arr.some((t) => t.tagName === tagInfo.tagName)) arr.push(tagInfo);
|
if (!arr.some((t) => t.tagName === tagInfo.tagName)) arr.push(tagInfo);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user