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
|
// Get category tree
|
||||||
getCategoryTree: () => {
|
getCategoryTree: () => {
|
||||||
return request.get<any, CategoryTree[]>('/categories/tree')
|
return request.get<any, CategoryTree[]>('/categories')
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get category by id
|
// Get category by id
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import request from './request'
|
import request from './request'
|
||||||
import type { OriginGood } from '@/types'
|
import type { OriginGood, OriginGoodsTreeResponse, PaginatedResult } from '@/types'
|
||||||
|
|
||||||
export const originGoodsApi = {
|
export const originGoodsApi = {
|
||||||
// Search origin goods
|
getTree: () => {
|
||||||
searchOriginGoods: (keyword: string) => {
|
return request.get<any, OriginGoodsTreeResponse>('/origin-goods/tree')
|
||||||
return request.get<any, OriginGood[]>('/origin-goods/search', { params: { keyword } })
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get origin goods list
|
getOriginGoodsList: (params: { page?: number; pageSize?: number; keyword?: string }) => {
|
||||||
getOriginGoodsList: (page = 1, pageSize = 20) => {
|
return request.get<any, PaginatedResult<OriginGood>>('/origin-goods', { params })
|
||||||
return request.get<any, { data: OriginGood[]; total: number }>('/origin-goods', {
|
|
||||||
params: { page, pageSize },
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,12 @@ request.interceptors.request.use(
|
|||||||
// Response interceptor
|
// Response interceptor
|
||||||
request.interceptors.response.use(
|
request.interceptors.response.use(
|
||||||
(response: AxiosResponse) => {
|
(response: AxiosResponse) => {
|
||||||
// Unwrap data.data
|
// Backend wraps everything in { data, success } — unwrap to data
|
||||||
return response.data
|
const body = response.data
|
||||||
|
if (body && typeof body === 'object' && 'success' in body && 'data' in body) {
|
||||||
|
return body.data
|
||||||
|
}
|
||||||
|
return body
|
||||||
},
|
},
|
||||||
(error: AxiosError) => {
|
(error: AxiosError) => {
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
|
|||||||
@@ -1,24 +1,14 @@
|
|||||||
import request from './request'
|
import request from './request'
|
||||||
import type { SyncLog, SyncStats, PaginatedResult } from '@/types'
|
import type { SyncLog } from '@/types'
|
||||||
|
|
||||||
export const syncApi = {
|
export const syncApi = {
|
||||||
// Trigger category sync
|
|
||||||
syncCategories: () => {
|
|
||||||
return request.post<any, SyncLog>('/sync/categories')
|
|
||||||
},
|
|
||||||
|
|
||||||
// Trigger product sync
|
|
||||||
syncProducts: () => {
|
syncProducts: () => {
|
||||||
return request.post<any, SyncLog>('/sync/products')
|
return request.post<any, SyncLog>('/sync/products')
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get sync logs
|
getSyncStatus: (limit?: number) => {
|
||||||
getSyncLogs: (params: { page?: number; pageSize?: number; type?: 'CATEGORY' | 'PRODUCT' }) => {
|
return request.get<any, SyncLog[]>('/sync/status', {
|
||||||
return request.get<any, PaginatedResult<SyncLog>>('/sync/logs', { params })
|
params: limit ? { limit } : undefined,
|
||||||
},
|
})
|
||||||
|
|
||||||
// Get sync stats
|
|
||||||
getSyncStats: () => {
|
|
||||||
return request.get<any, SyncStats>('/sync/stats')
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+13
@@ -12,13 +12,18 @@ export {}
|
|||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
ElAside: typeof import('element-plus/es')['ElAside']
|
ElAside: typeof import('element-plus/es')['ElAside']
|
||||||
|
ElBadge: typeof import('element-plus/es')['ElBadge']
|
||||||
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||||
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
ElCard: typeof import('element-plus/es')['ElCard']
|
ElCard: typeof import('element-plus/es')['ElCard']
|
||||||
ElCascader: typeof import('element-plus/es')['ElCascader']
|
ElCascader: typeof import('element-plus/es')['ElCascader']
|
||||||
|
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||||
|
ElCol: typeof import('element-plus/es')['ElCol']
|
||||||
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||||
|
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||||
|
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||||
@@ -36,10 +41,18 @@ declare module 'vue' {
|
|||||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
ElOption: typeof import('element-plus/es')['ElOption']
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
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']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
ElTable: typeof import('element-plus/es')['ElTable']
|
ElTable: typeof import('element-plus/es')['ElTable']
|
||||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||||
|
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||||
|
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||||
ElTag: typeof import('element-plus/es')['ElTag']
|
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']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,6 @@ import { useRouter, useRoute } from 'vue-router'
|
|||||||
import { ElMessageBox } from 'element-plus'
|
import { ElMessageBox } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
Goods,
|
Goods,
|
||||||
Menu,
|
|
||||||
Location,
|
|
||||||
CollectionTag,
|
|
||||||
Sort,
|
|
||||||
Refresh,
|
|
||||||
Expand,
|
Expand,
|
||||||
Fold,
|
Fold,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
@@ -30,21 +25,11 @@ interface MenuItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const menuItems = ref<MenuItem[]>([
|
const menuItems = ref<MenuItem[]>([
|
||||||
{ index: '/goods', title: 'Goods', icon: 'Goods' },
|
{ index: '/goods', title: '商品管理', 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' },
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const iconMap: Record<string, unknown> = {
|
const iconMap: Record<string, unknown> = {
|
||||||
Goods,
|
Goods,
|
||||||
Menu,
|
|
||||||
Location,
|
|
||||||
CollectionTag,
|
|
||||||
Sort,
|
|
||||||
Refresh,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeMenu = computed(() => route.path)
|
const activeMenu = computed(() => route.path)
|
||||||
@@ -53,7 +38,7 @@ const username = computed(() => authStore.user?.username || 'Admin')
|
|||||||
|
|
||||||
const breadcrumb = computed(() => {
|
const breadcrumb = computed(() => {
|
||||||
const meta = route.meta as { title?: string } | undefined
|
const meta = route.meta as { title?: string } | undefined
|
||||||
return meta?.title || 'Dashboard'
|
return meta?.title || '商品管理'
|
||||||
})
|
})
|
||||||
|
|
||||||
function toggleSidebar() {
|
function toggleSidebar() {
|
||||||
@@ -62,9 +47,9 @@ function toggleSidebar() {
|
|||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm('Are you sure to sign out?', 'Sign out', {
|
await ElMessageBox.confirm('确定退出登录吗?', '退出登录', {
|
||||||
confirmButtonText: 'Sign out',
|
confirmButtonText: '退出登录',
|
||||||
cancelButtonText: 'Cancel',
|
cancelButtonText: '取消',
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
@@ -86,11 +71,11 @@ function handleCommand(command: string) {
|
|||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<el-aside :width="collapsed ? '64px' : '240px'" class="layout-aside">
|
<el-aside :width="collapsed ? '64px' : '240px'" class="layout-aside">
|
||||||
<div class="brand" :class="{ collapsed }">
|
<div class="brand" :class="{ collapsed }">
|
||||||
<div class="brand-mark">IR</div>
|
<div class="brand-mark">IR</div>
|
||||||
<div v-if="!collapsed" class="brand-text">
|
<div v-if="!collapsed" class="brand-text">
|
||||||
<div class="brand-name">InkReach</div>
|
<div class="brand-name">Inkreach</div>
|
||||||
<div class="brand-tag">Product Center</div>
|
<div class="brand-tag">官网后台</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-menu
|
<el-menu
|
||||||
@@ -118,7 +103,7 @@ function handleCommand(command: string) {
|
|||||||
<el-button
|
<el-button
|
||||||
text
|
text
|
||||||
class="collapse-btn"
|
class="collapse-btn"
|
||||||
:title="collapsed ? 'Expand sidebar' : 'Collapse sidebar'"
|
:title="collapsed ? '展开侧边栏' : '折叠侧边栏'"
|
||||||
@click="toggleSidebar"
|
@click="toggleSidebar"
|
||||||
>
|
>
|
||||||
<el-icon :size="20">
|
<el-icon :size="20">
|
||||||
@@ -127,7 +112,7 @@ function handleCommand(command: string) {
|
|||||||
</el-button>
|
</el-button>
|
||||||
|
|
||||||
<el-breadcrumb separator="/" class="breadcrumb">
|
<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-item>{{ breadcrumb }}</el-breadcrumb-item>
|
||||||
</el-breadcrumb>
|
</el-breadcrumb>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,7 +128,7 @@ function handleCommand(command: string) {
|
|||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
<el-dropdown-item command="logout">
|
<el-dropdown-item command="logout">
|
||||||
<el-icon><SwitchButton /></el-icon>
|
<el-icon><SwitchButton /></el-icon>
|
||||||
<span>Sign out</span>
|
<span>退出登录</span>
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
path: '/login',
|
path: '/login',
|
||||||
name: 'Login',
|
name: 'Login',
|
||||||
component: () => import('@/views/login/LoginView.vue'),
|
component: () => import('@/views/login/LoginView.vue'),
|
||||||
meta: { title: 'Login', public: true },
|
meta: { title: '登录', public: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -16,38 +16,8 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{
|
{
|
||||||
path: 'goods',
|
path: 'goods',
|
||||||
name: 'Goods',
|
name: 'Goods',
|
||||||
component: () => import('@/views/goods/GoodsView.vue'),
|
component: () => import('@/views/product-management/ProductManagementView.vue'),
|
||||||
meta: { title: 'Goods', icon: 'Goods' },
|
meta: { title: '商品管理' },
|
||||||
},
|
|
||||||
{
|
|
||||||
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' },
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -78,8 +48,8 @@ router.beforeEach((to) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
router.afterEach((to) => {
|
router.afterEach((to) => {
|
||||||
const title = (to.meta?.title as string) || 'InkReach Admin'
|
const title = (to.meta?.title as string) || 'Inkreach 官网后台'
|
||||||
document.title = `${title} | InkReach Admin`
|
document.title = `${title} | Inkreach 官网后台`
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -42,10 +42,10 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
async function login(payload: LoginRequest) {
|
async function login(payload: LoginRequest) {
|
||||||
const res = await authApi.login(payload) as any;
|
const res = await authApi.login(payload) as any;
|
||||||
const accessToken = res.data?.accessToken ?? res.accessToken ?? res.token;
|
const accessToken: string = res.accessToken ?? res.data?.accessToken ?? '';
|
||||||
const userData = res.data?.user ?? res.user;
|
const userData: User | null = res.user ?? res.data?.user ?? null;
|
||||||
setToken(accessToken);
|
if (accessToken) setToken(accessToken);
|
||||||
setUser(userData);
|
if (userData) setUser(userData);
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Common types
|
// Common types
|
||||||
export interface PaginatedResult<T> {
|
export interface PaginatedResult<T> {
|
||||||
data: T[]
|
items: T[]
|
||||||
total: number
|
total: number
|
||||||
page: number
|
page: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
@@ -13,7 +13,7 @@ export interface LoginRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
token: string
|
accessToken: string
|
||||||
user: User
|
user: User
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,49 +26,58 @@ export interface User {
|
|||||||
// Good types
|
// Good types
|
||||||
export interface Good {
|
export interface Good {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
goodName: string
|
||||||
|
goodImage: string | null
|
||||||
|
goodPriority: number
|
||||||
originGoodId: string
|
originGoodId: string
|
||||||
originGood?: OriginGood
|
originGood?: OriginGood
|
||||||
countryId: string
|
countryId: string
|
||||||
country?: Country
|
country?: Country
|
||||||
categoryId: string
|
categoryId: string
|
||||||
category?: Category
|
category?: Category
|
||||||
tagId: string
|
tagId?: string
|
||||||
tag?: Tag
|
tag?: Tag
|
||||||
|
tags: Tag[]
|
||||||
positionId?: string
|
positionId?: string
|
||||||
position?: Position
|
position?: Position
|
||||||
priority: number
|
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateGoodRequest {
|
export interface CreateGoodRequest {
|
||||||
name: string
|
goodName: string
|
||||||
originGoodId: string
|
goodImage?: string
|
||||||
countryId: string
|
originGoodId: number
|
||||||
categoryId: string
|
countryId: number
|
||||||
tagId: string
|
categoryId: number
|
||||||
positionId?: string
|
tagIds?: number[]
|
||||||
priority: number
|
positionId?: number
|
||||||
|
goodPriority?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateGoodRequest {
|
export interface UpdateGoodRequest {
|
||||||
name?: string
|
goodName?: string
|
||||||
originGoodId?: string
|
goodImage?: string | null
|
||||||
countryId?: string
|
originGoodId?: number
|
||||||
categoryId?: string
|
countryId?: number
|
||||||
tagId?: string
|
categoryId?: number
|
||||||
positionId?: string
|
tagIds?: number[]
|
||||||
|
positionId?: number | null
|
||||||
|
goodPriority?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchCreateItem {
|
||||||
|
originGoodId: number
|
||||||
priority?: number
|
priority?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BatchCreateGoodsRequest {
|
export interface BatchCreateGoodsRequest {
|
||||||
originGoodIds: string[]
|
items: BatchCreateItem[]
|
||||||
countryId: string
|
countryId: number
|
||||||
categoryId: string
|
categoryId: number
|
||||||
tagId: string
|
tagIds?: number[]
|
||||||
positionId?: string
|
positionId?: number
|
||||||
priority: number
|
defaultPriority?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdatePriorityRequest {
|
export interface UpdatePriorityRequest {
|
||||||
@@ -79,28 +88,29 @@ export interface UpdatePriorityRequest {
|
|||||||
// Country types
|
// Country types
|
||||||
export interface Country {
|
export interface Country {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
countryName: string
|
||||||
icon?: string
|
countryIcon?: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateCountryRequest {
|
export interface CreateCountryRequest {
|
||||||
name: string
|
countryName: string
|
||||||
icon?: string
|
countryIcon?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateCountryRequest {
|
export interface UpdateCountryRequest {
|
||||||
name?: string
|
countryName?: string
|
||||||
icon?: string
|
countryIcon?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Category types
|
// Category types
|
||||||
export interface Category {
|
export interface Category {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
categoryName: string
|
||||||
icon?: string
|
categoryIcon?: string | null
|
||||||
parentId?: string
|
sdsCategoryId?: string | null
|
||||||
|
parentCategoryId?: string | null
|
||||||
parent?: Category
|
parent?: Category
|
||||||
children?: Category[]
|
children?: Category[]
|
||||||
_count?: {
|
_count?: {
|
||||||
@@ -111,15 +121,15 @@ export interface Category {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateCategoryRequest {
|
export interface CreateCategoryRequest {
|
||||||
name: string
|
categoryName: string
|
||||||
icon?: string
|
categoryIcon?: string
|
||||||
parentId?: string
|
parentCategoryId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateCategoryRequest {
|
export interface UpdateCategoryRequest {
|
||||||
name?: string
|
categoryName?: string
|
||||||
icon?: string
|
categoryIcon?: string | null
|
||||||
parentId?: string
|
parentCategoryId?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CategoryTree extends Category {
|
export interface CategoryTree extends Category {
|
||||||
@@ -129,23 +139,23 @@ export interface CategoryTree extends Category {
|
|||||||
// Tag types
|
// Tag types
|
||||||
export interface Tag {
|
export interface Tag {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
tagName: string
|
||||||
color?: string
|
tagColor?: string | null
|
||||||
timing?: string
|
timing?: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateTagRequest {
|
export interface CreateTagRequest {
|
||||||
name: string
|
tagName: string
|
||||||
color?: string
|
tagColor?: string
|
||||||
timing?: string
|
timing?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateTagRequest {
|
export interface UpdateTagRequest {
|
||||||
name?: string
|
tagName?: string
|
||||||
color?: string
|
tagColor?: string | null
|
||||||
timing?: string
|
timing?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position types
|
// Position types
|
||||||
@@ -162,26 +172,56 @@ export interface Position {
|
|||||||
|
|
||||||
export interface CreatePositionRequest {
|
export interface CreatePositionRequest {
|
||||||
indexVal: number
|
indexVal: number
|
||||||
countryId?: string
|
countryId?: number
|
||||||
categoryId?: string
|
categoryId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdatePositionRequest {
|
export interface UpdatePositionRequest {
|
||||||
indexVal?: number
|
indexVal?: number
|
||||||
countryId?: string
|
countryId?: number | null
|
||||||
categoryId?: string
|
categoryId?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Origin Good types
|
// Origin Good types
|
||||||
export interface OriginGood {
|
export interface OriginGood {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
goodName: string | null
|
||||||
productId: string
|
goodImage: string | null
|
||||||
categoryId?: string
|
goodPrice: string | null
|
||||||
|
sdsGoodId: string
|
||||||
|
sdsCategoryId: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: 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
|
// Sync types
|
||||||
export interface SyncLog {
|
export interface SyncLog {
|
||||||
id: string
|
id: string
|
||||||
@@ -194,12 +234,6 @@ export interface SyncLog {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncStats {
|
|
||||||
lastSyncTime?: string
|
|
||||||
status?: 'IDLE' | 'SYNCING'
|
|
||||||
errorCount?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter types
|
// Filter types
|
||||||
export interface GoodsFilter {
|
export interface GoodsFilter {
|
||||||
countryId?: string
|
countryId?: string
|
||||||
@@ -211,20 +245,20 @@ export interface GoodsFilter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CategoryFilter {
|
export interface CategoryFilter {
|
||||||
parentId?: string
|
parentCategoryId?: string
|
||||||
name?: string
|
categoryName?: string
|
||||||
page?: number
|
page?: number
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CountryFilter {
|
export interface CountryFilter {
|
||||||
name?: string
|
countryName?: string
|
||||||
page?: number
|
page?: number
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TagFilter {
|
export interface TagFilter {
|
||||||
name?: string
|
tagName?: string
|
||||||
page?: number
|
page?: number
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
}
|
}
|
||||||
@@ -234,4 +268,4 @@ export interface PositionFilter {
|
|||||||
categoryId?: string
|
categoryId?: string
|
||||||
page?: number
|
page?: number
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const cascaderOptions = ref<CascaderNode[]>([])
|
|||||||
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
|
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
|
||||||
return nodes.map((n) => ({
|
return nodes.map((n) => ({
|
||||||
value: n.id,
|
value: n.id,
|
||||||
label: n.name,
|
label: n.categoryName,
|
||||||
children: n.children?.length ? buildCascader(n.children) : undefined,
|
children: n.children?.length ? buildCascader(n.children) : undefined,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -50,18 +50,18 @@ const dialogMode = ref<'create' | 'edit'>('create')
|
|||||||
const dialogLoading = ref(false)
|
const dialogLoading = ref(false)
|
||||||
|
|
||||||
const dialogForm = reactive<CreateCategoryRequest & { id?: string }>({
|
const dialogForm = reactive<CreateCategoryRequest & { id?: string }>({
|
||||||
name: '',
|
categoryName: '',
|
||||||
icon: '',
|
categoryIcon: '',
|
||||||
parentId: '',
|
parentCategoryId: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const dialogRules = {
|
const dialogRules = {
|
||||||
name: [{ required: true, message: 'Name is required', trigger: 'blur' }],
|
categoryName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openAddDialog() {
|
async function openAddDialog() {
|
||||||
dialogMode.value = 'create'
|
dialogMode.value = 'create'
|
||||||
Object.assign(dialogForm, { id: undefined, name: '', icon: '', parentId: '' })
|
Object.assign(dialogForm, { id: undefined, categoryName: '', categoryIcon: '', parentCategoryId: '' })
|
||||||
rebuildCascader()
|
rebuildCascader()
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
@@ -70,9 +70,9 @@ async function openEditDialog(c: Category) {
|
|||||||
dialogMode.value = 'edit'
|
dialogMode.value = 'edit'
|
||||||
Object.assign(dialogForm, {
|
Object.assign(dialogForm, {
|
||||||
id: c.id,
|
id: c.id,
|
||||||
name: c.name,
|
categoryName: c.categoryName,
|
||||||
icon: c.icon || '',
|
categoryIcon: c.categoryIcon || '',
|
||||||
parentId: c.parentId || '',
|
parentCategoryId: c.parentCategoryId || '',
|
||||||
})
|
})
|
||||||
rebuildCascader()
|
rebuildCascader()
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
@@ -85,16 +85,16 @@ async function handleSubmit() {
|
|||||||
dialogLoading.value = true
|
dialogLoading.value = true
|
||||||
try {
|
try {
|
||||||
const payload: CreateCategoryRequest = {
|
const payload: CreateCategoryRequest = {
|
||||||
name: dialogForm.name,
|
categoryName: dialogForm.categoryName,
|
||||||
icon: dialogForm.icon || undefined,
|
categoryIcon: dialogForm.categoryIcon || undefined,
|
||||||
parentId: dialogForm.parentId || undefined,
|
parentCategoryId: dialogForm.parentCategoryId || undefined,
|
||||||
}
|
}
|
||||||
if (dialogMode.value === 'create') {
|
if (dialogMode.value === 'create') {
|
||||||
await categoriesApi.createCategory(payload)
|
await categoriesApi.createCategory(payload)
|
||||||
ElMessage.success('Category created')
|
ElMessage.success('分类创建成功')
|
||||||
} else {
|
} else {
|
||||||
await categoriesApi.updateCategory(dialogForm.id!, payload as UpdateCategoryRequest)
|
await categoriesApi.updateCategory(dialogForm.id!, payload as UpdateCategoryRequest)
|
||||||
ElMessage.success('Category updated')
|
ElMessage.success('分类更新成功')
|
||||||
}
|
}
|
||||||
dialogVisible.value = false
|
dialogVisible.value = false
|
||||||
fetchTree()
|
fetchTree()
|
||||||
@@ -106,16 +106,16 @@ async function handleSubmit() {
|
|||||||
|
|
||||||
async function handleDelete(c: Category) {
|
async function handleDelete(c: Category) {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(`Delete "${c.name}"?`, 'Confirm', {
|
await ElMessageBox.confirm(`确定删除「${c.categoryName}」吗?`, '确认', {
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
confirmButtonText: 'Delete',
|
confirmButtonText: '删除',
|
||||||
cancelButtonText: 'Cancel',
|
cancelButtonText: '取消',
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await categoriesApi.deleteCategory(c.id)
|
await categoriesApi.deleteCategory(c.id)
|
||||||
ElMessage.success('Deleted')
|
ElMessage.success('删除成功')
|
||||||
fetchTree()
|
fetchTree()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,11 +128,11 @@ onMounted(fetchTree)
|
|||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<el-button type="primary" @click="openAddDialog">
|
<el-button type="primary" @click="openAddDialog">
|
||||||
<el-icon><Plus /></el-icon>
|
<el-icon><Plus /></el-icon>
|
||||||
<span>Add Category</span>
|
<span>新增分类</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button @click="fetchTree">
|
<el-button @click="fetchTree">
|
||||||
<el-icon><Refresh /></el-icon>
|
<el-icon><Refresh /></el-icon>
|
||||||
<span>Refresh</span>
|
<span>刷新</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -144,52 +144,52 @@ onMounted(fetchTree)
|
|||||||
:tree-props="{ children: 'children' }"
|
:tree-props="{ children: 'children' }"
|
||||||
default-expand-all
|
default-expand-all
|
||||||
>
|
>
|
||||||
<el-table-column prop="name" label="Name" min-width="220" />
|
<el-table-column prop="categoryName" label="名称" min-width="220" />
|
||||||
<el-table-column label="Icon" width="100">
|
<el-table-column label="图标" width="100">
|
||||||
<template #default="{ row }: { row: CategoryTree }">
|
<template #default="{ row }: { row: CategoryTree }">
|
||||||
<el-image
|
<el-image
|
||||||
v-if="row.icon"
|
v-if="row.categoryIcon"
|
||||||
:src="row.icon"
|
:src="row.categoryIcon"
|
||||||
:preview-src-list="[row.icon]"
|
:preview-src-list="[row.categoryIcon]"
|
||||||
fit="cover"
|
fit="cover"
|
||||||
style="width: 32px; height: 32px; border-radius: 4px;"
|
style="width: 32px; height: 32px; border-radius: 4px;"
|
||||||
/>
|
/>
|
||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="Parent">
|
<el-table-column label="父级">
|
||||||
<template #default="{ row }: { row: CategoryTree }">
|
<template #default="{ row }: { row: CategoryTree }">
|
||||||
{{ row.parent?.name || '-' }}
|
{{ row.parent?.categoryName || '-' }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="Children" width="100">
|
<el-table-column label="子级数" width="100">
|
||||||
<template #default="{ row }: { row: CategoryTree }">
|
<template #default="{ row }: { row: CategoryTree }">
|
||||||
{{ row._count?.children ?? row.children?.length ?? 0 }}
|
{{ row._count?.children ?? row.children?.length ?? 0 }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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 }">
|
<template #default="{ row }: { row: CategoryTree }">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||||
<el-icon><Edit /></el-icon>
|
<el-icon><Edit /></el-icon>
|
||||||
<span>Edit</span>
|
<span>编辑</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||||
<el-icon><Delete /></el-icon>
|
<el-icon><Delete /></el-icon>
|
||||||
<span>Delete</span>
|
<span>删除</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<template #empty>
|
<template #empty>
|
||||||
<el-empty description="No categories" />
|
<el-empty description="暂无分类" />
|
||||||
</template>
|
</template>
|
||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="dialogVisible"
|
v-model="dialogVisible"
|
||||||
:title="dialogMode === 'create' ? 'Add Category' : 'Edit Category'"
|
:title="dialogMode === 'create' ? '新增分类' : '编辑分类'"
|
||||||
width="520px"
|
width="520px"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
>
|
>
|
||||||
@@ -199,12 +199,12 @@ onMounted(fetchTree)
|
|||||||
:rules="dialogRules"
|
:rules="dialogRules"
|
||||||
label-width="100px"
|
label-width="100px"
|
||||||
>
|
>
|
||||||
<el-form-item label="Name" prop="name">
|
<el-form-item label="名称" prop="categoryName">
|
||||||
<el-input v-model="dialogForm.name" placeholder="Category name" />
|
<el-input v-model="dialogForm.categoryName" placeholder="请输入分类名称" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="Parent">
|
<el-form-item label="父级分类">
|
||||||
<el-cascader
|
<el-cascader
|
||||||
v-model="dialogForm.parentId"
|
v-model="dialogForm.parentCategoryId"
|
||||||
:options="cascaderOptions"
|
:options="cascaderOptions"
|
||||||
:props="{
|
:props="{
|
||||||
checkStrictly: true,
|
checkStrictly: true,
|
||||||
@@ -213,19 +213,19 @@ onMounted(fetchTree)
|
|||||||
children: 'children',
|
children: 'children',
|
||||||
emitPath: false,
|
emitPath: false,
|
||||||
}"
|
}"
|
||||||
placeholder="Top-level (optional)"
|
placeholder="顶级(可选)"
|
||||||
clearable
|
clearable
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="Icon URL">
|
<el-form-item label="图标 URL">
|
||||||
<el-input v-model="dialogForm.icon" placeholder="https://..." />
|
<el-input v-model="dialogForm.categoryIcon" placeholder="https://..." />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<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">
|
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||||
{{ dialogMode === 'create' ? 'Create' : 'Save' }}
|
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const list = ref<Country[]>([])
|
|||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
|
|
||||||
const filter = reactive<Required<CountryFilter>>({
|
const filter = reactive<Required<CountryFilter>>({
|
||||||
name: '',
|
countryName: '',
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
})
|
})
|
||||||
@@ -24,8 +24,10 @@ async function fetchList() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await countriesApi.getCountriesList(filter)
|
const res = await countriesApi.getCountriesList(filter)
|
||||||
list.value = res.data
|
const data = await countriesApi.getCountriesList(filter) as any
|
||||||
total.value = res.total
|
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||||
|
list.value = arr
|
||||||
|
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -37,7 +39,7 @@ function handleSearch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleReset() {
|
function handleReset() {
|
||||||
filter.name = ''
|
filter.countryName = ''
|
||||||
filter.page = 1
|
filter.page = 1
|
||||||
fetchList()
|
fetchList()
|
||||||
}
|
}
|
||||||
@@ -48,23 +50,23 @@ const dialogMode = ref<'create' | 'edit'>('create')
|
|||||||
const dialogLoading = ref(false)
|
const dialogLoading = ref(false)
|
||||||
|
|
||||||
const dialogForm = reactive<CreateCountryRequest & { id?: string }>({
|
const dialogForm = reactive<CreateCountryRequest & { id?: string }>({
|
||||||
name: '',
|
countryName: '',
|
||||||
icon: '',
|
countryIcon: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const dialogRules = {
|
const dialogRules = {
|
||||||
name: [{ required: true, message: 'Name is required', trigger: 'blur' }],
|
countryName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAddDialog() {
|
function openAddDialog() {
|
||||||
dialogMode.value = 'create'
|
dialogMode.value = 'create'
|
||||||
Object.assign(dialogForm, { id: undefined, name: '', icon: '' })
|
Object.assign(dialogForm, { id: undefined, countryName: '', countryIcon: '' })
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEditDialog(c: Country) {
|
function openEditDialog(c: Country) {
|
||||||
dialogMode.value = 'edit'
|
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
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,15 +77,15 @@ async function handleSubmit() {
|
|||||||
dialogLoading.value = true
|
dialogLoading.value = true
|
||||||
try {
|
try {
|
||||||
const payload: CreateCountryRequest = {
|
const payload: CreateCountryRequest = {
|
||||||
name: dialogForm.name,
|
countryName: dialogForm.countryName,
|
||||||
icon: dialogForm.icon || undefined,
|
countryIcon: dialogForm.countryIcon || undefined,
|
||||||
}
|
}
|
||||||
if (dialogMode.value === 'create') {
|
if (dialogMode.value === 'create') {
|
||||||
await countriesApi.createCountry(payload)
|
await countriesApi.createCountry(payload)
|
||||||
ElMessage.success('Country created')
|
ElMessage.success('国家创建成功')
|
||||||
} else {
|
} else {
|
||||||
await countriesApi.updateCountry(dialogForm.id!, payload as UpdateCountryRequest)
|
await countriesApi.updateCountry(dialogForm.id!, payload as UpdateCountryRequest)
|
||||||
ElMessage.success('Country updated')
|
ElMessage.success('国家更新成功')
|
||||||
}
|
}
|
||||||
dialogVisible.value = false
|
dialogVisible.value = false
|
||||||
fetchList()
|
fetchList()
|
||||||
@@ -95,16 +97,16 @@ async function handleSubmit() {
|
|||||||
|
|
||||||
async function handleDelete(c: Country) {
|
async function handleDelete(c: Country) {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(`Delete "${c.name}"?`, 'Confirm', {
|
await ElMessageBox.confirm(`确定删除「${c.countryName}」吗?`, '确认', {
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
confirmButtonText: 'Delete',
|
confirmButtonText: '删除',
|
||||||
cancelButtonText: 'Cancel',
|
cancelButtonText: '取消',
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await countriesApi.deleteCountry(c.id)
|
await countriesApi.deleteCountry(c.id)
|
||||||
ElMessage.success('Deleted')
|
ElMessage.success('删除成功')
|
||||||
fetchList()
|
fetchList()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,8 +118,8 @@ onMounted(fetchList)
|
|||||||
<div class="page-card">
|
<div class="page-card">
|
||||||
<div class="filter-bar">
|
<div class="filter-bar">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="filter.name"
|
v-model="filter.countryName"
|
||||||
placeholder="Search by name"
|
placeholder="按名称搜索"
|
||||||
clearable
|
clearable
|
||||||
@keyup.enter="handleSearch"
|
@keyup.enter="handleSearch"
|
||||||
@clear="handleSearch"
|
@clear="handleSearch"
|
||||||
@@ -128,56 +130,56 @@ onMounted(fetchList)
|
|||||||
</el-input>
|
</el-input>
|
||||||
<el-button type="primary" @click="handleSearch">
|
<el-button type="primary" @click="handleSearch">
|
||||||
<el-icon><Search /></el-icon>
|
<el-icon><Search /></el-icon>
|
||||||
<span>Search</span>
|
<span>搜索</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button @click="handleReset">
|
<el-button @click="handleReset">
|
||||||
<el-icon><Refresh /></el-icon>
|
<el-icon><Refresh /></el-icon>
|
||||||
<span>Reset</span>
|
<span>重置</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
|
|
||||||
<div class="filter-spacer" />
|
<div class="filter-spacer" />
|
||||||
|
|
||||||
<el-button type="primary" @click="openAddDialog">
|
<el-button type="primary" @click="openAddDialog">
|
||||||
<el-icon><Plus /></el-icon>
|
<el-icon><Plus /></el-icon>
|
||||||
<span>Add Country</span>
|
<span>新增国家</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="list" border stripe>
|
<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 }">
|
<template #default="{ row }: { row: Country }">
|
||||||
<el-image
|
<el-image
|
||||||
v-if="row.icon"
|
v-if="row.countryIcon"
|
||||||
:src="row.icon"
|
:src="row.countryIcon"
|
||||||
:preview-src-list="[row.icon]"
|
:preview-src-list="[row.countryIcon]"
|
||||||
fit="cover"
|
fit="cover"
|
||||||
style="width: 32px; height: 32px; border-radius: 4px;"
|
style="width: 32px; height: 32px; border-radius: 4px;"
|
||||||
/>
|
/>
|
||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="name" label="Name" min-width="200" />
|
<el-table-column prop="countryName" label="名称" min-width="200" />
|
||||||
<el-table-column label="Created" width="180">
|
<el-table-column label="创建时间" width="180">
|
||||||
<template #default="{ row }: { row: Country }">
|
<template #default="{ row }: { row: Country }">
|
||||||
{{ new Date(row.createdAt).toLocaleString() }}
|
{{ new Date(row.createdAt).toLocaleString() }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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 }">
|
<template #default="{ row }: { row: Country }">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||||
<el-icon><Edit /></el-icon>
|
<el-icon><Edit /></el-icon>
|
||||||
<span>Edit</span>
|
<span>编辑</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||||
<el-icon><Delete /></el-icon>
|
<el-icon><Delete /></el-icon>
|
||||||
<span>Delete</span>
|
<span>删除</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<template #empty>
|
<template #empty>
|
||||||
<el-empty description="No countries" />
|
<el-empty description="暂无国家" />
|
||||||
</template>
|
</template>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
@@ -195,22 +197,22 @@ onMounted(fetchList)
|
|||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="dialogVisible"
|
v-model="dialogVisible"
|
||||||
:title="dialogMode === 'create' ? 'Add Country' : 'Edit Country'"
|
:title="dialogMode === 'create' ? '新增国家' : '编辑国家'"
|
||||||
width="480px"
|
width="480px"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
>
|
>
|
||||||
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
||||||
<el-form-item label="Name" prop="name">
|
<el-form-item label="名称" prop="countryName">
|
||||||
<el-input v-model="dialogForm.name" placeholder="Country name" />
|
<el-input v-model="dialogForm.countryName" placeholder="请输入国家名称" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="Icon URL">
|
<el-form-item label="图标 URL">
|
||||||
<el-input v-model="dialogForm.icon" placeholder="https://..." />
|
<el-input v-model="dialogForm.countryIcon" placeholder="https://..." />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<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">
|
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||||
{{ dialogMode === 'create' ? 'Create' : 'Save' }}
|
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -17,12 +17,12 @@ const form = reactive({
|
|||||||
|
|
||||||
const rules: FormRules = {
|
const rules: FormRules = {
|
||||||
username: [
|
username: [
|
||||||
{ required: true, message: 'Please enter your username', trigger: 'blur' },
|
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||||
{ min: 2, max: 64, message: 'Length 2-64', trigger: 'blur' },
|
{ min: 2, max: 64, message: '长度 2-64', trigger: 'blur' },
|
||||||
],
|
],
|
||||||
password: [
|
password: [
|
||||||
{ required: true, message: 'Please enter your password', trigger: 'blur' },
|
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||||
{ min: 4, max: 64, message: 'Length 4-64', trigger: 'blur' },
|
{ min: 4, max: 64, message: '长度 4-64', trigger: 'blur' },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ async function handleSubmit() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await authStore.login({ username: form.username, password: form.password })
|
await authStore.login({ username: form.username, password: form.password })
|
||||||
ElMessage.success('Login successful')
|
ElMessage.success('登录成功')
|
||||||
router.push('/')
|
router.push('/')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Error toast is shown by axios response interceptor
|
// Error toast is shown by axios response interceptor
|
||||||
@@ -50,8 +50,8 @@ async function handleSubmit() {
|
|||||||
<div class="login-bg" />
|
<div class="login-bg" />
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<div class="login-brand">
|
<div class="login-brand">
|
||||||
<div class="brand-logo">InkReach</div>
|
<div class="brand-logo">Inkreach</div>
|
||||||
<div class="brand-subtitle">Product Center · Admin Console</div>
|
<div class="brand-subtitle">官网后台</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-form
|
<el-form
|
||||||
@@ -62,10 +62,10 @@ async function handleSubmit() {
|
|||||||
label-position="top"
|
label-position="top"
|
||||||
@submit.prevent="handleSubmit"
|
@submit.prevent="handleSubmit"
|
||||||
>
|
>
|
||||||
<el-form-item label="Username" prop="username">
|
<el-form-item label="用户名" prop="username">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="form.username"
|
v-model="form.username"
|
||||||
placeholder="Enter your username"
|
placeholder="请输入用户名"
|
||||||
clearable
|
clearable
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
>
|
>
|
||||||
@@ -75,11 +75,11 @@ async function handleSubmit() {
|
|||||||
</el-input>
|
</el-input>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="Password" prop="password">
|
<el-form-item label="密码" prop="password">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="form.password"
|
v-model="form.password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Enter your password"
|
placeholder="请输入密码"
|
||||||
show-password
|
show-password
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
@keyup.enter="handleSubmit"
|
@keyup.enter="handleSubmit"
|
||||||
@@ -98,13 +98,13 @@ async function handleSubmit() {
|
|||||||
native-type="submit"
|
native-type="submit"
|
||||||
@click="handleSubmit"
|
@click="handleSubmit"
|
||||||
>
|
>
|
||||||
Sign in
|
登录
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<div class="login-footer">
|
<div class="login-footer">
|
||||||
<span>© {{ new Date().getFullYear() }} InkReach</span>
|
<span>© {{ new Date().getFullYear() }} Inkreach</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ async function loadLookups() {
|
|||||||
countriesApi.getCountriesList({ page: 1, pageSize: 500 }),
|
countriesApi.getCountriesList({ page: 1, pageSize: 500 }),
|
||||||
categoriesApi.getCategoryTree(),
|
categoriesApi.getCategoryTree(),
|
||||||
])
|
])
|
||||||
countries.value = c.data
|
countries.value = c.items
|
||||||
categoriesTree.value = ct
|
categoriesTree.value = ct
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,8 +42,10 @@ async function fetchList() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await positionsApi.getPositionsList(filter)
|
const res = await positionsApi.getPositionsList(filter)
|
||||||
list.value = res.data
|
const data = await positionsApi.getPositionsList(filter) as any
|
||||||
total.value = res.total
|
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||||
|
list.value = arr
|
||||||
|
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -73,7 +75,7 @@ const cascaderOptions = ref<CascaderNode[]>([])
|
|||||||
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
|
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
|
||||||
return nodes.map((n) => ({
|
return nodes.map((n) => ({
|
||||||
value: n.id,
|
value: n.id,
|
||||||
label: n.name,
|
label: n.categoryName,
|
||||||
children: n.children?.length ? buildCascader(n.children) : undefined,
|
children: n.children?.length ? buildCascader(n.children) : undefined,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -91,7 +93,7 @@ const dialogForm = reactive<CreatePositionRequest & { id?: string }>({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const dialogRules = {
|
const dialogRules = {
|
||||||
indexVal: [{ required: true, message: 'Index is required', trigger: 'blur' }],
|
indexVal: [{ required: true, message: '排序值是必填项', trigger: 'blur' }],
|
||||||
}
|
}
|
||||||
|
|
||||||
function rebuildCascader() {
|
function rebuildCascader() {
|
||||||
@@ -130,10 +132,10 @@ async function handleSubmit() {
|
|||||||
}
|
}
|
||||||
if (dialogMode.value === 'create') {
|
if (dialogMode.value === 'create') {
|
||||||
await positionsApi.createPosition(payload)
|
await positionsApi.createPosition(payload)
|
||||||
ElMessage.success('Position created')
|
ElMessage.success('位置创建成功')
|
||||||
} else {
|
} else {
|
||||||
await positionsApi.updatePosition(dialogForm.id!, payload as UpdatePositionRequest)
|
await positionsApi.updatePosition(dialogForm.id!, payload as UpdatePositionRequest)
|
||||||
ElMessage.success('Position updated')
|
ElMessage.success('位置更新成功')
|
||||||
}
|
}
|
||||||
dialogVisible.value = false
|
dialogVisible.value = false
|
||||||
fetchList()
|
fetchList()
|
||||||
@@ -145,22 +147,22 @@ async function handleSubmit() {
|
|||||||
|
|
||||||
async function handleDelete(p: Position) {
|
async function handleDelete(p: Position) {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(`Delete position #${p.indexVal}?`, 'Confirm', {
|
await ElMessageBox.confirm(`确定删除位置 #${p.indexVal} 吗?`, '确认', {
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
confirmButtonText: 'Delete',
|
confirmButtonText: '删除',
|
||||||
cancelButtonText: 'Cancel',
|
cancelButtonText: '取消',
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await positionsApi.deletePosition(p.id)
|
await positionsApi.deletePosition(p.id)
|
||||||
ElMessage.success('Deleted')
|
ElMessage.success('删除成功')
|
||||||
fetchList()
|
fetchList()
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCategoryName(p: Position): string {
|
function getCategoryName(p: Position): string {
|
||||||
if (!p.category) return p.categoryId || '-'
|
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 () => {
|
onMounted(async () => {
|
||||||
@@ -175,14 +177,14 @@ onMounted(async () => {
|
|||||||
<div class="filter-bar">
|
<div class="filter-bar">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="filter.countryId"
|
v-model="filter.countryId"
|
||||||
placeholder="Country"
|
placeholder="请选择国家"
|
||||||
clearable
|
clearable
|
||||||
@change="handleSearch"
|
@change="handleSearch"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="c in countries"
|
v-for="c in countries"
|
||||||
:key="c.id"
|
:key="c.id"
|
||||||
:label="c.name"
|
:label="c.countryName"
|
||||||
:value="c.id"
|
:value="c.id"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
@@ -191,56 +193,56 @@ onMounted(async () => {
|
|||||||
v-model="filter.categoryId"
|
v-model="filter.categoryId"
|
||||||
:options="cascaderOptions"
|
:options="cascaderOptions"
|
||||||
:props="{ checkStrictly: true, value: 'value', label: 'label', children: 'children', emitPath: false }"
|
:props="{ checkStrictly: true, value: 'value', label: 'label', children: 'children', emitPath: false }"
|
||||||
placeholder="Category"
|
placeholder="请选择分类"
|
||||||
clearable
|
clearable
|
||||||
@change="handleSearch"
|
@change="handleSearch"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<el-button type="primary" @click="handleSearch">
|
<el-button type="primary" @click="handleSearch">
|
||||||
<el-icon><Search /></el-icon>
|
<el-icon><Search /></el-icon>
|
||||||
<span>Search</span>
|
<span>搜索</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button @click="handleReset">
|
<el-button @click="handleReset">
|
||||||
<el-icon><Refresh /></el-icon>
|
<el-icon><Refresh /></el-icon>
|
||||||
<span>Reset</span>
|
<span>重置</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
|
|
||||||
<div class="filter-spacer" />
|
<div class="filter-spacer" />
|
||||||
|
|
||||||
<el-button type="primary" @click="openAddDialog">
|
<el-button type="primary" @click="openAddDialog">
|
||||||
<el-icon><Plus /></el-icon>
|
<el-icon><Plus /></el-icon>
|
||||||
<span>Add Position</span>
|
<span>新增位置</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="list" border stripe>
|
<el-table v-loading="loading" :data="list" border stripe>
|
||||||
<el-table-column label="Index" prop="indexVal" width="100" sortable />
|
<el-table-column label="排序值" prop="indexVal" width="100" sortable />
|
||||||
<el-table-column label="Country" min-width="160">
|
<el-table-column label="国家" min-width="160">
|
||||||
<template #default="{ row }: { row: Position }">
|
<template #default="{ row }: { row: Position }">
|
||||||
{{ row.country?.name || '-' }}
|
{{ row.country?.countryName || '-' }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="Category" min-width="200">
|
<el-table-column label="分类" min-width="200">
|
||||||
<template #default="{ row }: { row: Position }">
|
<template #default="{ row }: { row: Position }">
|
||||||
{{ getCategoryName(row) }}
|
{{ getCategoryName(row) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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 }">
|
<template #default="{ row }: { row: Position }">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||||
<el-icon><Edit /></el-icon>
|
<el-icon><Edit /></el-icon>
|
||||||
<span>Edit</span>
|
<span>编辑</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||||
<el-icon><Delete /></el-icon>
|
<el-icon><Delete /></el-icon>
|
||||||
<span>Delete</span>
|
<span>删除</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<template #empty>
|
<template #empty>
|
||||||
<el-empty description="No positions" />
|
<el-empty description="暂无位置" />
|
||||||
</template>
|
</template>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
@@ -258,25 +260,25 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="dialogVisible"
|
v-model="dialogVisible"
|
||||||
:title="dialogMode === 'create' ? 'Add Position' : 'Edit Position'"
|
:title="dialogMode === 'create' ? '新增位置' : '编辑位置'"
|
||||||
width="520px"
|
width="520px"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
>
|
>
|
||||||
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
<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-input-number v-model="dialogForm.indexVal" :min="0" :max="9999" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="Country">
|
<el-form-item label="国家">
|
||||||
<el-select v-model="dialogForm.countryId" placeholder="Optional" clearable style="width: 100%">
|
<el-select v-model="dialogForm.countryId" placeholder="可选" clearable style="width: 100%">
|
||||||
<el-option
|
<el-option
|
||||||
v-for="c in countries"
|
v-for="c in countries"
|
||||||
:key="c.id"
|
:key="c.id"
|
||||||
:label="c.name"
|
:label="c.countryName"
|
||||||
:value="c.id"
|
:value="c.id"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="Category">
|
<el-form-item label="分类">
|
||||||
<el-cascader
|
<el-cascader
|
||||||
v-model="dialogForm.categoryId"
|
v-model="dialogForm.categoryId"
|
||||||
:options="cascaderOptions"
|
:options="cascaderOptions"
|
||||||
@@ -287,16 +289,16 @@ onMounted(async () => {
|
|||||||
children: 'children',
|
children: 'children',
|
||||||
emitPath: false,
|
emitPath: false,
|
||||||
}"
|
}"
|
||||||
placeholder="Optional"
|
placeholder="可选"
|
||||||
clearable
|
clearable
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<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">
|
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||||
{{ dialogMode === 'create' ? 'Create' : 'Save' }}
|
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</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">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, reactive, ref } from 'vue'
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Refresh, Folder, Box } from '@element-plus/icons-vue'
|
import { Refresh, Box } from '@element-plus/icons-vue'
|
||||||
import type { SyncLog, SyncStats } from '@/types'
|
import type { SyncLog } from '@/types'
|
||||||
import { syncApi } from '@/api/sync'
|
import { syncApi } from '@/api/sync'
|
||||||
|
|
||||||
const stats = ref<SyncStats | null>(null)
|
|
||||||
const logs = ref<SyncLog[]>([])
|
const logs = ref<SyncLog[]>([])
|
||||||
const total = ref(0)
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const syncingCategories = ref(false)
|
const syncing = ref(false)
|
||||||
const syncingProducts = ref(false)
|
|
||||||
|
|
||||||
const filter = reactive<{ page: number; pageSize: number; type: '' | 'CATEGORY' | 'PRODUCT' }>({
|
|
||||||
page: 1,
|
|
||||||
pageSize: 10,
|
|
||||||
type: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
let timer: ReturnType<typeof setInterval> | null = null
|
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() {
|
async function refreshLogs() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await syncApi.getSyncLogs({
|
const data = await syncApi.getSyncStatus(50) as any
|
||||||
page: filter.page,
|
logs.value = Array.isArray(data) ? data : []
|
||||||
pageSize: filter.pageSize,
|
|
||||||
type: filter.type || undefined,
|
|
||||||
})
|
|
||||||
logs.value = res.data
|
|
||||||
total.value = res.total
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to fetch sync logs', err)
|
console.warn('Failed to fetch sync status', err)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshAll() {
|
async function handleSync() {
|
||||||
await Promise.all([refreshStats(), refreshLogs()])
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSyncCategories() {
|
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
'Trigger category sync now? This may take a while.',
|
'确定立即执行产品同步吗?此操作可能需要一些时间。',
|
||||||
'Confirm',
|
'确认',
|
||||||
{
|
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
|
||||||
type: 'info',
|
|
||||||
confirmButtonText: 'Run',
|
|
||||||
cancelButtonText: 'Cancel',
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
} catch {
|
} catch { return }
|
||||||
return
|
|
||||||
}
|
|
||||||
syncingCategories.value = true
|
|
||||||
try {
|
|
||||||
const log = await syncApi.syncCategories()
|
|
||||||
ElMessage.success(`Category sync started (${log.id})`)
|
|
||||||
await refreshAll()
|
|
||||||
} finally {
|
|
||||||
syncingCategories.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSyncProducts() {
|
syncing.value = true
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await syncApi.syncProducts()
|
||||||
'Trigger product sync now? This may take a while.',
|
ElMessage.success('产品同步完成')
|
||||||
'Confirm',
|
await refreshLogs()
|
||||||
{
|
|
||||||
type: 'info',
|
|
||||||
confirmButtonText: 'Run',
|
|
||||||
cancelButtonText: 'Cancel',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} catch {
|
} catch {
|
||||||
return
|
ElMessage.error('产品同步失败')
|
||||||
}
|
|
||||||
syncingProducts.value = true
|
|
||||||
try {
|
|
||||||
const log = await syncApi.syncProducts()
|
|
||||||
ElMessage.success(`Product sync started (${log.id})`)
|
|
||||||
await refreshAll()
|
|
||||||
} finally {
|
} finally {
|
||||||
syncingProducts.value = false
|
syncing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,17 +49,9 @@ function formatDate(s?: string): string {
|
|||||||
return new Date(s).toLocaleString()
|
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(() => {
|
onMounted(() => {
|
||||||
refreshAll()
|
refreshLogs()
|
||||||
timer = setInterval(refreshAll, 30_000)
|
timer = setInterval(refreshLogs, 30_000)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -122,156 +61,58 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-container">
|
<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">
|
<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">
|
<el-card class="sync-card">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="sync-card-header">
|
<div class="sync-card-header">
|
||||||
<div class="sync-card-title">
|
<div class="sync-card-title">
|
||||||
<el-icon><Box /></el-icon>
|
<el-icon><Box /></el-icon>
|
||||||
<span>Product Sync</span>
|
<span>产品同步</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<p class="sync-card-desc">
|
<p class="sync-card-desc">
|
||||||
Pull latest products/origin goods from upstream and reconcile local database.
|
从上游拉取最新产品和原产品并与本地数据库进行同步。
|
||||||
</p>
|
</p>
|
||||||
<el-button
|
<el-button type="primary" :loading="syncing" @click="handleSync">
|
||||||
type="primary"
|
|
||||||
:loading="syncingProducts"
|
|
||||||
@click="handleSyncProducts"
|
|
||||||
>
|
|
||||||
<el-icon><Refresh /></el-icon>
|
<el-icon><Refresh /></el-icon>
|
||||||
<span>Run Product Sync</span>
|
<span>执行产品同步</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="page-card logs-card">
|
<div class="page-card logs-card">
|
||||||
<div class="logs-toolbar">
|
<div class="logs-toolbar">
|
||||||
<h3 class="logs-title">Sync Logs</h3>
|
<h3 class="logs-title">同步日志</h3>
|
||||||
<div class="logs-filter">
|
<el-button @click="refreshLogs">
|
||||||
<el-select v-model="filter.type" placeholder="All types" clearable style="width: 160px;">
|
<el-icon><Refresh /></el-icon>
|
||||||
<el-option label="Category" value="CATEGORY" />
|
<span>刷新</span>
|
||||||
<el-option label="Product" value="PRODUCT" />
|
</el-button>
|
||||||
</el-select>
|
|
||||||
<el-button @click="refreshLogs">
|
|
||||||
<el-icon><Refresh /></el-icon>
|
|
||||||
<span>Refresh</span>
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="logs" border stripe>
|
<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 }">
|
<template #default="{ row }: { row: SyncLog }">
|
||||||
<el-tag :type="row.type === 'CATEGORY' ? 'warning' : 'primary'">
|
<el-tag :type="row.status === 'SUCCESS' ? 'success' : 'danger'" size="small">
|
||||||
{{ typeLabel(row.type) }}
|
{{ row.status === 'SUCCESS' ? '成功' : '失败' }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="Status" width="120">
|
<el-table-column label="时间" width="200">
|
||||||
<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">
|
|
||||||
<template #default="{ row }: { row: SyncLog }">
|
<template #default="{ row }: { row: SyncLog }">
|
||||||
{{ formatDate(row.startTime) }}
|
{{ formatDate(row.startTime) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="Ended" width="180">
|
<el-table-column prop="message" label="信息" min-width="200" show-overflow-tooltip />
|
||||||
<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" />
|
|
||||||
<template #empty>
|
<template #empty>
|
||||||
<el-empty description="No sync logs" />
|
<el-empty description="暂无同步记录" />
|
||||||
</template>
|
</template>
|
||||||
</el-table>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.sync-cards {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
@@ -316,15 +157,4 @@ onUnmounted(() => {
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logs-filter {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination {
|
|
||||||
margin-top: 16px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const list = ref<Tag[]>([])
|
|||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
|
|
||||||
const filter = reactive<Required<TagFilter>>({
|
const filter = reactive<Required<TagFilter>>({
|
||||||
name: '',
|
tagName: '',
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
})
|
})
|
||||||
@@ -24,8 +24,10 @@ async function fetchList() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await tagsApi.getTagsList(filter)
|
const res = await tagsApi.getTagsList(filter)
|
||||||
list.value = res.data
|
const data = await tagsApi.getTagsList(filter) as any
|
||||||
total.value = res.total
|
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||||
|
list.value = arr
|
||||||
|
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -37,7 +39,7 @@ function handleSearch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleReset() {
|
function handleReset() {
|
||||||
filter.name = ''
|
filter.tagName = ''
|
||||||
filter.page = 1
|
filter.page = 1
|
||||||
fetchList()
|
fetchList()
|
||||||
}
|
}
|
||||||
@@ -48,18 +50,18 @@ const dialogMode = ref<'create' | 'edit'>('create')
|
|||||||
const dialogLoading = ref(false)
|
const dialogLoading = ref(false)
|
||||||
|
|
||||||
const dialogForm = reactive<CreateTagRequest & { id?: string }>({
|
const dialogForm = reactive<CreateTagRequest & { id?: string }>({
|
||||||
name: '',
|
tagName: '',
|
||||||
color: '#ff6800',
|
tagColor: '#ff6800',
|
||||||
timing: '',
|
timing: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const dialogRules = {
|
const dialogRules = {
|
||||||
name: [{ required: true, message: 'Name is required', trigger: 'blur' }],
|
tagName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAddDialog() {
|
function openAddDialog() {
|
||||||
dialogMode.value = 'create'
|
dialogMode.value = 'create'
|
||||||
Object.assign(dialogForm, { id: undefined, name: '', color: '#ff6800', timing: '' })
|
Object.assign(dialogForm, { id: undefined, tagName: '', tagColor: '#ff6800', timing: '' })
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,8 +69,8 @@ function openEditDialog(t: Tag) {
|
|||||||
dialogMode.value = 'edit'
|
dialogMode.value = 'edit'
|
||||||
Object.assign(dialogForm, {
|
Object.assign(dialogForm, {
|
||||||
id: t.id,
|
id: t.id,
|
||||||
name: t.name,
|
tagName: t.tagName,
|
||||||
color: t.color || '#ff6800',
|
tagColor: t.tagColor || '#ff6800',
|
||||||
timing: t.timing || '',
|
timing: t.timing || '',
|
||||||
})
|
})
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
@@ -81,16 +83,16 @@ async function handleSubmit() {
|
|||||||
dialogLoading.value = true
|
dialogLoading.value = true
|
||||||
try {
|
try {
|
||||||
const payload: CreateTagRequest = {
|
const payload: CreateTagRequest = {
|
||||||
name: dialogForm.name,
|
tagName: dialogForm.tagName,
|
||||||
color: dialogForm.color || undefined,
|
tagColor: dialogForm.tagColor || undefined,
|
||||||
timing: dialogForm.timing || undefined,
|
timing: dialogForm.timing || undefined,
|
||||||
}
|
}
|
||||||
if (dialogMode.value === 'create') {
|
if (dialogMode.value === 'create') {
|
||||||
await tagsApi.createTag(payload)
|
await tagsApi.createTag(payload)
|
||||||
ElMessage.success('Tag created')
|
ElMessage.success('标签创建成功')
|
||||||
} else {
|
} else {
|
||||||
await tagsApi.updateTag(dialogForm.id!, payload as UpdateTagRequest)
|
await tagsApi.updateTag(dialogForm.id!, payload as UpdateTagRequest)
|
||||||
ElMessage.success('Tag updated')
|
ElMessage.success('标签更新成功')
|
||||||
}
|
}
|
||||||
dialogVisible.value = false
|
dialogVisible.value = false
|
||||||
fetchList()
|
fetchList()
|
||||||
@@ -102,16 +104,16 @@ async function handleSubmit() {
|
|||||||
|
|
||||||
async function handleDelete(t: Tag) {
|
async function handleDelete(t: Tag) {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(`Delete "${t.name}"?`, 'Confirm', {
|
await ElMessageBox.confirm(`确定删除「${t.tagName}」吗?`, '确认', {
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
confirmButtonText: 'Delete',
|
confirmButtonText: '删除',
|
||||||
cancelButtonText: 'Cancel',
|
cancelButtonText: '取消',
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await tagsApi.deleteTag(t.id)
|
await tagsApi.deleteTag(t.id)
|
||||||
ElMessage.success('Deleted')
|
ElMessage.success('删除成功')
|
||||||
fetchList()
|
fetchList()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,8 +125,8 @@ onMounted(fetchList)
|
|||||||
<div class="page-card">
|
<div class="page-card">
|
||||||
<div class="filter-bar">
|
<div class="filter-bar">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="filter.name"
|
v-model="filter.tagName"
|
||||||
placeholder="Search by name"
|
placeholder="按名称搜索"
|
||||||
clearable
|
clearable
|
||||||
@keyup.enter="handleSearch"
|
@keyup.enter="handleSearch"
|
||||||
@clear="handleSearch"
|
@clear="handleSearch"
|
||||||
@@ -135,56 +137,56 @@ onMounted(fetchList)
|
|||||||
</el-input>
|
</el-input>
|
||||||
<el-button type="primary" @click="handleSearch">
|
<el-button type="primary" @click="handleSearch">
|
||||||
<el-icon><Search /></el-icon>
|
<el-icon><Search /></el-icon>
|
||||||
<span>Search</span>
|
<span>搜索</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button @click="handleReset">
|
<el-button @click="handleReset">
|
||||||
<el-icon><Refresh /></el-icon>
|
<el-icon><Refresh /></el-icon>
|
||||||
<span>Reset</span>
|
<span>重置</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
|
|
||||||
<div class="filter-spacer" />
|
<div class="filter-spacer" />
|
||||||
|
|
||||||
<el-button type="primary" @click="openAddDialog">
|
<el-button type="primary" @click="openAddDialog">
|
||||||
<el-icon><Plus /></el-icon>
|
<el-icon><Plus /></el-icon>
|
||||||
<span>Add Tag</span>
|
<span>新增标签</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="list" border stripe>
|
<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 }">
|
<template #default="{ row }: { row: Tag }">
|
||||||
<el-tag v-if="row.color" :color="row.color" effect="dark">
|
<el-tag v-if="row.tagColor" :color="row.tagColor" effect="dark">
|
||||||
{{ row.name }}
|
{{ row.tagName }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
<el-tag v-else effect="plain">{{ row.name }}</el-tag>
|
<el-tag v-else effect="plain">{{ row.tagName }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="name" label="Name" min-width="200" />
|
<el-table-column prop="tagName" label="名称" min-width="200" />
|
||||||
<el-table-column label="Color" width="140">
|
<el-table-column label="颜色" width="140">
|
||||||
<template #default="{ row }: { row: Tag }">
|
<template #default="{ row }: { row: Tag }">
|
||||||
<div class="color-cell">
|
<div class="color-cell">
|
||||||
<span class="color-swatch" :style="{ background: row.color || '#d1d5db' }" />
|
<span class="color-swatch" :style="{ background: row.tagColor || '#d1d5db' }" />
|
||||||
<span class="color-hex">{{ row.color || '-' }}</span>
|
<span class="color-hex">{{ row.tagColor || '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="timing" label="Timing" min-width="120" />
|
<el-table-column prop="timing" label="定时" min-width="120" />
|
||||||
<el-table-column label="Actions" width="180" fixed="right">
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
<template #default="{ row }: { row: Tag }">
|
<template #default="{ row }: { row: Tag }">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||||
<el-icon><Edit /></el-icon>
|
<el-icon><Edit /></el-icon>
|
||||||
<span>Edit</span>
|
<span>编辑</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||||
<el-icon><Delete /></el-icon>
|
<el-icon><Delete /></el-icon>
|
||||||
<span>Delete</span>
|
<span>删除</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<template #empty>
|
<template #empty>
|
||||||
<el-empty description="No tags" />
|
<el-empty description="暂无标签" />
|
||||||
</template>
|
</template>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
@@ -202,26 +204,26 @@ onMounted(fetchList)
|
|||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="dialogVisible"
|
v-model="dialogVisible"
|
||||||
:title="dialogMode === 'create' ? 'Add Tag' : 'Edit Tag'"
|
:title="dialogMode === 'create' ? '新增标签' : '编辑标签'"
|
||||||
width="480px"
|
width="480px"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
>
|
>
|
||||||
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
||||||
<el-form-item label="Name" prop="name">
|
<el-form-item label="名称" prop="tagName">
|
||||||
<el-input v-model="dialogForm.name" placeholder="Tag name" />
|
<el-input v-model="dialogForm.tagName" placeholder="请输入标签名称" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="Color">
|
<el-form-item label="颜色">
|
||||||
<el-color-picker v-model="dialogForm.color" />
|
<el-color-picker v-model="dialogForm.tagColor" />
|
||||||
<span class="color-readout">{{ dialogForm.color }}</span>
|
<span class="color-readout">{{ dialogForm.tagColor }}</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="Timing">
|
<el-form-item label="定时">
|
||||||
<el-input v-model="dialogForm.timing" placeholder="e.g. 9:00-12:00" />
|
<el-input v-model="dialogForm.timing" placeholder="例如 9:00-12:00" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<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">
|
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||||
{{ dialogMode === 'create' ? 'Create' : 'Save' }}
|
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|||||||
@@ -73,7 +73,8 @@ model Tag {
|
|||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
goods Good[]
|
goods Good[]
|
||||||
|
goodTags GoodTag[]
|
||||||
|
|
||||||
@@map("tags")
|
@@map("tags")
|
||||||
}
|
}
|
||||||
@@ -106,6 +107,7 @@ model Good {
|
|||||||
tagId BigInt? @map("tag_id")
|
tagId BigInt? @map("tag_id")
|
||||||
positionId BigInt? @map("position_id")
|
positionId BigInt? @map("position_id")
|
||||||
goodName String @map("good_name")
|
goodName String @map("good_name")
|
||||||
|
goodImage String? @map("good_image")
|
||||||
goodPriority Int @default(0) @map("good_priority")
|
goodPriority Int @default(0) @map("good_priority")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
@@ -115,6 +117,7 @@ model Good {
|
|||||||
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||||
tag Tag? @relation(fields: [tagId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
tag Tag? @relation(fields: [tagId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||||
position Position? @relation(fields: [positionId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
position Position? @relation(fields: [positionId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||||
|
goodTags GoodTag[]
|
||||||
|
|
||||||
@@index([originGoodId])
|
@@index([originGoodId])
|
||||||
@@index([countryId])
|
@@index([countryId])
|
||||||
@@ -127,6 +130,20 @@ model Good {
|
|||||||
@@map("goods")
|
@@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) ----------
|
// ---------- Users (admin authentication) ----------
|
||||||
model User {
|
model User {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ export class CategoryNodeDto {
|
|||||||
@ApiProperty({ nullable: true })
|
@ApiProperty({ nullable: true })
|
||||||
categoryIcon!: string | null;
|
categoryIcon!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
sdsCategoryId!: string | null;
|
||||||
|
|
||||||
@ApiProperty({ nullable: true, description: 'Parent category ID' })
|
@ApiProperty({ nullable: true, description: 'Parent category ID' })
|
||||||
parentCategoryId!: string | null;
|
parentCategoryId!: string | null;
|
||||||
|
|
||||||
@@ -22,6 +25,7 @@ export class CategoryNodeDto {
|
|||||||
id: category.id.toString(),
|
id: category.id.toString(),
|
||||||
categoryName: category.categoryName,
|
categoryName: category.categoryName,
|
||||||
categoryIcon: category.categoryIcon,
|
categoryIcon: category.categoryIcon,
|
||||||
|
sdsCategoryId: category.sdsCategoryId,
|
||||||
parentCategoryId: category.parentCategoryId
|
parentCategoryId: category.parentCategoryId
|
||||||
? category.parentCategoryId.toString()
|
? category.parentCategoryId.toString()
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -40,11 +40,12 @@ export class BatchCreateGoodDto {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
categoryId!: number;
|
categoryId!: number;
|
||||||
|
|
||||||
@ApiProperty({ required: false, nullable: true })
|
@ApiProperty({ required: false, nullable: true, type: [Number] })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsArray()
|
||||||
@Min(1)
|
@IsInt({ each: true })
|
||||||
tagId?: number;
|
@Min(1, { each: true })
|
||||||
|
tagIds?: number[];
|
||||||
|
|
||||||
@ApiProperty({ required: false, nullable: true })
|
@ApiProperty({ required: false, nullable: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -57,4 +58,4 @@ export class BatchCreateGoodDto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
defaultPriority?: number;
|
defaultPriority?: number;
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import {
|
import {
|
||||||
|
ArrayMinSize,
|
||||||
|
IsArray,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
@@ -28,11 +30,13 @@ export class CreateGoodDto {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
categoryId!: number;
|
categoryId!: number;
|
||||||
|
|
||||||
@ApiProperty({ required: false, nullable: true })
|
@ApiProperty({ required: false, nullable: true, type: [Number] })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsArray()
|
||||||
@Min(1)
|
@ArrayMinSize(1)
|
||||||
tagId?: number;
|
@IsInt({ each: true })
|
||||||
|
@Min(1, { each: true })
|
||||||
|
tagIds?: number[];
|
||||||
|
|
||||||
@ApiProperty({ required: false, nullable: true })
|
@ApiProperty({ required: false, nullable: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -45,4 +49,9 @@ export class CreateGoodDto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
goodPriority?: number;
|
goodPriority?: number;
|
||||||
}
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
goodImage?: string;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ export interface GoodRelations {
|
|||||||
goodImage: string | null;
|
goodImage: string | null;
|
||||||
goodPrice: unknown;
|
goodPrice: unknown;
|
||||||
} | null;
|
} | null;
|
||||||
|
goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null } }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GoodDto {
|
export class GoodDto {
|
||||||
@@ -22,6 +23,9 @@ export class GoodDto {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
goodName!: string;
|
goodName!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
goodImage!: string | null;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
goodPriority!: number;
|
goodPriority!: number;
|
||||||
|
|
||||||
@@ -55,6 +59,9 @@ export class GoodDto {
|
|||||||
@ApiProperty({ required: false, nullable: true })
|
@ApiProperty({ required: false, nullable: true })
|
||||||
tag?: { id: string; tagName: string; tagColor: string | null } | null;
|
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 })
|
@ApiProperty({ required: false, nullable: true })
|
||||||
position?: { id: string; indexVal: number } | null;
|
position?: { id: string; indexVal: number } | null;
|
||||||
|
|
||||||
@@ -74,6 +81,7 @@ export class GoodDto {
|
|||||||
return {
|
return {
|
||||||
id: good.id.toString(),
|
id: good.id.toString(),
|
||||||
goodName: good.goodName,
|
goodName: good.goodName,
|
||||||
|
goodImage: good.goodImage,
|
||||||
goodPriority: good.goodPriority,
|
goodPriority: good.goodPriority,
|
||||||
countryId: good.countryId.toString(),
|
countryId: good.countryId.toString(),
|
||||||
categoryId: good.categoryId.toString(),
|
categoryId: good.categoryId.toString(),
|
||||||
@@ -103,6 +111,13 @@ export class GoodDto {
|
|||||||
tagColor: rel.tag.tagColor,
|
tagColor: rel.tag.tagColor,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
tags: rel.goodTags
|
||||||
|
? rel.goodTags.map((gt) => ({
|
||||||
|
id: gt.tag.id.toString(),
|
||||||
|
tagName: gt.tag.tagName,
|
||||||
|
tagColor: gt.tag.tagColor,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
position: rel.position
|
position: rel.position
|
||||||
? {
|
? {
|
||||||
id: rel.position.id.toString(),
|
id: rel.position.id.toString(),
|
||||||
@@ -131,4 +146,4 @@ export interface PaginatedGoods {
|
|||||||
total: number;
|
total: number;
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import {
|
import {
|
||||||
|
IsArray,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
@@ -30,11 +31,12 @@ export class UpdateGoodDto {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
categoryId?: number;
|
categoryId?: number;
|
||||||
|
|
||||||
@ApiProperty({ required: false, nullable: true })
|
@ApiProperty({ required: false, nullable: true, type: [Number] })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsArray()
|
||||||
@Min(1)
|
@IsInt({ each: true })
|
||||||
tagId?: number | null;
|
@Min(1, { each: true })
|
||||||
|
tagIds?: number[];
|
||||||
|
|
||||||
@ApiProperty({ required: false, nullable: true })
|
@ApiProperty({ required: false, nullable: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -47,4 +49,9 @@ export class UpdateGoodDto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
goodPriority?: number;
|
goodPriority?: number;
|
||||||
}
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
goodImage?: string | null;
|
||||||
|
}
|
||||||
@@ -99,13 +99,13 @@ describe('GoodsService', () => {
|
|||||||
originGoodId: Number(originGoodIds[0]),
|
originGoodId: Number(originGoodIds[0]),
|
||||||
countryId: Number(countryId),
|
countryId: Number(countryId),
|
||||||
categoryId: Number(categoryId),
|
categoryId: Number(categoryId),
|
||||||
tagId: Number(tagId),
|
tagIds: [Number(tagId)],
|
||||||
positionId: Number(positionId),
|
positionId: Number(positionId),
|
||||||
goodPriority: 3,
|
goodPriority: 3,
|
||||||
});
|
});
|
||||||
expect(created.id).toBeTruthy();
|
expect(created.id).toBeTruthy();
|
||||||
expect(created.country?.countryName).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));
|
const fetched = await service.findOne(BigInt(created.id));
|
||||||
expect(fetched.goodName).toBe(`Goods Test ${stamp} basic`);
|
expect(fetched.goodName).toBe(`Goods Test ${stamp} basic`);
|
||||||
@@ -121,7 +121,9 @@ describe('GoodsService', () => {
|
|||||||
});
|
});
|
||||||
expect(result.items.length).toBeGreaterThan(0);
|
expect(result.items.length).toBeGreaterThan(0);
|
||||||
expect(result.items.every((g) => g.countryId === countryId.toString())).toBe(true);
|
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 () => {
|
it('categoryId filter includes descendants recursively', async () => {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const GOOD_INCLUDE = {
|
|||||||
tag: true,
|
tag: true,
|
||||||
position: true,
|
position: true,
|
||||||
originGood: true,
|
originGood: true,
|
||||||
|
goodTags: { include: { tag: true } },
|
||||||
} satisfies Prisma.GoodInclude;
|
} satisfies Prisma.GoodInclude;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -28,7 +29,7 @@ export class GoodsService {
|
|||||||
const { page, pageSize, countryId, categoryId, tagId, positionId, keyword } = query;
|
const { page, pageSize, countryId, categoryId, tagId, positionId, keyword } = query;
|
||||||
const where: Prisma.GoodWhereInput = {};
|
const where: Prisma.GoodWhereInput = {};
|
||||||
if (countryId !== undefined) where.countryId = BigInt(countryId);
|
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 (positionId !== undefined) where.positionId = BigInt(positionId);
|
||||||
if (keyword) {
|
if (keyword) {
|
||||||
where.goodName = { contains: keyword, mode: 'insensitive' };
|
where.goodName = { contains: keyword, mode: 'insensitive' };
|
||||||
@@ -56,6 +57,7 @@ export class GoodsService {
|
|||||||
tag: g.tag,
|
tag: g.tag,
|
||||||
position: g.position,
|
position: g.position,
|
||||||
originGood: g.originGood,
|
originGood: g.originGood,
|
||||||
|
goodTags: g.goodTags,
|
||||||
})),
|
})),
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
@@ -75,29 +77,44 @@ export class GoodsService {
|
|||||||
tag: good.tag,
|
tag: good.tag,
|
||||||
position: good.position,
|
position: good.position,
|
||||||
originGood: good.originGood,
|
originGood: good.originGood,
|
||||||
|
goodTags: good.goodTags,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateGoodDto): Promise<GoodDto> {
|
async create(dto: CreateGoodDto): Promise<GoodDto> {
|
||||||
await this.ensureReferences(dto);
|
await this.ensureReferences(dto);
|
||||||
const created = await this.prisma.good.create({
|
return this.prisma.$transaction(async (tx) => {
|
||||||
data: {
|
const created = await tx.good.create({
|
||||||
goodName: dto.goodName,
|
data: {
|
||||||
originGoodId: BigInt(dto.originGoodId),
|
goodName: dto.goodName,
|
||||||
countryId: BigInt(dto.countryId),
|
goodImage: dto.goodImage,
|
||||||
categoryId: BigInt(dto.categoryId),
|
originGoodId: BigInt(dto.originGoodId),
|
||||||
tagId: dto.tagId === undefined ? null : BigInt(dto.tagId),
|
countryId: BigInt(dto.countryId),
|
||||||
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
|
categoryId: BigInt(dto.categoryId),
|
||||||
goodPriority: dto.goodPriority ?? 0,
|
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
|
||||||
},
|
goodPriority: dto.goodPriority ?? 0,
|
||||||
include: GOOD_INCLUDE,
|
},
|
||||||
});
|
});
|
||||||
return GoodDto.from(created, {
|
if (dto.tagIds && dto.tagIds.length > 0) {
|
||||||
country: created.country,
|
await tx.goodTag.createMany({
|
||||||
category: created.category,
|
data: dto.tagIds.map((tagId) => ({
|
||||||
tag: created.tag,
|
goodId: created.id,
|
||||||
position: created.position,
|
tagId: BigInt(tagId),
|
||||||
originGood: created.originGood,
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const result = await tx.good.findUniqueOrThrow({
|
||||||
|
where: { id: created.id },
|
||||||
|
include: GOOD_INCLUDE,
|
||||||
|
});
|
||||||
|
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);
|
await this.ensureCategory(dto.categoryId);
|
||||||
data.category = { connect: { id: BigInt(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) {
|
if (dto.positionId !== undefined) {
|
||||||
data.position =
|
data.position =
|
||||||
dto.positionId === null
|
dto.positionId === null
|
||||||
@@ -130,17 +141,38 @@ export class GoodsService {
|
|||||||
: { connect: { id: BigInt(dto.positionId) } };
|
: { connect: { id: BigInt(dto.positionId) } };
|
||||||
}
|
}
|
||||||
if (dto.goodPriority !== undefined) data.goodPriority = dto.goodPriority;
|
if (dto.goodPriority !== undefined) data.goodPriority = dto.goodPriority;
|
||||||
const updated = await this.prisma.good.update({
|
if (dto.goodImage !== undefined) data.goodImage = dto.goodImage;
|
||||||
where: { id },
|
if (dto.tagIds !== undefined) {
|
||||||
data,
|
for (const tagId of dto.tagIds) {
|
||||||
include: GOOD_INCLUDE,
|
await this.ensureTag(tagId);
|
||||||
});
|
}
|
||||||
return GoodDto.from(updated, {
|
}
|
||||||
country: updated.country,
|
|
||||||
category: updated.category,
|
return this.prisma.$transaction(async (tx) => {
|
||||||
tag: updated.tag,
|
if (dto.tagIds !== undefined) {
|
||||||
position: updated.position,
|
await tx.goodTag.deleteMany({ where: { goodId: id } });
|
||||||
originGood: updated.originGood,
|
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,
|
||||||
|
});
|
||||||
|
return GoodDto.from(updated, {
|
||||||
|
country: updated.country,
|
||||||
|
category: updated.category,
|
||||||
|
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.
|
* and a default priority that may be overridden per item.
|
||||||
*/
|
*/
|
||||||
async batchCreate(dto: BatchCreateGoodDto): Promise<GoodDto[]> {
|
async batchCreate(dto: BatchCreateGoodDto): Promise<GoodDto[]> {
|
||||||
const defaultPriority = dto.defaultPriority ?? 0;
|
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) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const created: GoodDto[] = [];
|
const created: GoodDto[] = [];
|
||||||
for (const item of dto.items) {
|
for (const item of dto.items) {
|
||||||
@@ -186,21 +223,33 @@ export class GoodsService {
|
|||||||
const row = await tx.good.create({
|
const row = await tx.good.create({
|
||||||
data: {
|
data: {
|
||||||
goodName: og.goodName ?? `Origin Good ${og.sdsGoodId}`,
|
goodName: og.goodName ?? `Origin Good ${og.sdsGoodId}`,
|
||||||
|
goodImage: og.goodImage,
|
||||||
originGoodId: og.id,
|
originGoodId: og.id,
|
||||||
countryId: BigInt(dto.countryId),
|
countryId: BigInt(dto.countryId),
|
||||||
categoryId: BigInt(dto.categoryId),
|
categoryId: BigInt(dto.categoryId),
|
||||||
tagId: dto.tagId === undefined ? null : BigInt(dto.tagId),
|
|
||||||
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
|
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
|
||||||
goodPriority: item.priority ?? defaultPriority,
|
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,
|
include: GOOD_INCLUDE,
|
||||||
});
|
});
|
||||||
created.push(GoodDto.from(row, {
|
created.push(GoodDto.from(result, {
|
||||||
country: row.country,
|
country: result.country,
|
||||||
category: row.category,
|
category: result.category,
|
||||||
tag: row.tag,
|
tag: result.tag,
|
||||||
position: row.position,
|
position: result.position,
|
||||||
originGood: row.originGood,
|
originGood: result.originGood,
|
||||||
|
goodTags: result.goodTags,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
return created;
|
return created;
|
||||||
@@ -245,17 +294,23 @@ export class GoodsService {
|
|||||||
if (!c) throw new BadRequestException(`Category ${id} not found`);
|
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) {
|
private async ensureReferences(dto: CreateGoodDto) {
|
||||||
await this.ensureOriginGood(dto.originGoodId);
|
await this.ensureOriginGood(dto.originGoodId);
|
||||||
await this.ensureCountry(dto.countryId);
|
await this.ensureCountry(dto.countryId);
|
||||||
await this.ensureCategory(dto.categoryId);
|
await this.ensureCategory(dto.categoryId);
|
||||||
if (dto.tagId !== undefined) {
|
if (dto.tagIds && dto.tagIds.length > 0) {
|
||||||
const t = await this.prisma.tag.findUnique({ where: { id: BigInt(dto.tagId) } });
|
for (const tagId of dto.tagIds) {
|
||||||
if (!t) throw new BadRequestException(`Tag ${dto.tagId} not found`);
|
await this.ensureTag(tagId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (dto.positionId !== undefined) {
|
if (dto.positionId !== undefined) {
|
||||||
const p = await this.prisma.position.findUnique({ where: { id: BigInt(dto.positionId) } });
|
const p = await this.prisma.position.findUnique({ where: { id: BigInt(dto.positionId) } });
|
||||||
if (!p) throw new BadRequestException(`Position ${dto.positionId} not found`);
|
if (!p) throw new BadRequestException(`Position ${dto.positionId} not found`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,14 @@ import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
|
|||||||
export class OriginGoodsController {
|
export class OriginGoodsController {
|
||||||
constructor(private readonly service: OriginGoodsService) {}
|
constructor(private readonly service: OriginGoodsService) {}
|
||||||
|
|
||||||
|
@Get('tree')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Origin goods grouped by SDS category with config status',
|
||||||
|
})
|
||||||
|
getTree() {
|
||||||
|
return this.service.getTree();
|
||||||
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'Paginated list of origin goods (read-only)' })
|
@ApiOperation({ summary: 'Paginated list of origin goods (read-only)' })
|
||||||
findAll(@Query() query: QueryOriginGoodDto) {
|
findAll(@Query() query: QueryOriginGoodDto) {
|
||||||
|
|||||||
@@ -19,6 +19,39 @@ export interface PaginatedOriginGoods {
|
|||||||
pageSize: number;
|
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()
|
@Injectable()
|
||||||
export class OriginGoodsService {
|
export class OriginGoodsService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
@@ -55,4 +88,169 @@ export class OriginGoodsService {
|
|||||||
pageSize,
|
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 })
|
@ApiProperty({ nullable: true })
|
||||||
tag!: { id: string; tagName: string; tagColor: string | null } | null;
|
tag!: { id: string; tagName: string; tagColor: string | null } | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Array })
|
||||||
|
tags!: Array<{ id: string; tagName: string; tagColor: string | null }>;
|
||||||
|
|
||||||
@ApiProperty({ nullable: true })
|
@ApiProperty({ nullable: true })
|
||||||
position!: { id: string; indexVal: number } | null;
|
position!: { id: string; indexVal: number } | null;
|
||||||
|
|
||||||
@@ -30,4 +33,4 @@ export class PublicGoodDto {
|
|||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
createdAt!: string;
|
createdAt!: string;
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,15 @@ export interface PublicPaginatedGoods {
|
|||||||
pageSize: number;
|
pageSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PUBLIC_GOOD_INCLUDE = {
|
||||||
|
country: true,
|
||||||
|
category: true,
|
||||||
|
tag: true,
|
||||||
|
position: true,
|
||||||
|
originGood: true,
|
||||||
|
goodTags: { include: { tag: true } },
|
||||||
|
} satisfies Prisma.GoodInclude;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PublicService {
|
export class PublicService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
@@ -38,7 +47,9 @@ export class PublicService {
|
|||||||
async getGoods(query: PublicQueryGoodDto): Promise<PublicPaginatedGoods> {
|
async getGoods(query: PublicQueryGoodDto): Promise<PublicPaginatedGoods> {
|
||||||
const where: Prisma.GoodWhereInput = {};
|
const where: Prisma.GoodWhereInput = {};
|
||||||
if (query.countryId !== undefined) where.countryId = BigInt(query.countryId);
|
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) {
|
if (query.keyword) {
|
||||||
where.goodName = { contains: query.keyword, mode: 'insensitive' };
|
where.goodName = { contains: query.keyword, mode: 'insensitive' };
|
||||||
}
|
}
|
||||||
@@ -51,13 +62,7 @@ export class PublicService {
|
|||||||
this.prisma.good.count({ where }),
|
this.prisma.good.count({ where }),
|
||||||
this.prisma.good.findMany({
|
this.prisma.good.findMany({
|
||||||
where,
|
where,
|
||||||
include: {
|
include: PUBLIC_GOOD_INCLUDE,
|
||||||
country: true,
|
|
||||||
category: true,
|
|
||||||
tag: true,
|
|
||||||
position: true,
|
|
||||||
originGood: true,
|
|
||||||
},
|
|
||||||
// Server-side primary sort; PublicGoodDto retains original indexes
|
// Server-side primary sort; PublicGoodDto retains original indexes
|
||||||
// for stable pagination but the final ORDER BY is mirrored below.
|
// for stable pagination but the final ORDER BY is mirrored below.
|
||||||
orderBy: [
|
orderBy: [
|
||||||
@@ -81,13 +86,7 @@ export class PublicService {
|
|||||||
async getGood(id: bigint): Promise<PublicGoodDto> {
|
async getGood(id: bigint): Promise<PublicGoodDto> {
|
||||||
const good = await this.prisma.good.findUnique({
|
const good = await this.prisma.good.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: PUBLIC_GOOD_INCLUDE,
|
||||||
country: true,
|
|
||||||
category: true,
|
|
||||||
tag: true,
|
|
||||||
position: true,
|
|
||||||
originGood: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (!good) throw new NotFoundException(`Good ${id} not found`);
|
if (!good) throw new NotFoundException(`Good ${id} not found`);
|
||||||
return this.toPublicGood(good);
|
return this.toPublicGood(good);
|
||||||
@@ -96,6 +95,7 @@ export class PublicService {
|
|||||||
private toPublicGood(good: {
|
private toPublicGood(good: {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
goodName: string;
|
goodName: string;
|
||||||
|
goodImage: string | null;
|
||||||
goodPriority: number;
|
goodPriority: number;
|
||||||
country: { id: bigint; countryName: string; countryIcon: string | null };
|
country: { id: bigint; countryName: string; countryIcon: string | null };
|
||||||
category: { id: bigint; categoryName: string; categoryIcon: string | null };
|
category: { id: bigint; categoryName: string; categoryIcon: string | null };
|
||||||
@@ -105,6 +105,7 @@ export class PublicService {
|
|||||||
goodImage: string | null;
|
goodImage: string | null;
|
||||||
goodPrice: { toString(): string } | null;
|
goodPrice: { toString(): string } | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
goodTags: { tag: { id: bigint; tagName: string; tagColor: string | null } }[];
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}): PublicGoodDto {
|
}): PublicGoodDto {
|
||||||
return {
|
return {
|
||||||
@@ -128,13 +129,18 @@ export class PublicService {
|
|||||||
tagColor: good.tag.tagColor,
|
tagColor: good.tag.tagColor,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
tags: good.goodTags.map((gt) => ({
|
||||||
|
id: gt.tag.id.toString(),
|
||||||
|
tagName: gt.tag.tagName,
|
||||||
|
tagColor: gt.tag.tagColor,
|
||||||
|
})),
|
||||||
position: good.position
|
position: good.position
|
||||||
? {
|
? {
|
||||||
id: good.position.id.toString(),
|
id: good.position.id.toString(),
|
||||||
indexVal: good.position.indexVal,
|
indexVal: good.position.indexVal,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
image: good.originGood?.goodImage ?? null,
|
image: good.goodImage ?? good.originGood?.goodImage ?? null,
|
||||||
price:
|
price:
|
||||||
good.originGood?.goodPrice === null ||
|
good.originGood?.goodPrice === null ||
|
||||||
good.originGood?.goodPrice === undefined
|
good.originGood?.goodPrice === undefined
|
||||||
@@ -180,4 +186,4 @@ export class PublicService {
|
|||||||
}
|
}
|
||||||
return roots;
|
return roots;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user