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:
@@ -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 })
|
||||
},
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user