feat(admin): redesign GoodsView with dual-tree UX, filter bar, inline CRUD

- Two-line good nodes with country + tag chips, hover tooltip
- Resizable dual-tree with fixed-height panels (no node overlap)
- Filter bar: search, country filter, tag filter (multi-select with rename)
- Mode switch (品类/国家) pushed to right via spacer
- Inline create country/tag in edit & config modals via quick-create buttons
- Right tree node height auto-fix for locate highlight
- Filter dropdown styles in global scope for teleported popper
- Backend: multi-tag (GoodTag junction), goodImage column, origin goods tree API
- Admin response interceptor unwraps {data, success} envelope
- Simplified sidebar to single 商品管理 entry with tabs
- SyncView simplified to product sync only
This commit is contained in:
yeuimu
2026-06-22 01:47:20 +08:00
parent c3472a449b
commit a903dd4903
30 changed files with 1970 additions and 1209 deletions
@@ -0,0 +1,92 @@
# Goods Management Redesign
## Date: 2026-06-21
## Problem
1. **Origin Good name not displayed**: GoodsView doesn't show the origin product name/image/price
2. **Single tag per good**: Current schema only allows one tag per good via `goods.tag_id` FK
3. **Easy to miss unconfigured origin products**: Flat paginated table with no visibility into which origin products have been configured vs not
## Solution
### 1. Multi-Tag Support (GoodTag Junction Table)
**Schema change:**
- New `good_tags` junction table: `(good_id, tag_id)` composite PK
- Migrate existing `goods.tag_id` data into `good_tags`
- Set `goods.tag_id` to nullable (will be dropped in a future migration)
**Backend:**
- `CreateGoodDto`: `tagIds: number[]` (replaces `tagId`)
- `UpdateGoodDto`: `tagIds?: number[]` (replaces `tagId`)
- `BatchCreateGoodDto`: `tagIds?: number[]` (replaces `tagId`)
- `QueryGoodDto`: `tagId` filter queries `tags.some` instead of direct FK
- `GoodsService.create/update`: manage `good_tags` records in transaction
- `GOOD_INCLUDE`: include `tags: { include: { tag: true } }`
### 2. Origin Goods Tree API
New endpoint: `GET /origin-goods/tree`
Returns origin goods grouped by SDS category, with configuration status:
```json
[
{
"sdsCategoryId": "4185",
"categoryName": "(包邮)180g纯棉T恤-单面印花",
"originGoods": [
{
"id": "123",
"goodName": "产品A",
"goodImage": "https://...",
"goodPrice": "12.50",
"sdsGoodId": "p-123",
"configuredCount": 2,
"configuredCountries": ["美国", "英国"]
}
]
}
]
```
Origin goods without `sdsCategoryId` are grouped under "未分类".
### 3. Admin UI: Dual Tree Layout
```
┌──────────────────────────────────────────────────────────┐
│ [搜索原产品] [210/439 已配置] │
├───────────────────┬─────────────────────────────────────┤
│ 首页分类树 │ 原产品分类树 │
│ │ │
│ ▼ 美国 │ ▼ (包邮)180g纯棉T恤 │
│ ▼ T恤 │ ✓ 产品A [美国,英国] │
│ ▼ 卫衣 │ ✗ 产品B [未配置] │
│ ▼ 英国 │ ▼ 未分类 │
│ ▼ 韩国 │ ✗ 产品C [未配置] │
├───────────────────┴─────────────────────────────────────┤
│ 详情/操作区域 │
│ - 选左侧分类: 该分类下所有 goods 表格 │
│ - 选右侧原产品: 该原产品的配置详情 + 快捷配置按钮 │
│ - 多标签选择 (el-select multiple) │
└──────────────────────────────────────────────────────────┘
```
**Key interactions:**
- Left tree click: filter goods by selected category
- Right tree click: show origin good detail + all its goods configs
- Right tree badges: ✓ configured / ✗ unconfigured
- Quick configure: select country+category+tags, click origin goods to batch create
- Statistics bar: configured/total count
### 4. Data Migration
```sql
-- Step 1: Create good_tags table
-- Step 2: Copy existing tag_id data
INSERT INTO good_tags (good_id, tag_id, created_at)
SELECT good_id, tag_id, NOW() FROM goods WHERE tag_id IS NOT NULL;
-- Step 3: Good.tag_id becomes nullable (already nullable)
```
@@ -16,7 +16,7 @@ export const categoriesApi = {
// Get category tree
getCategoryTree: () => {
return request.get<any, CategoryTree[]>('/categories/tree')
return request.get<any, CategoryTree[]>('/categories')
},
// Get category by id
@@ -1,16 +1,12 @@
import request from './request'
import type { OriginGood } from '@/types'
import type { OriginGood, OriginGoodsTreeResponse, PaginatedResult } from '@/types'
export const originGoodsApi = {
// Search origin goods
searchOriginGoods: (keyword: string) => {
return request.get<any, OriginGood[]>('/origin-goods/search', { params: { keyword } })
getTree: () => {
return request.get<any, OriginGoodsTreeResponse>('/origin-goods/tree')
},
// Get origin goods list
getOriginGoodsList: (page = 1, pageSize = 20) => {
return request.get<any, { data: OriginGood[]; total: number }>('/origin-goods', {
params: { page, pageSize },
})
getOriginGoodsList: (params: { page?: number; pageSize?: number; keyword?: string }) => {
return request.get<any, PaginatedResult<OriginGood>>('/origin-goods', { params })
},
}
+6 -2
View File
@@ -28,8 +28,12 @@ request.interceptors.request.use(
// Response interceptor
request.interceptors.response.use(
(response: AxiosResponse) => {
// Unwrap data.data
return response.data
// Backend wraps everything in { data, success } — unwrap to data
const body = response.data
if (body && typeof body === 'object' && 'success' in body && 'data' in body) {
return body.data
}
return body
},
(error: AxiosError) => {
if (error.response) {
+5 -15
View File
@@ -1,24 +1,14 @@
import request from './request'
import type { SyncLog, SyncStats, PaginatedResult } from '@/types'
import type { SyncLog } from '@/types'
export const syncApi = {
// Trigger category sync
syncCategories: () => {
return request.post<any, SyncLog>('/sync/categories')
},
// Trigger product sync
syncProducts: () => {
return request.post<any, SyncLog>('/sync/products')
},
// Get sync logs
getSyncLogs: (params: { page?: number; pageSize?: number; type?: 'CATEGORY' | 'PRODUCT' }) => {
return request.get<any, PaginatedResult<SyncLog>>('/sync/logs', { params })
},
// Get sync stats
getSyncStats: () => {
return request.get<any, SyncStats>('/sync/stats')
getSyncStatus: (limit?: number) => {
return request.get<any, SyncLog[]>('/sync/status', {
params: limit ? { limit } : undefined,
})
},
}
+13
View File
@@ -12,13 +12,18 @@ 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']
@@ -36,10 +41,18 @@ declare module 'vue' {
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']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElTree: typeof import('element-plus/es')['ElTree']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
@@ -4,11 +4,6 @@ import { useRouter, useRoute } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import {
Goods,
Menu,
Location,
CollectionTag,
Sort,
Refresh,
Expand,
Fold,
ArrowDown,
@@ -30,21 +25,11 @@ interface MenuItem {
}
const menuItems = ref<MenuItem[]>([
{ index: '/goods', title: 'Goods', icon: 'Goods' },
{ index: '/categories', title: 'Categories', icon: 'Menu' },
{ index: '/countries', title: 'Countries', icon: 'Location' },
{ index: '/tags', title: 'Tags', icon: 'CollectionTag' },
{ index: '/positions', title: 'Positions', icon: 'Sort' },
{ index: '/sync', title: 'Sync', icon: 'Refresh' },
{ index: '/goods', title: '商品管理', icon: 'Goods' },
])
const iconMap: Record<string, unknown> = {
Goods,
Menu,
Location,
CollectionTag,
Sort,
Refresh,
}
const activeMenu = computed(() => route.path)
@@ -53,7 +38,7 @@ const username = computed(() => authStore.user?.username || 'Admin')
const breadcrumb = computed(() => {
const meta = route.meta as { title?: string } | undefined
return meta?.title || 'Dashboard'
return meta?.title || '商品管理'
})
function toggleSidebar() {
@@ -62,9 +47,9 @@ function toggleSidebar() {
async function handleLogout() {
try {
await ElMessageBox.confirm('Are you sure to sign out?', 'Sign out', {
confirmButtonText: 'Sign out',
cancelButtonText: 'Cancel',
await ElMessageBox.confirm('确定退出登录吗?', '退出登录', {
confirmButtonText: '退出登录',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
@@ -88,8 +73,8 @@ function handleCommand(command: string) {
<div class="brand" :class="{ collapsed }">
<div class="brand-mark">IR</div>
<div v-if="!collapsed" class="brand-text">
<div class="brand-name">InkReach</div>
<div class="brand-tag">Product Center</div>
<div class="brand-name">Inkreach</div>
<div class="brand-tag">官网后台</div>
</div>
</div>
@@ -118,7 +103,7 @@ function handleCommand(command: string) {
<el-button
text
class="collapse-btn"
:title="collapsed ? 'Expand sidebar' : 'Collapse sidebar'"
:title="collapsed ? '展开侧边栏' : '折叠侧边栏'"
@click="toggleSidebar"
>
<el-icon :size="20">
@@ -127,7 +112,7 @@ function handleCommand(command: string) {
</el-button>
<el-breadcrumb separator="/" class="breadcrumb">
<el-breadcrumb-item :to="{ path: '/' }">Home</el-breadcrumb-item>
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>{{ breadcrumb }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
@@ -143,7 +128,7 @@ function handleCommand(command: string) {
<el-dropdown-menu>
<el-dropdown-item command="logout">
<el-icon><SwitchButton /></el-icon>
<span>Sign out</span>
<span>退出登录</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
+5 -35
View File
@@ -6,7 +6,7 @@ const routes: RouteRecordRaw[] = [
path: '/login',
name: 'Login',
component: () => import('@/views/login/LoginView.vue'),
meta: { title: 'Login', public: true },
meta: { title: '登录', public: true },
},
{
path: '/',
@@ -16,38 +16,8 @@ const routes: RouteRecordRaw[] = [
{
path: 'goods',
name: 'Goods',
component: () => import('@/views/goods/GoodsView.vue'),
meta: { title: 'Goods', icon: 'Goods' },
},
{
path: 'categories',
name: 'Categories',
component: () => import('@/views/categories/CategoriesView.vue'),
meta: { title: 'Categories', icon: 'Menu' },
},
{
path: 'countries',
name: 'Countries',
component: () => import('@/views/countries/CountriesView.vue'),
meta: { title: 'Countries', icon: 'Location' },
},
{
path: 'tags',
name: 'Tags',
component: () => import('@/views/tags/TagsView.vue'),
meta: { title: 'Tags', icon: 'CollectionTag' },
},
{
path: 'positions',
name: 'Positions',
component: () => import('@/views/positions/PositionsView.vue'),
meta: { title: 'Positions', icon: 'Sort' },
},
{
path: 'sync',
name: 'Sync',
component: () => import('@/views/sync/SyncView.vue'),
meta: { title: 'Sync', icon: 'Refresh' },
component: () => import('@/views/product-management/ProductManagementView.vue'),
meta: { title: '商品管理' },
},
],
},
@@ -78,8 +48,8 @@ router.beforeEach((to) => {
})
router.afterEach((to) => {
const title = (to.meta?.title as string) || 'InkReach Admin'
document.title = `${title} | InkReach Admin`
const title = (to.meta?.title as string) || 'Inkreach 官网后台'
document.title = `${title} | Inkreach 官网后台`
})
export default router
+4 -4
View File
@@ -42,10 +42,10 @@ export const useAuthStore = defineStore('auth', () => {
async function login(payload: LoginRequest) {
const res = await authApi.login(payload) as any;
const accessToken = res.data?.accessToken ?? res.accessToken ?? res.token;
const userData = res.data?.user ?? res.user;
setToken(accessToken);
setUser(userData);
const accessToken: string = res.accessToken ?? res.data?.accessToken ?? '';
const userData: User | null = res.user ?? res.data?.user ?? null;
if (accessToken) setToken(accessToken);
if (userData) setUser(userData);
return res;
}
+98 -64
View File
@@ -1,6 +1,6 @@
// Common types
export interface PaginatedResult<T> {
data: T[]
items: T[]
total: number
page: number
pageSize: number
@@ -13,7 +13,7 @@ export interface LoginRequest {
}
export interface LoginResponse {
token: string
accessToken: string
user: User
}
@@ -26,49 +26,58 @@ export interface User {
// Good types
export interface Good {
id: string
name: string
goodName: string
goodImage: string | null
goodPriority: number
originGoodId: string
originGood?: OriginGood
countryId: string
country?: Country
categoryId: string
category?: Category
tagId: string
tagId?: string
tag?: Tag
tags: Tag[]
positionId?: string
position?: Position
priority: number
createdAt: string
updatedAt: string
}
export interface CreateGoodRequest {
name: string
originGoodId: string
countryId: string
categoryId: string
tagId: string
positionId?: string
priority: number
goodName: string
goodImage?: string
originGoodId: number
countryId: number
categoryId: number
tagIds?: number[]
positionId?: number
goodPriority?: number
}
export interface UpdateGoodRequest {
name?: string
originGoodId?: string
countryId?: string
categoryId?: string
tagId?: string
positionId?: string
goodName?: string
goodImage?: string | null
originGoodId?: number
countryId?: number
categoryId?: number
tagIds?: number[]
positionId?: number | null
goodPriority?: number
}
export interface BatchCreateItem {
originGoodId: number
priority?: number
}
export interface BatchCreateGoodsRequest {
originGoodIds: string[]
countryId: string
categoryId: string
tagId: string
positionId?: string
priority: number
items: BatchCreateItem[]
countryId: number
categoryId: number
tagIds?: number[]
positionId?: number
defaultPriority?: number
}
export interface UpdatePriorityRequest {
@@ -79,28 +88,29 @@ export interface UpdatePriorityRequest {
// Country types
export interface Country {
id: string
name: string
icon?: string
countryName: string
countryIcon?: string | null
createdAt: string
updatedAt: string
}
export interface CreateCountryRequest {
name: string
icon?: string
countryName: string
countryIcon?: string
}
export interface UpdateCountryRequest {
name?: string
icon?: string
countryName?: string
countryIcon?: string | null
}
// Category types
export interface Category {
id: string
name: string
icon?: string
parentId?: string
categoryName: string
categoryIcon?: string | null
sdsCategoryId?: string | null
parentCategoryId?: string | null
parent?: Category
children?: Category[]
_count?: {
@@ -111,15 +121,15 @@ export interface Category {
}
export interface CreateCategoryRequest {
name: string
icon?: string
parentId?: string
categoryName: string
categoryIcon?: string
parentCategoryId?: number
}
export interface UpdateCategoryRequest {
name?: string
icon?: string
parentId?: string
categoryName?: string
categoryIcon?: string | null
parentCategoryId?: number | null
}
export interface CategoryTree extends Category {
@@ -129,23 +139,23 @@ export interface CategoryTree extends Category {
// Tag types
export interface Tag {
id: string
name: string
color?: string
timing?: string
tagName: string
tagColor?: string | null
timing?: string | null
createdAt: string
updatedAt: string
}
export interface CreateTagRequest {
name: string
color?: string
tagName: string
tagColor?: string
timing?: string
}
export interface UpdateTagRequest {
name?: string
color?: string
timing?: string
tagName?: string
tagColor?: string | null
timing?: string | null
}
// Position types
@@ -162,26 +172,56 @@ export interface Position {
export interface CreatePositionRequest {
indexVal: number
countryId?: string
categoryId?: string
countryId?: number
categoryId?: number
}
export interface UpdatePositionRequest {
indexVal?: number
countryId?: string
categoryId?: string
countryId?: number | null
categoryId?: number | null
}
// Origin Good types
export interface OriginGood {
id: string
name: string
productId: string
categoryId?: string
goodName: string | null
goodImage: string | null
goodPrice: string | null
sdsGoodId: string
sdsCategoryId: string | null
createdAt: string
updatedAt: string
}
// Origin Goods Tree types
export interface OriginGoodsTreeNode {
id: string
goodName: string
goodImage: string | null
goodPrice: string | null
sdsGoodId: string
configuredCount: number
configuredCountries: string[]
configuredTags: { tagName: string; tagColor: string | null }[]
}
export interface OriginGoodsTreeCategoryNode {
categoryId: string
categoryName: string
sdsCategoryId: string | null
configuredCount: number
totalCount: number
children: OriginGoodsTreeCategoryNode[]
originGoods: OriginGoodsTreeNode[]
}
export interface OriginGoodsTreeResponse {
tree: OriginGoodsTreeCategoryNode[]
totalOriginGoods: number
configuredCount: number
}
// Sync types
export interface SyncLog {
id: string
@@ -194,12 +234,6 @@ export interface SyncLog {
createdAt: string
}
export interface SyncStats {
lastSyncTime?: string
status?: 'IDLE' | 'SYNCING'
errorCount?: number
}
// Filter types
export interface GoodsFilter {
countryId?: string
@@ -211,20 +245,20 @@ export interface GoodsFilter {
}
export interface CategoryFilter {
parentId?: string
name?: string
parentCategoryId?: string
categoryName?: string
page?: number
pageSize?: number
}
export interface CountryFilter {
name?: string
countryName?: string
page?: number
pageSize?: number
}
export interface TagFilter {
name?: string
tagName?: string
page?: number
pageSize?: number
}
@@ -34,7 +34,7 @@ const cascaderOptions = ref<CascaderNode[]>([])
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
return nodes.map((n) => ({
value: n.id,
label: n.name,
label: n.categoryName,
children: n.children?.length ? buildCascader(n.children) : undefined,
}))
}
@@ -50,18 +50,18 @@ const dialogMode = ref<'create' | 'edit'>('create')
const dialogLoading = ref(false)
const dialogForm = reactive<CreateCategoryRequest & { id?: string }>({
name: '',
icon: '',
parentId: '',
categoryName: '',
categoryIcon: '',
parentCategoryId: '',
})
const dialogRules = {
name: [{ required: true, message: 'Name is required', trigger: 'blur' }],
categoryName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
}
async function openAddDialog() {
dialogMode.value = 'create'
Object.assign(dialogForm, { id: undefined, name: '', icon: '', parentId: '' })
Object.assign(dialogForm, { id: undefined, categoryName: '', categoryIcon: '', parentCategoryId: '' })
rebuildCascader()
dialogVisible.value = true
}
@@ -70,9 +70,9 @@ async function openEditDialog(c: Category) {
dialogMode.value = 'edit'
Object.assign(dialogForm, {
id: c.id,
name: c.name,
icon: c.icon || '',
parentId: c.parentId || '',
categoryName: c.categoryName,
categoryIcon: c.categoryIcon || '',
parentCategoryId: c.parentCategoryId || '',
})
rebuildCascader()
dialogVisible.value = true
@@ -85,16 +85,16 @@ async function handleSubmit() {
dialogLoading.value = true
try {
const payload: CreateCategoryRequest = {
name: dialogForm.name,
icon: dialogForm.icon || undefined,
parentId: dialogForm.parentId || undefined,
categoryName: dialogForm.categoryName,
categoryIcon: dialogForm.categoryIcon || undefined,
parentCategoryId: dialogForm.parentCategoryId || undefined,
}
if (dialogMode.value === 'create') {
await categoriesApi.createCategory(payload)
ElMessage.success('Category created')
ElMessage.success('分类创建成功')
} else {
await categoriesApi.updateCategory(dialogForm.id!, payload as UpdateCategoryRequest)
ElMessage.success('Category updated')
ElMessage.success('分类更新成功')
}
dialogVisible.value = false
fetchTree()
@@ -106,16 +106,16 @@ async function handleSubmit() {
async function handleDelete(c: Category) {
try {
await ElMessageBox.confirm(`Delete "${c.name}"?`, 'Confirm', {
await ElMessageBox.confirm(`确定删除「${c.categoryName}」吗?`, '确认', {
type: 'warning',
confirmButtonText: 'Delete',
cancelButtonText: 'Cancel',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
} catch {
return
}
await categoriesApi.deleteCategory(c.id)
ElMessage.success('Deleted')
ElMessage.success('删除成功')
fetchTree()
}
@@ -128,11 +128,11 @@ onMounted(fetchTree)
<div class="toolbar">
<el-button type="primary" @click="openAddDialog">
<el-icon><Plus /></el-icon>
<span>Add Category</span>
<span>新增分类</span>
</el-button>
<el-button @click="fetchTree">
<el-icon><Refresh /></el-icon>
<span>Refresh</span>
<span>刷新</span>
</el-button>
</div>
@@ -144,52 +144,52 @@ onMounted(fetchTree)
:tree-props="{ children: 'children' }"
default-expand-all
>
<el-table-column prop="name" label="Name" min-width="220" />
<el-table-column label="Icon" width="100">
<el-table-column prop="categoryName" label="名称" min-width="220" />
<el-table-column label="图标" width="100">
<template #default="{ row }: { row: CategoryTree }">
<el-image
v-if="row.icon"
:src="row.icon"
:preview-src-list="[row.icon]"
v-if="row.categoryIcon"
:src="row.categoryIcon"
:preview-src-list="[row.categoryIcon]"
fit="cover"
style="width: 32px; height: 32px; border-radius: 4px;"
/>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="Parent">
<el-table-column label="父级">
<template #default="{ row }: { row: CategoryTree }">
{{ row.parent?.name || '-' }}
{{ row.parent?.categoryName || '-' }}
</template>
</el-table-column>
<el-table-column label="Children" width="100">
<el-table-column label="子级数" width="100">
<template #default="{ row }: { row: CategoryTree }">
{{ row._count?.children ?? row.children?.length ?? 0 }}
</template>
</el-table-column>
<el-table-column label="Actions" width="180" fixed="right">
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }: { row: CategoryTree }">
<div class="table-actions">
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
<el-icon><Edit /></el-icon>
<span>Edit</span>
<span>编辑</span>
</el-button>
<el-button size="small" type="danger" plain @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
<span>Delete</span>
<span>删除</span>
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="No categories" />
<el-empty description="暂无分类" />
</template>
</el-table>
</div>
<el-dialog
v-model="dialogVisible"
:title="dialogMode === 'create' ? 'Add Category' : 'Edit Category'"
:title="dialogMode === 'create' ? '新增分类' : '编辑分类'"
width="520px"
destroy-on-close
>
@@ -199,12 +199,12 @@ onMounted(fetchTree)
:rules="dialogRules"
label-width="100px"
>
<el-form-item label="Name" prop="name">
<el-input v-model="dialogForm.name" placeholder="Category name" />
<el-form-item label="名称" prop="categoryName">
<el-input v-model="dialogForm.categoryName" placeholder="请输入分类名称" />
</el-form-item>
<el-form-item label="Parent">
<el-form-item label="父级分类">
<el-cascader
v-model="dialogForm.parentId"
v-model="dialogForm.parentCategoryId"
:options="cascaderOptions"
:props="{
checkStrictly: true,
@@ -213,19 +213,19 @@ onMounted(fetchTree)
children: 'children',
emitPath: false,
}"
placeholder="Top-level (optional)"
placeholder="顶级(可选)"
clearable
style="width: 100%"
/>
</el-form-item>
<el-form-item label="Icon URL">
<el-input v-model="dialogForm.icon" placeholder="https://..." />
<el-form-item label="图标 URL">
<el-input v-model="dialogForm.categoryIcon" placeholder="https://..." />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">Cancel</el-button>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
{{ dialogMode === 'create' ? 'Create' : 'Save' }}
{{ dialogMode === 'create' ? '创建' : '保存' }}
</el-button>
</template>
</el-dialog>
@@ -15,7 +15,7 @@ const list = ref<Country[]>([])
const total = ref(0)
const filter = reactive<Required<CountryFilter>>({
name: '',
countryName: '',
page: 1,
pageSize: 10,
})
@@ -24,8 +24,10 @@ async function fetchList() {
loading.value = true
try {
const res = await countriesApi.getCountriesList(filter)
list.value = res.data
total.value = res.total
const data = await countriesApi.getCountriesList(filter) as any
const arr = Array.isArray(data) ? data : (data.items ?? [])
list.value = arr
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
} finally {
loading.value = false
}
@@ -37,7 +39,7 @@ function handleSearch() {
}
function handleReset() {
filter.name = ''
filter.countryName = ''
filter.page = 1
fetchList()
}
@@ -48,23 +50,23 @@ const dialogMode = ref<'create' | 'edit'>('create')
const dialogLoading = ref(false)
const dialogForm = reactive<CreateCountryRequest & { id?: string }>({
name: '',
icon: '',
countryName: '',
countryIcon: '',
})
const dialogRules = {
name: [{ required: true, message: 'Name is required', trigger: 'blur' }],
countryName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
}
function openAddDialog() {
dialogMode.value = 'create'
Object.assign(dialogForm, { id: undefined, name: '', icon: '' })
Object.assign(dialogForm, { id: undefined, countryName: '', countryIcon: '' })
dialogVisible.value = true
}
function openEditDialog(c: Country) {
dialogMode.value = 'edit'
Object.assign(dialogForm, { id: c.id, name: c.name, icon: c.icon || '' })
Object.assign(dialogForm, { id: c.id, countryName: c.countryName, countryIcon: c.countryIcon || '' })
dialogVisible.value = true
}
@@ -75,15 +77,15 @@ async function handleSubmit() {
dialogLoading.value = true
try {
const payload: CreateCountryRequest = {
name: dialogForm.name,
icon: dialogForm.icon || undefined,
countryName: dialogForm.countryName,
countryIcon: dialogForm.countryIcon || undefined,
}
if (dialogMode.value === 'create') {
await countriesApi.createCountry(payload)
ElMessage.success('Country created')
ElMessage.success('国家创建成功')
} else {
await countriesApi.updateCountry(dialogForm.id!, payload as UpdateCountryRequest)
ElMessage.success('Country updated')
ElMessage.success('国家更新成功')
}
dialogVisible.value = false
fetchList()
@@ -95,16 +97,16 @@ async function handleSubmit() {
async function handleDelete(c: Country) {
try {
await ElMessageBox.confirm(`Delete "${c.name}"?`, 'Confirm', {
await ElMessageBox.confirm(`确定删除「${c.countryName}」吗?`, '确认', {
type: 'warning',
confirmButtonText: 'Delete',
cancelButtonText: 'Cancel',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
} catch {
return
}
await countriesApi.deleteCountry(c.id)
ElMessage.success('Deleted')
ElMessage.success('删除成功')
fetchList()
}
@@ -116,8 +118,8 @@ onMounted(fetchList)
<div class="page-card">
<div class="filter-bar">
<el-input
v-model="filter.name"
placeholder="Search by name"
v-model="filter.countryName"
placeholder="按名称搜索"
clearable
@keyup.enter="handleSearch"
@clear="handleSearch"
@@ -128,56 +130,56 @@ onMounted(fetchList)
</el-input>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
<span>Search</span>
<span>搜索</span>
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
<span>Reset</span>
<span>重置</span>
</el-button>
<div class="filter-spacer" />
<el-button type="primary" @click="openAddDialog">
<el-icon><Plus /></el-icon>
<span>Add Country</span>
<span>新增国家</span>
</el-button>
</div>
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column label="Icon" width="80">
<el-table-column label="图标" width="80">
<template #default="{ row }: { row: Country }">
<el-image
v-if="row.icon"
:src="row.icon"
:preview-src-list="[row.icon]"
v-if="row.countryIcon"
:src="row.countryIcon"
:preview-src-list="[row.countryIcon]"
fit="cover"
style="width: 32px; height: 32px; border-radius: 4px;"
/>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="name" label="Name" min-width="200" />
<el-table-column label="Created" width="180">
<el-table-column prop="countryName" label="名称" min-width="200" />
<el-table-column label="创建时间" width="180">
<template #default="{ row }: { row: Country }">
{{ new Date(row.createdAt).toLocaleString() }}
</template>
</el-table-column>
<el-table-column label="Actions" width="180" fixed="right">
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }: { row: Country }">
<div class="table-actions">
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
<el-icon><Edit /></el-icon>
<span>Edit</span>
<span>编辑</span>
</el-button>
<el-button size="small" type="danger" plain @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
<span>Delete</span>
<span>删除</span>
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="No countries" />
<el-empty description="暂无国家" />
</template>
</el-table>
@@ -195,22 +197,22 @@ onMounted(fetchList)
<el-dialog
v-model="dialogVisible"
:title="dialogMode === 'create' ? 'Add Country' : 'Edit Country'"
:title="dialogMode === 'create' ? '新增国家' : '编辑国家'"
width="480px"
destroy-on-close
>
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
<el-form-item label="Name" prop="name">
<el-input v-model="dialogForm.name" placeholder="Country name" />
<el-form-item label="名称" prop="countryName">
<el-input v-model="dialogForm.countryName" placeholder="请输入国家名称" />
</el-form-item>
<el-form-item label="Icon URL">
<el-input v-model="dialogForm.icon" placeholder="https://..." />
<el-form-item label="图标 URL">
<el-input v-model="dialogForm.countryIcon" placeholder="https://..." />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">Cancel</el-button>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
{{ dialogMode === 'create' ? 'Create' : 'Save' }}
{{ dialogMode === 'create' ? '创建' : '保存' }}
</el-button>
</template>
</el-dialog>
File diff suppressed because it is too large Load Diff
@@ -17,12 +17,12 @@ const form = reactive({
const rules: FormRules = {
username: [
{ required: true, message: 'Please enter your username', trigger: 'blur' },
{ min: 2, max: 64, message: 'Length 2-64', trigger: 'blur' },
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, max: 64, message: '长度 2-64', trigger: 'blur' },
],
password: [
{ required: true, message: 'Please enter your password', trigger: 'blur' },
{ min: 4, max: 64, message: 'Length 4-64', trigger: 'blur' },
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 4, max: 64, message: '长度 4-64', trigger: 'blur' },
],
}
@@ -33,7 +33,7 @@ async function handleSubmit() {
loading.value = true
try {
await authStore.login({ username: form.username, password: form.password })
ElMessage.success('Login successful')
ElMessage.success('登录成功')
router.push('/')
} catch (err) {
// Error toast is shown by axios response interceptor
@@ -50,8 +50,8 @@ async function handleSubmit() {
<div class="login-bg" />
<div class="login-card">
<div class="login-brand">
<div class="brand-logo">InkReach</div>
<div class="brand-subtitle">Product Center · Admin Console</div>
<div class="brand-logo">Inkreach</div>
<div class="brand-subtitle">官网后台</div>
</div>
<el-form
@@ -62,10 +62,10 @@ async function handleSubmit() {
label-position="top"
@submit.prevent="handleSubmit"
>
<el-form-item label="Username" prop="username">
<el-form-item label="用户名" prop="username">
<el-input
v-model="form.username"
placeholder="Enter your username"
placeholder="请输入用户名"
clearable
autocomplete="username"
>
@@ -75,11 +75,11 @@ async function handleSubmit() {
</el-input>
</el-form-item>
<el-form-item label="Password" prop="password">
<el-form-item label="密码" prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="Enter your password"
placeholder="请输入密码"
show-password
autocomplete="current-password"
@keyup.enter="handleSubmit"
@@ -98,13 +98,13 @@ async function handleSubmit() {
native-type="submit"
@click="handleSubmit"
>
Sign in
登录
</el-button>
</el-form-item>
</el-form>
<div class="login-footer">
<span>© {{ new Date().getFullYear() }} InkReach</span>
<span>© {{ new Date().getFullYear() }} Inkreach</span>
</div>
</div>
</div>
@@ -34,7 +34,7 @@ async function loadLookups() {
countriesApi.getCountriesList({ page: 1, pageSize: 500 }),
categoriesApi.getCategoryTree(),
])
countries.value = c.data
countries.value = c.items
categoriesTree.value = ct
}
@@ -42,8 +42,10 @@ async function fetchList() {
loading.value = true
try {
const res = await positionsApi.getPositionsList(filter)
list.value = res.data
total.value = res.total
const data = await positionsApi.getPositionsList(filter) as any
const arr = Array.isArray(data) ? data : (data.items ?? [])
list.value = arr
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
} finally {
loading.value = false
}
@@ -73,7 +75,7 @@ const cascaderOptions = ref<CascaderNode[]>([])
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
return nodes.map((n) => ({
value: n.id,
label: n.name,
label: n.categoryName,
children: n.children?.length ? buildCascader(n.children) : undefined,
}))
}
@@ -91,7 +93,7 @@ const dialogForm = reactive<CreatePositionRequest & { id?: string }>({
})
const dialogRules = {
indexVal: [{ required: true, message: 'Index is required', trigger: 'blur' }],
indexVal: [{ required: true, message: '排序值是必填项', trigger: 'blur' }],
}
function rebuildCascader() {
@@ -130,10 +132,10 @@ async function handleSubmit() {
}
if (dialogMode.value === 'create') {
await positionsApi.createPosition(payload)
ElMessage.success('Position created')
ElMessage.success('位置创建成功')
} else {
await positionsApi.updatePosition(dialogForm.id!, payload as UpdatePositionRequest)
ElMessage.success('Position updated')
ElMessage.success('位置更新成功')
}
dialogVisible.value = false
fetchList()
@@ -145,22 +147,22 @@ async function handleSubmit() {
async function handleDelete(p: Position) {
try {
await ElMessageBox.confirm(`Delete position #${p.indexVal}?`, 'Confirm', {
await ElMessageBox.confirm(`确定删除位置 #${p.indexVal} 吗?`, '确认', {
type: 'warning',
confirmButtonText: 'Delete',
cancelButtonText: 'Cancel',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
} catch {
return
}
await positionsApi.deletePosition(p.id)
ElMessage.success('Deleted')
ElMessage.success('删除成功')
fetchList()
}
function getCategoryName(p: Position): string {
if (!p.category) return p.categoryId || '-'
return p.category.parent ? `${p.category.parent.name} / ${p.category.name}` : p.category.name
return p.category.parent ? `${p.category.parent.categoryName} / ${p.category.categoryName}` : p.category.categoryName
}
onMounted(async () => {
@@ -175,14 +177,14 @@ onMounted(async () => {
<div class="filter-bar">
<el-select
v-model="filter.countryId"
placeholder="Country"
placeholder="请选择国家"
clearable
@change="handleSearch"
>
<el-option
v-for="c in countries"
:key="c.id"
:label="c.name"
:label="c.countryName"
:value="c.id"
/>
</el-select>
@@ -191,56 +193,56 @@ onMounted(async () => {
v-model="filter.categoryId"
:options="cascaderOptions"
:props="{ checkStrictly: true, value: 'value', label: 'label', children: 'children', emitPath: false }"
placeholder="Category"
placeholder="请选择分类"
clearable
@change="handleSearch"
/>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
<span>Search</span>
<span>搜索</span>
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
<span>Reset</span>
<span>重置</span>
</el-button>
<div class="filter-spacer" />
<el-button type="primary" @click="openAddDialog">
<el-icon><Plus /></el-icon>
<span>Add Position</span>
<span>新增位置</span>
</el-button>
</div>
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column label="Index" prop="indexVal" width="100" sortable />
<el-table-column label="Country" min-width="160">
<el-table-column label="排序值" prop="indexVal" width="100" sortable />
<el-table-column label="国家" min-width="160">
<template #default="{ row }: { row: Position }">
{{ row.country?.name || '-' }}
{{ row.country?.countryName || '-' }}
</template>
</el-table-column>
<el-table-column label="Category" min-width="200">
<el-table-column label="分类" min-width="200">
<template #default="{ row }: { row: Position }">
{{ getCategoryName(row) }}
</template>
</el-table-column>
<el-table-column label="Actions" width="180" fixed="right">
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }: { row: Position }">
<div class="table-actions">
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
<el-icon><Edit /></el-icon>
<span>Edit</span>
<span>编辑</span>
</el-button>
<el-button size="small" type="danger" plain @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
<span>Delete</span>
<span>删除</span>
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="No positions" />
<el-empty description="暂无位置" />
</template>
</el-table>
@@ -258,25 +260,25 @@ onMounted(async () => {
<el-dialog
v-model="dialogVisible"
:title="dialogMode === 'create' ? 'Add Position' : 'Edit Position'"
:title="dialogMode === 'create' ? '新增位置' : '编辑位置'"
width="520px"
destroy-on-close
>
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
<el-form-item label="Index" prop="indexVal">
<el-form-item label="排序值" prop="indexVal">
<el-input-number v-model="dialogForm.indexVal" :min="0" :max="9999" />
</el-form-item>
<el-form-item label="Country">
<el-select v-model="dialogForm.countryId" placeholder="Optional" clearable style="width: 100%">
<el-form-item label="国家">
<el-select v-model="dialogForm.countryId" placeholder="可选" clearable style="width: 100%">
<el-option
v-for="c in countries"
:key="c.id"
:label="c.name"
:label="c.countryName"
:value="c.id"
/>
</el-select>
</el-form-item>
<el-form-item label="Category">
<el-form-item label="分类">
<el-cascader
v-model="dialogForm.categoryId"
:options="cascaderOptions"
@@ -287,16 +289,16 @@ onMounted(async () => {
children: 'children',
emitPath: false,
}"
placeholder="Optional"
placeholder="可选"
clearable
style="width: 100%"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">Cancel</el-button>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
{{ dialogMode === 'create' ? 'Create' : 'Save' }}
{{ dialogMode === 'create' ? '创建' : '保存' }}
</el-button>
</template>
</el-dialog>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { markRaw, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Goods, Refresh } from '@element-plus/icons-vue'
import GoodsView from '@/views/goods/GoodsView.vue'
import SyncView from '@/views/sync/SyncView.vue'
const route = useRoute()
const router = useRouter()
const tabs = [
{ name: 'goods', label: '商品配置', icon: markRaw(Goods), comp: markRaw(GoodsView) },
{ name: 'sync', label: '数据同步', icon: markRaw(Refresh), comp: markRaw(SyncView) },
]
const activeTab = ref((route.query.tab as string) || 'goods')
watch(activeTab, (val) => {
router.replace({ query: { ...route.query, tab: val } })
})
</script>
<template>
<div class="pm-wrapper">
<el-tabs v-model="activeTab" class="pm-tabs" type="card">
<el-tab-pane
v-for="t in tabs"
:key="t.name"
:name="t.name"
>
<template #label>
<span class="pm-tab-label">
<el-icon class="pm-tab-icon"><component :is="t.icon" /></el-icon>
{{ t.label }}
</span>
</template>
<component :is="t.comp" />
</el-tab-pane>
</el-tabs>
</div>
</template>
<style scoped>
.pm-wrapper { height: 100%; display: flex; flex-direction: column; }
.pm-tabs { height: 100%; display: flex; flex-direction: column; }
.pm-tabs :deep(.el-tabs__header) { margin-bottom: 0; flex-shrink: 0; }
.pm-tabs :deep(.el-tabs__content) { flex: 1; min-height: 0; overflow: hidden; }
.pm-tabs :deep(.el-tab-pane) { height: 100%; }
.pm-tab-label {
display: inline-flex;
align-items: center;
gap: 4px;
}
.pm-tab-icon { font-size: 14px; }
</style>
@@ -1,99 +1,46 @@
<script setup lang="ts">
import { onMounted, onUnmounted, reactive, ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, Folder, Box } from '@element-plus/icons-vue'
import type { SyncLog, SyncStats } from '@/types'
import { Refresh, Box } from '@element-plus/icons-vue'
import type { SyncLog } from '@/types'
import { syncApi } from '@/api/sync'
const stats = ref<SyncStats | null>(null)
const logs = ref<SyncLog[]>([])
const total = ref(0)
const loading = ref(false)
const syncingCategories = ref(false)
const syncingProducts = ref(false)
const filter = reactive<{ page: number; pageSize: number; type: '' | 'CATEGORY' | 'PRODUCT' }>({
page: 1,
pageSize: 10,
type: '',
})
const syncing = ref(false)
let timer: ReturnType<typeof setInterval> | null = null
async function refreshStats() {
try {
stats.value = await syncApi.getSyncStats()
} catch (err) {
console.warn('Failed to fetch sync stats', err)
}
}
async function refreshLogs() {
loading.value = true
try {
const res = await syncApi.getSyncLogs({
page: filter.page,
pageSize: filter.pageSize,
type: filter.type || undefined,
})
logs.value = res.data
total.value = res.total
const data = await syncApi.getSyncStatus(50) as any
logs.value = Array.isArray(data) ? data : []
} catch (err) {
console.warn('Failed to fetch sync logs', err)
console.warn('Failed to fetch sync status', err)
} finally {
loading.value = false
}
}
async function refreshAll() {
await Promise.all([refreshStats(), refreshLogs()])
}
async function handleSyncCategories() {
async function handleSync() {
try {
await ElMessageBox.confirm(
'Trigger category sync now? This may take a while.',
'Confirm',
{
type: 'info',
confirmButtonText: 'Run',
cancelButtonText: 'Cancel',
}
'确定立即执行产品同步吗?此操作可能需要一些时间。',
'确认',
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
)
} catch {
return
}
syncingCategories.value = true
try {
const log = await syncApi.syncCategories()
ElMessage.success(`Category sync started (${log.id})`)
await refreshAll()
} finally {
syncingCategories.value = false
}
}
} catch { return }
async function handleSyncProducts() {
syncing.value = true
try {
await ElMessageBox.confirm(
'Trigger product sync now? This may take a while.',
'Confirm',
{
type: 'info',
confirmButtonText: 'Run',
cancelButtonText: 'Cancel',
}
)
await syncApi.syncProducts()
ElMessage.success('产品同步完成')
await refreshLogs()
} catch {
return
}
syncingProducts.value = true
try {
const log = await syncApi.syncProducts()
ElMessage.success(`Product sync started (${log.id})`)
await refreshAll()
ElMessage.error('产品同步失败')
} finally {
syncingProducts.value = false
syncing.value = false
}
}
@@ -102,17 +49,9 @@ function formatDate(s?: string): string {
return new Date(s).toLocaleString()
}
function statusType(status: SyncLog['status']) {
return status === 'SUCCESS' ? 'success' : 'danger'
}
function typeLabel(type: SyncLog['type']) {
return type === 'CATEGORY' ? 'Category' : 'Product'
}
onMounted(() => {
refreshAll()
timer = setInterval(refreshAll, 30_000)
refreshLogs()
timer = setInterval(refreshLogs, 30_000)
})
onUnmounted(() => {
@@ -122,156 +61,58 @@ onUnmounted(() => {
<template>
<div class="page-container">
<div class="page-card sync-status">
<div class="status-item">
<div class="status-label">Last Sync</div>
<div class="status-value">{{ formatDate(stats?.lastSyncTime) }}</div>
</div>
<div class="status-item">
<div class="status-label">Current Status</div>
<div class="status-value">
<el-tag :type="stats?.status === 'SYNCING' ? 'warning' : 'success'">
{{ stats?.status || 'IDLE' }}
</el-tag>
</div>
</div>
<div class="status-item">
<div class="status-label">Error Count</div>
<div class="status-value">{{ stats?.errorCount ?? 0 }}</div>
</div>
</div>
<div class="sync-cards">
<el-card class="sync-card">
<template #header>
<div class="sync-card-header">
<div class="sync-card-title">
<el-icon><Folder /></el-icon>
<span>Category Sync</span>
</div>
</div>
</template>
<p class="sync-card-desc">
Pull latest categories from upstream and reconcile local database.
</p>
<el-button
type="primary"
:loading="syncingCategories"
@click="handleSyncCategories"
>
<el-icon><Refresh /></el-icon>
<span>Run Category Sync</span>
</el-button>
</el-card>
<el-card class="sync-card">
<template #header>
<div class="sync-card-header">
<div class="sync-card-title">
<el-icon><Box /></el-icon>
<span>Product Sync</span>
<span>产品同步</span>
</div>
</div>
</template>
<p class="sync-card-desc">
Pull latest products/origin goods from upstream and reconcile local database.
从上游拉取最新产品和原产品并与本地数据库进行同步
</p>
<el-button
type="primary"
:loading="syncingProducts"
@click="handleSyncProducts"
>
<el-button type="primary" :loading="syncing" @click="handleSync">
<el-icon><Refresh /></el-icon>
<span>Run Product Sync</span>
<span>执行产品同步</span>
</el-button>
</el-card>
</div>
<div class="page-card logs-card">
<div class="logs-toolbar">
<h3 class="logs-title">Sync Logs</h3>
<div class="logs-filter">
<el-select v-model="filter.type" placeholder="All types" clearable style="width: 160px;">
<el-option label="Category" value="CATEGORY" />
<el-option label="Product" value="PRODUCT" />
</el-select>
<h3 class="logs-title">同步日志</h3>
<el-button @click="refreshLogs">
<el-icon><Refresh /></el-icon>
<span>Refresh</span>
<span>刷新</span>
</el-button>
</div>
</div>
<el-table v-loading="loading" :data="logs" border stripe>
<el-table-column label="Type" width="120">
<el-table-column label="状态" width="100">
<template #default="{ row }: { row: SyncLog }">
<el-tag :type="row.type === 'CATEGORY' ? 'warning' : 'primary'">
{{ typeLabel(row.type) }}
<el-tag :type="row.status === 'SUCCESS' ? 'success' : 'danger'" size="small">
{{ row.status === 'SUCCESS' ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="Status" width="120">
<template #default="{ row }: { row: SyncLog }">
<el-tag :type="statusType(row.status)">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="Started" width="180">
<el-table-column label="时间" width="200">
<template #default="{ row }: { row: SyncLog }">
{{ formatDate(row.startTime) }}
</template>
</el-table-column>
<el-table-column label="Ended" width="180">
<template #default="{ row }: { row: SyncLog }">
{{ formatDate(row.endTime) }}
</template>
</el-table-column>
<el-table-column label="Errors" prop="errorCount" width="90" />
<el-table-column prop="message" label="Message" min-width="220" />
<el-table-column prop="message" label="信息" min-width="200" show-overflow-tooltip />
<template #empty>
<el-empty description="No sync logs" />
<el-empty description="暂无同步记录" />
</template>
</el-table>
<el-pagination
class="pagination"
v-model:current-page="filter.page"
v-model:page-size="filter.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="(p: number) => { filter.page = p; refreshLogs() }"
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; refreshLogs() }"
/>
</div>
</div>
</template>
<style scoped>
.sync-status {
display: flex;
gap: 24px;
margin-bottom: 16px;
}
.status-item {
flex: 1;
padding: 4px 0;
}
.status-label {
font-size: 12px;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-value {
font-size: 20px;
font-weight: 600;
color: #1f2937;
margin-top: 4px;
}
.sync-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
@@ -316,15 +157,4 @@ onUnmounted(() => {
font-size: 16px;
font-weight: 600;
}
.logs-filter {
display: flex;
gap: 8px;
align-items: center;
}
.pagination {
margin-top: 16px;
justify-content: flex-end;
}
</style>
@@ -15,7 +15,7 @@ const list = ref<Tag[]>([])
const total = ref(0)
const filter = reactive<Required<TagFilter>>({
name: '',
tagName: '',
page: 1,
pageSize: 10,
})
@@ -24,8 +24,10 @@ async function fetchList() {
loading.value = true
try {
const res = await tagsApi.getTagsList(filter)
list.value = res.data
total.value = res.total
const data = await tagsApi.getTagsList(filter) as any
const arr = Array.isArray(data) ? data : (data.items ?? [])
list.value = arr
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
} finally {
loading.value = false
}
@@ -37,7 +39,7 @@ function handleSearch() {
}
function handleReset() {
filter.name = ''
filter.tagName = ''
filter.page = 1
fetchList()
}
@@ -48,18 +50,18 @@ const dialogMode = ref<'create' | 'edit'>('create')
const dialogLoading = ref(false)
const dialogForm = reactive<CreateTagRequest & { id?: string }>({
name: '',
color: '#ff6800',
tagName: '',
tagColor: '#ff6800',
timing: '',
})
const dialogRules = {
name: [{ required: true, message: 'Name is required', trigger: 'blur' }],
tagName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
}
function openAddDialog() {
dialogMode.value = 'create'
Object.assign(dialogForm, { id: undefined, name: '', color: '#ff6800', timing: '' })
Object.assign(dialogForm, { id: undefined, tagName: '', tagColor: '#ff6800', timing: '' })
dialogVisible.value = true
}
@@ -67,8 +69,8 @@ function openEditDialog(t: Tag) {
dialogMode.value = 'edit'
Object.assign(dialogForm, {
id: t.id,
name: t.name,
color: t.color || '#ff6800',
tagName: t.tagName,
tagColor: t.tagColor || '#ff6800',
timing: t.timing || '',
})
dialogVisible.value = true
@@ -81,16 +83,16 @@ async function handleSubmit() {
dialogLoading.value = true
try {
const payload: CreateTagRequest = {
name: dialogForm.name,
color: dialogForm.color || undefined,
tagName: dialogForm.tagName,
tagColor: dialogForm.tagColor || undefined,
timing: dialogForm.timing || undefined,
}
if (dialogMode.value === 'create') {
await tagsApi.createTag(payload)
ElMessage.success('Tag created')
ElMessage.success('标签创建成功')
} else {
await tagsApi.updateTag(dialogForm.id!, payload as UpdateTagRequest)
ElMessage.success('Tag updated')
ElMessage.success('标签更新成功')
}
dialogVisible.value = false
fetchList()
@@ -102,16 +104,16 @@ async function handleSubmit() {
async function handleDelete(t: Tag) {
try {
await ElMessageBox.confirm(`Delete "${t.name}"?`, 'Confirm', {
await ElMessageBox.confirm(`确定删除「${t.tagName}」吗?`, '确认', {
type: 'warning',
confirmButtonText: 'Delete',
cancelButtonText: 'Cancel',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
} catch {
return
}
await tagsApi.deleteTag(t.id)
ElMessage.success('Deleted')
ElMessage.success('删除成功')
fetchList()
}
@@ -123,8 +125,8 @@ onMounted(fetchList)
<div class="page-card">
<div class="filter-bar">
<el-input
v-model="filter.name"
placeholder="Search by name"
v-model="filter.tagName"
placeholder="按名称搜索"
clearable
@keyup.enter="handleSearch"
@clear="handleSearch"
@@ -135,56 +137,56 @@ onMounted(fetchList)
</el-input>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
<span>Search</span>
<span>搜索</span>
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
<span>Reset</span>
<span>重置</span>
</el-button>
<div class="filter-spacer" />
<el-button type="primary" @click="openAddDialog">
<el-icon><Plus /></el-icon>
<span>Add Tag</span>
<span>新增标签</span>
</el-button>
</div>
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column label="Preview" width="120">
<el-table-column label="预览" width="120">
<template #default="{ row }: { row: Tag }">
<el-tag v-if="row.color" :color="row.color" effect="dark">
{{ row.name }}
<el-tag v-if="row.tagColor" :color="row.tagColor" effect="dark">
{{ row.tagName }}
</el-tag>
<el-tag v-else effect="plain">{{ row.name }}</el-tag>
<el-tag v-else effect="plain">{{ row.tagName }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="name" label="Name" min-width="200" />
<el-table-column label="Color" width="140">
<el-table-column prop="tagName" label="名称" min-width="200" />
<el-table-column label="颜色" width="140">
<template #default="{ row }: { row: Tag }">
<div class="color-cell">
<span class="color-swatch" :style="{ background: row.color || '#d1d5db' }" />
<span class="color-hex">{{ row.color || '-' }}</span>
<span class="color-swatch" :style="{ background: row.tagColor || '#d1d5db' }" />
<span class="color-hex">{{ row.tagColor || '-' }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="timing" label="Timing" min-width="120" />
<el-table-column label="Actions" width="180" fixed="right">
<el-table-column prop="timing" label="定时" min-width="120" />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }: { row: Tag }">
<div class="table-actions">
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
<el-icon><Edit /></el-icon>
<span>Edit</span>
<span>编辑</span>
</el-button>
<el-button size="small" type="danger" plain @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
<span>Delete</span>
<span>删除</span>
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="No tags" />
<el-empty description="暂无标签" />
</template>
</el-table>
@@ -202,26 +204,26 @@ onMounted(fetchList)
<el-dialog
v-model="dialogVisible"
:title="dialogMode === 'create' ? 'Add Tag' : 'Edit Tag'"
:title="dialogMode === 'create' ? '新增标签' : '编辑标签'"
width="480px"
destroy-on-close
>
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
<el-form-item label="Name" prop="name">
<el-input v-model="dialogForm.name" placeholder="Tag name" />
<el-form-item label="名称" prop="tagName">
<el-input v-model="dialogForm.tagName" placeholder="请输入标签名称" />
</el-form-item>
<el-form-item label="Color">
<el-color-picker v-model="dialogForm.color" />
<span class="color-readout">{{ dialogForm.color }}</span>
<el-form-item label="颜色">
<el-color-picker v-model="dialogForm.tagColor" />
<span class="color-readout">{{ dialogForm.tagColor }}</span>
</el-form-item>
<el-form-item label="Timing">
<el-input v-model="dialogForm.timing" placeholder="e.g. 9:00-12:00" />
<el-form-item label="定时">
<el-input v-model="dialogForm.timing" placeholder="例如 9:00-12:00" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">Cancel</el-button>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
{{ dialogMode === 'create' ? 'Create' : 'Save' }}
{{ dialogMode === 'create' ? '创建' : '保存' }}
</el-button>
</template>
</el-dialog>
@@ -74,6 +74,7 @@ model Tag {
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
goods Good[]
goodTags GoodTag[]
@@map("tags")
}
@@ -106,6 +107,7 @@ model Good {
tagId BigInt? @map("tag_id")
positionId BigInt? @map("position_id")
goodName String @map("good_name")
goodImage String? @map("good_image")
goodPriority Int @default(0) @map("good_priority")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
@@ -115,6 +117,7 @@ model Good {
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict, onUpdate: NoAction)
tag Tag? @relation(fields: [tagId], references: [id], onDelete: SetNull, onUpdate: NoAction)
position Position? @relation(fields: [positionId], references: [id], onDelete: SetNull, onUpdate: NoAction)
goodTags GoodTag[]
@@index([originGoodId])
@@index([countryId])
@@ -127,6 +130,20 @@ model Good {
@@map("goods")
}
// ---------- Good-Tag Junction (M:N) ----------
model GoodTag {
goodId BigInt @map("good_id")
tagId BigInt @map("tag_id")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
good Good @relation(fields: [goodId], references: [id], onDelete: Cascade, onUpdate: NoAction)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@id([goodId, tagId])
@@index([tagId])
@@map("good_tags")
}
// ---------- Users (admin authentication) ----------
model User {
id BigInt @id @default(autoincrement())
@@ -11,6 +11,9 @@ export class CategoryNodeDto {
@ApiProperty({ nullable: true })
categoryIcon!: string | null;
@ApiProperty({ nullable: true })
sdsCategoryId!: string | null;
@ApiProperty({ nullable: true, description: 'Parent category ID' })
parentCategoryId!: string | null;
@@ -22,6 +25,7 @@ export class CategoryNodeDto {
id: category.id.toString(),
categoryName: category.categoryName,
categoryIcon: category.categoryIcon,
sdsCategoryId: category.sdsCategoryId,
parentCategoryId: category.parentCategoryId
? category.parentCategoryId.toString()
: null,
@@ -40,11 +40,12 @@ export class BatchCreateGoodDto {
@Min(1)
categoryId!: number;
@ApiProperty({ required: false, nullable: true })
@ApiProperty({ required: false, nullable: true, type: [Number] })
@IsOptional()
@IsInt()
@Min(1)
tagId?: number;
@IsArray()
@IsInt({ each: true })
@Min(1, { each: true })
tagIds?: number[];
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@@ -1,5 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import {
ArrayMinSize,
IsArray,
IsInt,
IsNotEmpty,
IsOptional,
@@ -28,11 +30,13 @@ export class CreateGoodDto {
@Min(1)
categoryId!: number;
@ApiProperty({ required: false, nullable: true })
@ApiProperty({ required: false, nullable: true, type: [Number] })
@IsOptional()
@IsInt()
@Min(1)
tagId?: number;
@IsArray()
@ArrayMinSize(1)
@IsInt({ each: true })
@Min(1, { each: true })
tagIds?: number[];
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@@ -45,4 +49,9 @@ export class CreateGoodDto {
@IsInt()
@Min(0)
goodPriority?: number;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
goodImage?: string;
}
@@ -13,6 +13,7 @@ export interface GoodRelations {
goodImage: string | null;
goodPrice: unknown;
} | null;
goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null } }[];
}
export class GoodDto {
@@ -22,6 +23,9 @@ export class GoodDto {
@ApiProperty()
goodName!: string;
@ApiProperty({ nullable: true })
goodImage!: string | null;
@ApiProperty()
goodPriority!: number;
@@ -55,6 +59,9 @@ export class GoodDto {
@ApiProperty({ required: false, nullable: true })
tag?: { id: string; tagName: string; tagColor: string | null } | null;
@ApiProperty({ required: false, type: Array })
tags!: Array<{ id: string; tagName: string; tagColor: string | null }>;
@ApiProperty({ required: false, nullable: true })
position?: { id: string; indexVal: number } | null;
@@ -74,6 +81,7 @@ export class GoodDto {
return {
id: good.id.toString(),
goodName: good.goodName,
goodImage: good.goodImage,
goodPriority: good.goodPriority,
countryId: good.countryId.toString(),
categoryId: good.categoryId.toString(),
@@ -103,6 +111,13 @@ export class GoodDto {
tagColor: rel.tag.tagColor,
}
: null,
tags: rel.goodTags
? rel.goodTags.map((gt) => ({
id: gt.tag.id.toString(),
tagName: gt.tag.tagName,
tagColor: gt.tag.tagColor,
}))
: [],
position: rel.position
? {
id: rel.position.id.toString(),
@@ -1,5 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsArray,
IsInt,
IsOptional,
IsString,
@@ -30,11 +31,12 @@ export class UpdateGoodDto {
@Min(1)
categoryId?: number;
@ApiProperty({ required: false, nullable: true })
@ApiProperty({ required: false, nullable: true, type: [Number] })
@IsOptional()
@IsInt()
@Min(1)
tagId?: number | null;
@IsArray()
@IsInt({ each: true })
@Min(1, { each: true })
tagIds?: number[];
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@@ -47,4 +49,9 @@ export class UpdateGoodDto {
@IsInt()
@Min(0)
goodPriority?: number;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
goodImage?: string | null;
}
@@ -99,13 +99,13 @@ describe('GoodsService', () => {
originGoodId: Number(originGoodIds[0]),
countryId: Number(countryId),
categoryId: Number(categoryId),
tagId: Number(tagId),
tagIds: [Number(tagId)],
positionId: Number(positionId),
goodPriority: 3,
});
expect(created.id).toBeTruthy();
expect(created.country?.countryName).toBeTruthy();
expect(created.tag?.tagColor).toBe('#00FF00');
expect(created.tags.some((t) => t.tagColor === '#00FF00')).toBe(true);
const fetched = await service.findOne(BigInt(created.id));
expect(fetched.goodName).toBe(`Goods Test ${stamp} basic`);
@@ -121,7 +121,9 @@ describe('GoodsService', () => {
});
expect(result.items.length).toBeGreaterThan(0);
expect(result.items.every((g) => g.countryId === countryId.toString())).toBe(true);
expect(result.items.every((g) => g.tagId === tagId.toString())).toBe(true);
expect(
result.items.every((g) => g.tags.some((t) => t.id === tagId.toString())),
).toBe(true);
});
it('categoryId filter includes descendants recursively', async () => {
@@ -18,6 +18,7 @@ const GOOD_INCLUDE = {
tag: true,
position: true,
originGood: true,
goodTags: { include: { tag: true } },
} satisfies Prisma.GoodInclude;
@Injectable()
@@ -28,7 +29,7 @@ export class GoodsService {
const { page, pageSize, countryId, categoryId, tagId, positionId, keyword } = query;
const where: Prisma.GoodWhereInput = {};
if (countryId !== undefined) where.countryId = BigInt(countryId);
if (tagId !== undefined) where.tagId = BigInt(tagId);
if (tagId !== undefined) where.goodTags = { some: { tagId: BigInt(tagId) } };
if (positionId !== undefined) where.positionId = BigInt(positionId);
if (keyword) {
where.goodName = { contains: keyword, mode: 'insensitive' };
@@ -56,6 +57,7 @@ export class GoodsService {
tag: g.tag,
position: g.position,
originGood: g.originGood,
goodTags: g.goodTags,
})),
total,
page,
@@ -75,29 +77,44 @@ export class GoodsService {
tag: good.tag,
position: good.position,
originGood: good.originGood,
goodTags: good.goodTags,
});
}
async create(dto: CreateGoodDto): Promise<GoodDto> {
await this.ensureReferences(dto);
const created = await this.prisma.good.create({
return this.prisma.$transaction(async (tx) => {
const created = await tx.good.create({
data: {
goodName: dto.goodName,
goodImage: dto.goodImage,
originGoodId: BigInt(dto.originGoodId),
countryId: BigInt(dto.countryId),
categoryId: BigInt(dto.categoryId),
tagId: dto.tagId === undefined ? null : BigInt(dto.tagId),
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
goodPriority: dto.goodPriority ?? 0,
},
});
if (dto.tagIds && dto.tagIds.length > 0) {
await tx.goodTag.createMany({
data: dto.tagIds.map((tagId) => ({
goodId: created.id,
tagId: BigInt(tagId),
})),
});
}
const result = await tx.good.findUniqueOrThrow({
where: { id: created.id },
include: GOOD_INCLUDE,
});
return GoodDto.from(created, {
country: created.country,
category: created.category,
tag: created.tag,
position: created.position,
originGood: created.originGood,
return GoodDto.from(result, {
country: result.country,
category: result.category,
tag: result.tag,
position: result.position,
originGood: result.originGood,
goodTags: result.goodTags,
});
});
}
@@ -117,12 +134,6 @@ export class GoodsService {
await this.ensureCategory(dto.categoryId);
data.category = { connect: { id: BigInt(dto.categoryId) } };
}
if (dto.tagId !== undefined) {
data.tag =
dto.tagId === null
? { disconnect: true }
: { connect: { id: BigInt(dto.tagId) } };
}
if (dto.positionId !== undefined) {
data.position =
dto.positionId === null
@@ -130,7 +141,26 @@ export class GoodsService {
: { connect: { id: BigInt(dto.positionId) } };
}
if (dto.goodPriority !== undefined) data.goodPriority = dto.goodPriority;
const updated = await this.prisma.good.update({
if (dto.goodImage !== undefined) data.goodImage = dto.goodImage;
if (dto.tagIds !== undefined) {
for (const tagId of dto.tagIds) {
await this.ensureTag(tagId);
}
}
return this.prisma.$transaction(async (tx) => {
if (dto.tagIds !== undefined) {
await tx.goodTag.deleteMany({ where: { goodId: id } });
if (dto.tagIds.length > 0) {
await tx.goodTag.createMany({
data: dto.tagIds.map((tagId) => ({
goodId: id,
tagId: BigInt(tagId),
})),
});
}
}
const updated = await tx.good.update({
where: { id },
data,
include: GOOD_INCLUDE,
@@ -141,6 +171,8 @@ export class GoodsService {
tag: updated.tag,
position: updated.position,
originGood: updated.originGood,
goodTags: updated.goodTags,
});
});
}
@@ -167,11 +199,16 @@ export class GoodsService {
}
/**
* Creates multiple goods atomically, sharing countryId/categoryId/tagId/positionId
* Creates multiple goods atomically, sharing countryId/categoryId/tagIds/positionId
* and a default priority that may be overridden per item.
*/
async batchCreate(dto: BatchCreateGoodDto): Promise<GoodDto[]> {
const defaultPriority = dto.defaultPriority ?? 0;
if (dto.tagIds && dto.tagIds.length > 0) {
for (const tagId of dto.tagIds) {
await this.ensureTag(tagId);
}
}
return this.prisma.$transaction(async (tx) => {
const created: GoodDto[] = [];
for (const item of dto.items) {
@@ -186,21 +223,33 @@ export class GoodsService {
const row = await tx.good.create({
data: {
goodName: og.goodName ?? `Origin Good ${og.sdsGoodId}`,
goodImage: og.goodImage,
originGoodId: og.id,
countryId: BigInt(dto.countryId),
categoryId: BigInt(dto.categoryId),
tagId: dto.tagId === undefined ? null : BigInt(dto.tagId),
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
goodPriority: item.priority ?? defaultPriority,
},
});
if (dto.tagIds && dto.tagIds.length > 0) {
await tx.goodTag.createMany({
data: dto.tagIds.map((tagId) => ({
goodId: row.id,
tagId: BigInt(tagId),
})),
});
}
const result = await tx.good.findUniqueOrThrow({
where: { id: row.id },
include: GOOD_INCLUDE,
});
created.push(GoodDto.from(row, {
country: row.country,
category: row.category,
tag: row.tag,
position: row.position,
originGood: row.originGood,
created.push(GoodDto.from(result, {
country: result.country,
category: result.category,
tag: result.tag,
position: result.position,
originGood: result.originGood,
goodTags: result.goodTags,
}));
}
return created;
@@ -245,13 +294,19 @@ export class GoodsService {
if (!c) throw new BadRequestException(`Category ${id} not found`);
}
private async ensureTag(id: number) {
const t = await this.prisma.tag.findUnique({ where: { id: BigInt(id) } });
if (!t) throw new BadRequestException(`Tag ${id} not found`);
}
private async ensureReferences(dto: CreateGoodDto) {
await this.ensureOriginGood(dto.originGoodId);
await this.ensureCountry(dto.countryId);
await this.ensureCategory(dto.categoryId);
if (dto.tagId !== undefined) {
const t = await this.prisma.tag.findUnique({ where: { id: BigInt(dto.tagId) } });
if (!t) throw new BadRequestException(`Tag ${dto.tagId} not found`);
if (dto.tagIds && dto.tagIds.length > 0) {
for (const tagId of dto.tagIds) {
await this.ensureTag(tagId);
}
}
if (dto.positionId !== undefined) {
const p = await this.prisma.position.findUnique({ where: { id: BigInt(dto.positionId) } });
@@ -11,6 +11,14 @@ import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
export class OriginGoodsController {
constructor(private readonly service: OriginGoodsService) {}
@Get('tree')
@ApiOperation({
summary: 'Origin goods grouped by SDS category with config status',
})
getTree() {
return this.service.getTree();
}
@Get()
@ApiOperation({ summary: 'Paginated list of origin goods (read-only)' })
findAll(@Query() query: QueryOriginGoodDto) {
@@ -19,6 +19,39 @@ export interface PaginatedOriginGoods {
pageSize: number;
}
/**
* A single origin-good node inside the tree, augmented with configuration status
* (how many `goods` rows reference it and which countries it has been configured for).
*/
export interface OriginGoodsTreeNode {
id: string;
goodName: string;
goodImage: string | null;
goodPrice: string | null;
sdsGoodId: string;
configuredCount: number;
configuredCountries: string[];
configuredTags: { tagName: string; tagColor: string | null }[];
}
/** A category node in the hierarchical tree, with origin goods as leaves. */
export interface OriginGoodsTreeCategoryNode {
categoryId: string;
categoryName: string;
sdsCategoryId: string | null;
configuredCount: number;
totalCount: number;
children: OriginGoodsTreeCategoryNode[];
originGoods: OriginGoodsTreeNode[];
}
/** Top-level tree response returned by `OriginGoodsService.getTree()`. */
export interface OriginGoodsTreeResponse {
tree: OriginGoodsTreeCategoryNode[];
totalOriginGoods: number;
configuredCount: number;
}
@Injectable()
export class OriginGoodsService {
constructor(private readonly prisma: PrismaService) {}
@@ -55,4 +88,169 @@ export class OriginGoodsService {
pageSize,
};
}
/**
* Builds a hierarchical tree using the `categories` table parent-child
* structure, placing each origin-good as a leaf under the category whose
* `sdsCategoryId` matches the origin-good's `sdsCategoryId`.
*
* Origin-goods whose `sdsCategoryId` doesn't map to any category are placed
* under a synthetic "未分类" root node.
*/
async getTree(): Promise<OriginGoodsTreeResponse> {
const [allCategories, allOriginGoods, configCounts, goodsWithCountries, goodsWithTags] =
await Promise.all([
this.prisma.category.findMany({
where: { sdsCategoryId: { not: null } },
orderBy: { categoryName: 'asc' },
select: {
id: true,
categoryName: true,
sdsCategoryId: true,
parentCategoryId: true,
},
}),
this.prisma.originGood.findMany({ orderBy: { goodName: 'asc' } }),
this.prisma.good.groupBy({
by: ['originGoodId'],
_count: { _all: true },
}),
this.prisma.good.findMany({
select: {
originGoodId: true,
country: { select: { countryName: true } },
},
distinct: ['originGoodId', 'countryId'],
}),
this.prisma.goodTag.findMany({
select: {
good: { select: { originGoodId: true } },
tag: { select: { tagName: true, tagColor: true } },
},
}),
]);
const countMap = new Map<string, number>();
configCounts.forEach((c) =>
countMap.set(c.originGoodId.toString(), c._count._all),
);
const countryMap = new Map<string, string[]>();
goodsWithCountries.forEach((g) => {
const key = g.originGoodId.toString();
const name = g.country?.countryName;
if (!name) return;
const arr = countryMap.get(key);
if (arr) arr.push(name);
else countryMap.set(key, [name]);
});
const tagMap = new Map<string, { tagName: string; tagColor: string | null }[]>();
goodsWithTags.forEach((gt) => {
const key = gt.good.originGoodId.toString();
const tagInfo = { tagName: gt.tag.tagName, tagColor: gt.tag.tagColor };
const arr = tagMap.get(key);
if (arr) {
if (!arr.some((t) => t.tagName === tagInfo.tagName)) arr.push(tagInfo);
} else {
tagMap.set(key, [tagInfo]);
}
});
const sdsToCategory = new Map<
string,
(typeof allCategories)[number]
>();
for (const c of allCategories) {
if (c.sdsCategoryId) sdsToCategory.set(c.sdsCategoryId, c);
}
const ogToCategory = new Map<string, string>();
for (const og of allOriginGoods) {
if (og.sdsCategoryId && sdsToCategory.has(og.sdsCategoryId)) {
ogToCategory.set(og.id.toString(), sdsToCategory.get(og.sdsCategoryId)!.id.toString());
}
}
const buildNode = (
cat: (typeof allCategories)[number],
): OriginGoodsTreeCategoryNode => {
const childrenCats = allCategories.filter(
(c) => c.parentCategoryId !== null && c.parentCategoryId === cat.id,
);
const childNodes = childrenCats.map(buildNode);
const ogsForThisCat = allOriginGoods.filter(
(og) => ogToCategory.get(og.id.toString()) === cat.id.toString(),
);
const ogNodes: OriginGoodsTreeNode[] = ogsForThisCat.map((og) => ({
id: og.id.toString(),
goodName: og.goodName ?? `SDS-${og.sdsGoodId}`,
goodImage: og.goodImage,
goodPrice: og.goodPrice?.toString() ?? null,
sdsGoodId: og.sdsGoodId,
configuredCount: countMap.get(og.id.toString()) ?? 0,
configuredCountries: countryMap.get(og.id.toString()) ?? [],
configuredTags: tagMap.get(og.id.toString()) ?? [],
}));
const childTotal = childNodes.reduce((s, n) => s + n.totalCount, 0);
const childConfigured = childNodes.reduce(
(s, n) => s + n.configuredCount,
0,
);
const ogConfigured = ogNodes.filter((o) => o.configuredCount > 0).length;
return {
categoryId: cat.id.toString(),
categoryName: cat.categoryName,
sdsCategoryId: cat.sdsCategoryId,
configuredCount: childConfigured + ogConfigured,
totalCount: childTotal + ogNodes.length,
children: childNodes,
originGoods: ogNodes,
};
};
const roots = allCategories.filter((c) => c.parentCategoryId === null);
const tree = roots.map(buildNode);
const unmapped = allOriginGoods.filter(
(og) => !ogToCategory.has(og.id.toString()),
);
if (unmapped.length > 0) {
tree.push({
categoryId: 'uncategorized',
categoryName: '未分类',
sdsCategoryId: null,
configuredCount: unmapped.filter(
(og) => (countMap.get(og.id.toString()) ?? 0) > 0,
).length,
totalCount: unmapped.length,
children: [],
originGoods: unmapped.map((og) => ({
id: og.id.toString(),
goodName: og.goodName ?? `SDS-${og.sdsGoodId}`,
goodImage: og.goodImage,
goodPrice: og.goodPrice?.toString() ?? null,
sdsGoodId: og.sdsGoodId,
configuredCount: countMap.get(og.id.toString()) ?? 0,
configuredCountries: countryMap.get(og.id.toString()) ?? [],
configuredTags: tagMap.get(og.id.toString()) ?? [],
})),
});
}
tree.sort((a, b) => a.categoryName.localeCompare(b.categoryName, 'zh'));
const totalConfigured = allOriginGoods.filter(
(og) => (countMap.get(og.id.toString()) ?? 0) > 0,
).length;
return {
tree,
totalOriginGoods: allOriginGoods.length,
configuredCount: totalConfigured,
};
}
}
@@ -19,6 +19,9 @@ export class PublicGoodDto {
@ApiProperty({ nullable: true })
tag!: { id: string; tagName: string; tagColor: string | null } | null;
@ApiProperty({ type: Array })
tags!: Array<{ id: string; tagName: string; tagColor: string | null }>;
@ApiProperty({ nullable: true })
position!: { id: string; indexVal: number } | null;
@@ -15,6 +15,15 @@ export interface PublicPaginatedGoods {
pageSize: number;
}
const PUBLIC_GOOD_INCLUDE = {
country: true,
category: true,
tag: true,
position: true,
originGood: true,
goodTags: { include: { tag: true } },
} satisfies Prisma.GoodInclude;
@Injectable()
export class PublicService {
constructor(private readonly prisma: PrismaService) {}
@@ -38,7 +47,9 @@ export class PublicService {
async getGoods(query: PublicQueryGoodDto): Promise<PublicPaginatedGoods> {
const where: Prisma.GoodWhereInput = {};
if (query.countryId !== undefined) where.countryId = BigInt(query.countryId);
if (query.tagId !== undefined) where.tagId = BigInt(query.tagId);
if (query.tagId !== undefined) {
where.goodTags = { some: { tagId: BigInt(query.tagId) } };
}
if (query.keyword) {
where.goodName = { contains: query.keyword, mode: 'insensitive' };
}
@@ -51,13 +62,7 @@ export class PublicService {
this.prisma.good.count({ where }),
this.prisma.good.findMany({
where,
include: {
country: true,
category: true,
tag: true,
position: true,
originGood: true,
},
include: PUBLIC_GOOD_INCLUDE,
// Server-side primary sort; PublicGoodDto retains original indexes
// for stable pagination but the final ORDER BY is mirrored below.
orderBy: [
@@ -81,13 +86,7 @@ export class PublicService {
async getGood(id: bigint): Promise<PublicGoodDto> {
const good = await this.prisma.good.findUnique({
where: { id },
include: {
country: true,
category: true,
tag: true,
position: true,
originGood: true,
},
include: PUBLIC_GOOD_INCLUDE,
});
if (!good) throw new NotFoundException(`Good ${id} not found`);
return this.toPublicGood(good);
@@ -96,6 +95,7 @@ export class PublicService {
private toPublicGood(good: {
id: bigint;
goodName: string;
goodImage: string | null;
goodPriority: number;
country: { id: bigint; countryName: string; countryIcon: string | null };
category: { id: bigint; categoryName: string; categoryIcon: string | null };
@@ -105,6 +105,7 @@ export class PublicService {
goodImage: string | null;
goodPrice: { toString(): string } | null;
} | null;
goodTags: { tag: { id: bigint; tagName: string; tagColor: string | null } }[];
createdAt: Date;
}): PublicGoodDto {
return {
@@ -128,13 +129,18 @@ export class PublicService {
tagColor: good.tag.tagColor,
}
: null,
tags: good.goodTags.map((gt) => ({
id: gt.tag.id.toString(),
tagName: gt.tag.tagName,
tagColor: gt.tag.tagColor,
})),
position: good.position
? {
id: good.position.id.toString(),
indexVal: good.position.indexVal,
}
: null,
image: good.originGood?.goodImage ?? null,
image: good.goodImage ?? good.originGood?.goodImage ?? null,
price:
good.originGood?.goodPrice === null ||
good.originGood?.goodPrice === undefined