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 官方产品中心项目,包含三个协同工作的子项目:
|
||||
pnpm workspaces + Turborepo monorepo,包含三个协同工作的子项目和共享包:
|
||||
|
||||
| 子项目 | 技术栈 | 端口 | 角色 |
|
||||
|--------|--------|------|------|
|
||||
| `inkreach-official-nestjs` | NestJS 10 + Prisma 5 + PostgreSQL | 3001 | 后端 API(鉴权 / CRUD / SDS 同步 / 公开 API / Swagger) |
|
||||
| `inkreach-official-admin` | Vue 3 + Vite + Element Plus + Pinia | 5173 | 后台管理(登录 / 商品 / 品类 / 国家 / 标签 / 坑位 / 同步) |
|
||||
| `inkreach-official-website` | Nuxt 4 + Vue 3 + Tailwind v4 | 3000 | 官方展示站 + 产品中心页 |
|
||||
| 子项目 | 包名 | 技术栈 | 端口 | 角色 |
|
||||
|--------|------|--------|------|------|
|
||||
| `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/website` | `@inkreach/website` | Nuxt 4 + Vue 3 + Tailwind v4 | 3000 | 官方展示站 + 产品中心页 |
|
||||
|
||||
| 共享包 | 说明 |
|
||||
|--------|------|
|
||||
| `packages/tsconfig` | 共享 TypeScript 配置(base / api / vue) |
|
||||
| `packages/shared-types` | 共享类型定义(PaginatedResult, BackendEnvelope, 实体接口) |
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 启动顺序
|
||||
|
||||
后端必须先于前两个子项目启动,否则前端代理会失败。
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
# Terminal 1:后端(需要 PostgreSQL)
|
||||
cd inkreach-official-nestjs
|
||||
npm install
|
||||
npm run prisma:generate
|
||||
npm run prisma:migrate
|
||||
npm run start:dev
|
||||
# → http://localhost:3001 · Swagger: http://localhost:3001/api/docs
|
||||
|
||||
# Terminal 2:官网
|
||||
cd inkreach-official-website
|
||||
npm install
|
||||
npm run dev
|
||||
# → http://localhost:3000
|
||||
|
||||
# Terminal 3:后台
|
||||
cd inkreach-official-admin
|
||||
npm install
|
||||
npm run dev
|
||||
# → http://localhost:5173
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 2. 环境变量
|
||||
@@ -44,7 +29,7 @@ npm run dev
|
||||
DATABASE_URL=postgresql://postgres:<password>@localhost:5432/inkreach-official
|
||||
```
|
||||
|
||||
后端额外需要的变量(在 `inkreach-official-nestjs/.env` 中):
|
||||
后端额外需要的变量(在 `apps/api/.env` 中):
|
||||
|
||||
```env
|
||||
JWT_SECRET=<your-secret>
|
||||
@@ -53,17 +38,44 @@ SDS_API_KEY=<your-key>
|
||||
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`。
|
||||
|
||||
### 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` |
|
||||
| 5173 | Vue Admin | `inkreach-official-admin/vite.config.ts` |
|
||||
| 3000 | Nuxt 官网 | `inkreach-official-website/nuxt.config.ts` |
|
||||
| 3001 | NestJS 后端 | `apps/api/src/main.ts` |
|
||||
| 5173 | Vue Admin | `apps/admin/vite.config.ts` |
|
||||
| 3000 | Nuxt 官网 | `apps/website/nuxt.config.ts` |
|
||||
|
||||
## 数据流概览
|
||||
|
||||
@@ -81,23 +93,28 @@ PORT=3001
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 根目录(使用 pnpm workspace filter)
|
||||
pnpm install # 安装所有依赖
|
||||
pnpm dev # 启动所有 dev 服务器 (turbo)
|
||||
pnpm build # 构建所有项目 (turbo)
|
||||
pnpm format # 格式化所有文件 (prettier)
|
||||
|
||||
# 后端
|
||||
cd inkreach-official-nestjs
|
||||
npm run start:dev # 开发
|
||||
npm run build # 编译
|
||||
npm run test # 单元测试
|
||||
npm run prisma:studio # 打开 Prisma Studio
|
||||
pnpm --filter @inkreach/api start:dev # 开发
|
||||
pnpm --filter @inkreach/api build # 编译
|
||||
pnpm --filter @inkreach/api test # 单元测试
|
||||
pnpm --filter @inkreach/api prisma:generate # 生成 Prisma Client
|
||||
pnpm --filter @inkreach/api prisma:migrate # 运行迁移
|
||||
pnpm --filter @inkreach/api prisma:studio # 打开 Prisma Studio
|
||||
|
||||
# 官网
|
||||
cd inkreach-official-website
|
||||
npm run dev # 开发
|
||||
npm run build # 构建
|
||||
npm run generate # 静态站点生成
|
||||
pnpm --filter @inkreach/website dev # 开发
|
||||
pnpm --filter @inkreach/website build # 构建
|
||||
pnpm --filter @inkreach/website generate # 静态站点生成
|
||||
|
||||
# 后台
|
||||
cd inkreach-official-admin
|
||||
npm run dev # 开发
|
||||
npm run build # 构建(含 vue-tsc 类型检查)
|
||||
pnpm --filter @inkreach/admin dev # 开发
|
||||
pnpm --filter @inkreach/admin build # 构建
|
||||
```
|
||||
|
||||
## 文档
|
||||
@@ -105,5 +122,5 @@ npm run build # 构建(含 vue-tsc 类型检查)
|
||||
- 整体目录结构:[`docs/references/structs.md`](docs/references/structs.md)
|
||||
- 数据库表设计:[`docs/dev/database-table-design.md`](docs/dev/database-table-design.md)
|
||||
- 产品中心 PRD:[`docs/dev/product-center-prd.md`](docs/dev/product-center-prd.md)
|
||||
- 官网子项目结构:[`inkreach-official-website/docs/references/structs.md`](inkreach-official-website/docs/references/structs.md)
|
||||
- 官网子项目结构:[`apps/website/docs/references/structs.md`](apps/website/docs/references/structs.md)
|
||||
- 开发规范:[`AGENTS.md`](AGENTS.md)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "inkreach-official-admin",
|
||||
"name": "@inkreach/admin",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"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,
|
||||
UpdateTagRequest,
|
||||
TagFilter,
|
||||
ReorderTagsRequest,
|
||||
PaginatedResult,
|
||||
} from '@/types'
|
||||
|
||||
@@ -32,4 +33,9 @@ export const tagsApi = {
|
||||
deleteTag: (id: string) => {
|
||||
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' {
|
||||
export interface GlobalComponents {
|
||||
ElAside: typeof import('element-plus/es')['ElAside']
|
||||
ElBadge: typeof import('element-plus/es')['ElBadge']
|
||||
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCascader: typeof import('element-plus/es')['ElCascader']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||
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']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
@@ -40,11 +36,9 @@ declare module 'vue' {
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
@@ -143,6 +143,9 @@ export interface Tag {
|
||||
tagColor?: string | null
|
||||
tagFontColor?: string | null
|
||||
timing?: string | null
|
||||
tagGroupId?: string | null
|
||||
sortOrder?: number
|
||||
tagGroup?: TagGroup | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
@@ -152,6 +155,8 @@ export interface CreateTagRequest {
|
||||
tagColor?: string
|
||||
tagFontColor?: string
|
||||
timing?: string
|
||||
tagGroupId?: number
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export interface UpdateTagRequest {
|
||||
@@ -159,6 +164,42 @@ export interface UpdateTagRequest {
|
||||
tagColor?: string | null
|
||||
tagFontColor?: 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
|
||||
@@ -206,7 +247,7 @@ export interface OriginGoodsTreeNode {
|
||||
sdsGoodId: string
|
||||
configuredCount: number
|
||||
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 {
|
||||
+388
-8
@@ -6,13 +6,14 @@ import {
|
||||
FolderAdd, Aim,
|
||||
} from '@element-plus/icons-vue'
|
||||
import type {
|
||||
CategoryTree, Country, Tag, Good, Position,
|
||||
CategoryTree, Country, Tag, TagGroup, Good, Position,
|
||||
OriginGoodsTreeResponse,
|
||||
} from '@/types'
|
||||
import { goodsApi } from '@/api/goods'
|
||||
import { countriesApi } from '@/api/countries'
|
||||
import { categoriesApi } from '@/api/categories'
|
||||
import { tagsApi } from '@/api/tags'
|
||||
import { tagGroupsApi } from '@/api/tag-groups'
|
||||
import { positionsApi } from '@/api/positions'
|
||||
import { originGoodsApi } from '@/api/origin-goods'
|
||||
|
||||
@@ -25,6 +26,7 @@ const rightTreeData = ref<any[]>([])
|
||||
const allCategories = ref<CategoryTree[]>([])
|
||||
const allCountries = ref<Country[]>([])
|
||||
const allTags = ref<Tag[]>([])
|
||||
const allTagGroups = ref<TagGroup[]>([])
|
||||
const allGoods = ref<Good[]>([])
|
||||
|
||||
const searchKeyword = ref('')
|
||||
@@ -97,6 +99,7 @@ async function loadAll() {
|
||||
.filter((c: any) => !c.sdsCategoryId)
|
||||
allCountries.value = Array.isArray(countries) ? countries : (countries.items ?? [])
|
||||
allTags.value = Array.isArray(tags) ? tags : (tags.items ?? [])
|
||||
allTagGroups.value = await tagGroupsApi.getTagGroupsList()
|
||||
allGoods.value = goodsRes?.items ?? []
|
||||
buildRightTree(ogTree)
|
||||
} catch (e) { console.error('加载失败', e) }
|
||||
@@ -600,6 +603,166 @@ async function handleTagEditDelete() {
|
||||
async function reloadTags() {
|
||||
const res = await tagsApi.getTagsList({ page: 1, pageSize: 200 } as any) as any
|
||||
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) ───
|
||||
@@ -658,14 +821,84 @@ onMounted(() => loadAll())
|
||||
</el-option>
|
||||
</el-select>
|
||||
|
||||
<el-select v-model="selectedTagIds" multiple collapse-tags collapse-tags-tooltip size="small" placeholder="标签筛选" style="width: 150px" popper-class="filter-popper">
|
||||
<el-option v-for="t in allTags" :key="t.id" :label="t.tagName" :value="t.id">
|
||||
<div class="filter-opt">
|
||||
<span class="filter-opt-tag" :style="{ '--c': t.tagColor || '#ccc' }">{{ t.tagName }}</span>
|
||||
<el-button text size="small" :icon="Edit" @click.stop="openTagEditFromFilter(t)" />
|
||||
<el-popover
|
||||
v-model:visible="tagPopoverVisible"
|
||||
placement="bottom-start"
|
||||
:width="280"
|
||||
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>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
<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" />
|
||||
<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>
|
||||
</template>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -1166,4 +1418,132 @@ onMounted(() => loadAll())
|
||||
content: ''; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
|
||||
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>
|
||||
+33
-3
@@ -4,15 +4,18 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import type {
|
||||
Tag,
|
||||
TagGroup,
|
||||
CreateTagRequest,
|
||||
UpdateTagRequest,
|
||||
TagFilter,
|
||||
} from '@/types'
|
||||
import { tagsApi } from '@/api/tags'
|
||||
import { tagGroupsApi } from '@/api/tag-groups'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<Tag[]>([])
|
||||
const total = ref(0)
|
||||
const allGroups = ref<TagGroup[]>([])
|
||||
|
||||
const filter = reactive<Required<TagFilter>>({
|
||||
tagName: '',
|
||||
@@ -33,6 +36,10 @@ async function fetchList() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGroups() {
|
||||
allGroups.value = await tagGroupsApi.getTagGroupsList()
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
@@ -49,11 +56,13 @@ const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreateTagRequest & { id?: string }>({
|
||||
const dialogForm = reactive<CreateTagRequest & { id?: string; tagGroupId?: number | null }>({
|
||||
id: undefined,
|
||||
tagName: '',
|
||||
tagColor: '#ff6800',
|
||||
tagFontColor: '#ffffff',
|
||||
timing: '',
|
||||
tagGroupId: null,
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
@@ -62,7 +71,7 @@ const dialogRules = {
|
||||
|
||||
function openAddDialog() {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -74,6 +83,7 @@ function openEditDialog(t: Tag) {
|
||||
tagColor: t.tagColor || '#ff6800',
|
||||
tagFontColor: t.tagFontColor || '#ffffff',
|
||||
timing: t.timing || '',
|
||||
tagGroupId: t.tagGroupId ? Number(t.tagGroupId) : null,
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
@@ -89,6 +99,7 @@ async function handleSubmit() {
|
||||
tagColor: dialogForm.tagColor || undefined,
|
||||
tagFontColor: dialogForm.tagFontColor || undefined,
|
||||
timing: dialogForm.timing || undefined,
|
||||
tagGroupId: dialogForm.tagGroupId ?? undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await tagsApi.createTag(payload)
|
||||
@@ -120,7 +131,10 @@ async function handleDelete(t: Tag) {
|
||||
fetchList()
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
loadGroups()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -183,6 +197,12 @@ onMounted(fetchList)
|
||||
</div>
|
||||
</template>
|
||||
</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 label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
@@ -233,6 +253,16 @@ onMounted(fetchList)
|
||||
<el-color-picker v-model="dialogForm.tagFontColor" />
|
||||
<span class="color-readout">{{ dialogForm.tagFontColor }}</span>
|
||||
</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-input v-model="dialogForm.timing" placeholder="例如 9:00-12:00" />
|
||||
</el-form-item>
|
||||
@@ -5,6 +5,7 @@
|
||||
"types": ["vite/client", "node"],
|
||||
|
||||
/* Path aliases */
|
||||
"ignoreDeprecations": "6.0",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "inkreach-official-nestjs",
|
||||
"name": "@inkreach/api",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"description": "NestJS backend for InkReach Product Center",
|
||||
"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")
|
||||
}
|
||||
|
||||
// ---------- 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 ----------
|
||||
model Tag {
|
||||
id BigInt @id @default(autoincrement()) @map("tag_id")
|
||||
@@ -71,12 +86,17 @@ model Tag {
|
||||
tagColor String? @map("tag_color")
|
||||
tagFontColor String? @map("tag_font_color")
|
||||
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)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
goods Good[]
|
||||
goodTags GoodTag[]
|
||||
tagGroup TagGroup? @relation(fields: [tagGroupId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||
|
||||
@@index([tagGroupId])
|
||||
@@index([tagGroupId, sortOrder])
|
||||
@@map("tags")
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AuthModule } from './auth/auth.module';
|
||||
import { CountriesModule } from './countries/countries.module';
|
||||
import { CategoriesModule } from './categories/categories.module';
|
||||
import { TagsModule } from './tags/tags.module';
|
||||
import { TagGroupsModule } from './tag-groups/tag-groups.module';
|
||||
import { PositionsModule } from './positions/positions.module';
|
||||
import { OriginGoodsModule } from './origin-goods/origin-goods.module';
|
||||
import { GoodsModule } from './goods/goods.module';
|
||||
@@ -21,6 +22,7 @@ import { PublicModule } from './public/public.module';
|
||||
CountriesModule,
|
||||
CategoriesModule,
|
||||
TagsModule,
|
||||
TagGroupsModule,
|
||||
PositionsModule,
|
||||
OriginGoodsModule,
|
||||
GoodsModule,
|
||||
+20
-4
@@ -31,7 +31,7 @@ export interface OriginGoodsTreeNode {
|
||||
sdsGoodId: string;
|
||||
configuredCount: number;
|
||||
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. */
|
||||
@@ -125,7 +125,16 @@ export class OriginGoodsService {
|
||||
this.prisma.goodTag.findMany({
|
||||
select: {
|
||||
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]);
|
||||
});
|
||||
|
||||
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) => {
|
||||
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);
|
||||
if (arr) {
|
||||
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