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:
yeuimu
2026-07-11 16:54:05 +08:00
parent 69945b8749
commit 7e04877bb6
155 changed files with 20134 additions and 14393 deletions
+45
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
{
"singleQuote": true,
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"printWidth": 100
}
+63 -46
View File
@@ -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

+33
View File
@@ -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 {
@@ -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>
@@ -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,
@@ -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