diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e3e014e --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..243b265 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "tabWidth": 2, + "semi": true, + "printWidth": 100 +} diff --git a/README.md b/README.md index 72f9acf..8be8df3 100644 --- a/README.md +++ b/README.md @@ -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:@localhost:5432/inkreach-official ``` -后端额外需要的变量(在 `inkreach-official-nestjs/.env` 中): +后端额外需要的变量(在 `apps/api/.env` 中): ```env JWT_SECRET= @@ -53,17 +38,44 @@ SDS_API_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) diff --git a/inkreach-official-admin/.env.development b/apps/admin/.env.development similarity index 100% rename from inkreach-official-admin/.env.development rename to apps/admin/.env.development diff --git a/inkreach-official-admin/.gitignore b/apps/admin/.gitignore similarity index 100% rename from inkreach-official-admin/.gitignore rename to apps/admin/.gitignore diff --git a/inkreach-official-admin/.vscode/extensions.json b/apps/admin/.vscode/extensions.json similarity index 100% rename from inkreach-official-admin/.vscode/extensions.json rename to apps/admin/.vscode/extensions.json diff --git a/inkreach-official-admin/README.md b/apps/admin/README.md similarity index 100% rename from inkreach-official-admin/README.md rename to apps/admin/README.md diff --git a/inkreach-official-admin/index.html b/apps/admin/index.html similarity index 100% rename from inkreach-official-admin/index.html rename to apps/admin/index.html diff --git a/inkreach-official-admin/package.json b/apps/admin/package.json similarity index 95% rename from inkreach-official-admin/package.json rename to apps/admin/package.json index f8a29ee..fc3cc0b 100644 --- a/inkreach-official-admin/package.json +++ b/apps/admin/package.json @@ -1,5 +1,5 @@ { - "name": "inkreach-official-admin", + "name": "@inkreach/admin", "private": true, "version": "0.0.0", "type": "module", diff --git a/inkreach-official-admin/public/favicon.svg b/apps/admin/public/favicon.svg similarity index 100% rename from inkreach-official-admin/public/favicon.svg rename to apps/admin/public/favicon.svg diff --git a/inkreach-official-admin/public/icons.svg b/apps/admin/public/icons.svg similarity index 100% rename from inkreach-official-admin/public/icons.svg rename to apps/admin/public/icons.svg diff --git a/inkreach-official-admin/src/App.vue b/apps/admin/src/App.vue similarity index 100% rename from inkreach-official-admin/src/App.vue rename to apps/admin/src/App.vue diff --git a/inkreach-official-admin/src/api/auth.ts b/apps/admin/src/api/auth.ts similarity index 100% rename from inkreach-official-admin/src/api/auth.ts rename to apps/admin/src/api/auth.ts diff --git a/inkreach-official-admin/src/api/categories.ts b/apps/admin/src/api/categories.ts similarity index 100% rename from inkreach-official-admin/src/api/categories.ts rename to apps/admin/src/api/categories.ts diff --git a/inkreach-official-admin/src/api/countries.ts b/apps/admin/src/api/countries.ts similarity index 100% rename from inkreach-official-admin/src/api/countries.ts rename to apps/admin/src/api/countries.ts diff --git a/inkreach-official-admin/src/api/goods.ts b/apps/admin/src/api/goods.ts similarity index 100% rename from inkreach-official-admin/src/api/goods.ts rename to apps/admin/src/api/goods.ts diff --git a/inkreach-official-admin/src/api/origin-goods.ts b/apps/admin/src/api/origin-goods.ts similarity index 100% rename from inkreach-official-admin/src/api/origin-goods.ts rename to apps/admin/src/api/origin-goods.ts diff --git a/inkreach-official-admin/src/api/positions.ts b/apps/admin/src/api/positions.ts similarity index 100% rename from inkreach-official-admin/src/api/positions.ts rename to apps/admin/src/api/positions.ts diff --git a/inkreach-official-admin/src/api/request.ts b/apps/admin/src/api/request.ts similarity index 100% rename from inkreach-official-admin/src/api/request.ts rename to apps/admin/src/api/request.ts diff --git a/inkreach-official-admin/src/api/sync.ts b/apps/admin/src/api/sync.ts similarity index 100% rename from inkreach-official-admin/src/api/sync.ts rename to apps/admin/src/api/sync.ts diff --git a/apps/admin/src/api/tag-groups.ts b/apps/admin/src/api/tag-groups.ts new file mode 100644 index 0000000..61be0ba --- /dev/null +++ b/apps/admin/src/api/tag-groups.ts @@ -0,0 +1,33 @@ +import request from './request' +import type { + TagGroup, + CreateTagGroupRequest, + UpdateTagGroupRequest, + ReorderTagGroupsRequest, +} from '@/types' + +export const tagGroupsApi = { + getTagGroupsList: () => { + return request.get('/tag-groups') + }, + + getTagGroupById: (id: string) => { + return request.get(`/tag-groups/${id}`) + }, + + createTagGroup: (data: CreateTagGroupRequest) => { + return request.post('/tag-groups', data) + }, + + updateTagGroup: (id: string, data: UpdateTagGroupRequest) => { + return request.patch(`/tag-groups/${id}`, data) + }, + + deleteTagGroup: (id: string) => { + return request.delete(`/tag-groups/${id}`) + }, + + reorderTagGroups: (data: ReorderTagGroupsRequest) => { + return request.patch('/tag-groups/sort', data) + }, +} diff --git a/inkreach-official-admin/src/api/tags.ts b/apps/admin/src/api/tags.ts similarity index 81% rename from inkreach-official-admin/src/api/tags.ts rename to apps/admin/src/api/tags.ts index 1c788ae..6faf8dd 100644 --- a/inkreach-official-admin/src/api/tags.ts +++ b/apps/admin/src/api/tags.ts @@ -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) + }, } \ No newline at end of file diff --git a/inkreach-official-admin/src/auto-imports.d.ts b/apps/admin/src/auto-imports.d.ts similarity index 100% rename from inkreach-official-admin/src/auto-imports.d.ts rename to apps/admin/src/auto-imports.d.ts diff --git a/inkreach-official-admin/src/components.d.ts b/apps/admin/src/components.d.ts similarity index 87% rename from inkreach-official-admin/src/components.d.ts rename to apps/admin/src/components.d.ts index 8dc3411..f749eb4 100644 --- a/inkreach-official-admin/src/components.d.ts +++ b/apps/admin/src/components.d.ts @@ -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'] diff --git a/inkreach-official-admin/src/layouts/DefaultLayout.vue b/apps/admin/src/layouts/DefaultLayout.vue similarity index 100% rename from inkreach-official-admin/src/layouts/DefaultLayout.vue rename to apps/admin/src/layouts/DefaultLayout.vue diff --git a/inkreach-official-admin/src/main.ts b/apps/admin/src/main.ts similarity index 100% rename from inkreach-official-admin/src/main.ts rename to apps/admin/src/main.ts diff --git a/inkreach-official-admin/src/router/index.ts b/apps/admin/src/router/index.ts similarity index 100% rename from inkreach-official-admin/src/router/index.ts rename to apps/admin/src/router/index.ts diff --git a/inkreach-official-admin/src/stores/app.ts b/apps/admin/src/stores/app.ts similarity index 100% rename from inkreach-official-admin/src/stores/app.ts rename to apps/admin/src/stores/app.ts diff --git a/inkreach-official-admin/src/stores/auth.ts b/apps/admin/src/stores/auth.ts similarity index 100% rename from inkreach-official-admin/src/stores/auth.ts rename to apps/admin/src/stores/auth.ts diff --git a/inkreach-official-admin/src/style.css b/apps/admin/src/style.css similarity index 100% rename from inkreach-official-admin/src/style.css rename to apps/admin/src/style.css diff --git a/inkreach-official-admin/src/types/index.ts b/apps/admin/src/types/index.ts similarity index 83% rename from inkreach-official-admin/src/types/index.ts rename to apps/admin/src/types/index.ts index 36776b8..f5899f8 100644 --- a/inkreach-official-admin/src/types/index.ts +++ b/apps/admin/src/types/index.ts @@ -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 { diff --git a/inkreach-official-admin/src/views/categories/CategoriesView.vue b/apps/admin/src/views/categories/CategoriesView.vue similarity index 100% rename from inkreach-official-admin/src/views/categories/CategoriesView.vue rename to apps/admin/src/views/categories/CategoriesView.vue diff --git a/inkreach-official-admin/src/views/countries/CountriesView.vue b/apps/admin/src/views/countries/CountriesView.vue similarity index 100% rename from inkreach-official-admin/src/views/countries/CountriesView.vue rename to apps/admin/src/views/countries/CountriesView.vue diff --git a/inkreach-official-admin/src/views/goods/GoodsView.vue b/apps/admin/src/views/goods/GoodsView.vue similarity index 81% rename from inkreach-official-admin/src/views/goods/GoodsView.vue rename to apps/admin/src/views/goods/GoodsView.vue index 6838566..647fd55 100644 --- a/inkreach-official-admin/src/views/goods/GoodsView.vue +++ b/apps/admin/src/views/goods/GoodsView.vue @@ -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([]) const allCategories = ref([]) const allCountries = ref([]) const allTags = ref([]) +const allTagGroups = ref([]) const allGoods = ref([]) 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 { + 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 { + 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(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(() => { + 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()) - - -
- {{ t.tagName }} - + + + +
+ + + +
+
@@ -954,6 +1187,25 @@ onMounted(() => loadAll()) 保存 + + + + + + + + + +
@@ -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; +} diff --git a/inkreach-official-admin/src/views/login/LoginView.vue b/apps/admin/src/views/login/LoginView.vue similarity index 100% rename from inkreach-official-admin/src/views/login/LoginView.vue rename to apps/admin/src/views/login/LoginView.vue diff --git a/inkreach-official-admin/src/views/positions/PositionsView.vue b/apps/admin/src/views/positions/PositionsView.vue similarity index 100% rename from inkreach-official-admin/src/views/positions/PositionsView.vue rename to apps/admin/src/views/positions/PositionsView.vue diff --git a/inkreach-official-admin/src/views/product-management/ProductManagementView.vue b/apps/admin/src/views/product-management/ProductManagementView.vue similarity index 100% rename from inkreach-official-admin/src/views/product-management/ProductManagementView.vue rename to apps/admin/src/views/product-management/ProductManagementView.vue diff --git a/inkreach-official-admin/src/views/sync/SyncView.vue b/apps/admin/src/views/sync/SyncView.vue similarity index 100% rename from inkreach-official-admin/src/views/sync/SyncView.vue rename to apps/admin/src/views/sync/SyncView.vue diff --git a/inkreach-official-admin/src/views/tags/TagsView.vue b/apps/admin/src/views/tags/TagsView.vue similarity index 87% rename from inkreach-official-admin/src/views/tags/TagsView.vue rename to apps/admin/src/views/tags/TagsView.vue index f1fd920..48f46bc 100644 --- a/inkreach-official-admin/src/views/tags/TagsView.vue +++ b/apps/admin/src/views/tags/TagsView.vue @@ -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([]) const total = ref(0) +const allGroups = ref([]) const filter = reactive>({ 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({ +const dialogForm = reactive({ + 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() +}) + + +