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

- Two-line good nodes with country + tag chips, hover tooltip
- Resizable dual-tree with fixed-height panels (no node overlap)
- Filter bar: search, country filter, tag filter (multi-select with rename)
- Mode switch (品类/国家) pushed to right via spacer
- Inline create country/tag in edit & config modals via quick-create buttons
- Right tree node height auto-fix for locate highlight
- Filter dropdown styles in global scope for teleported popper
- Backend: multi-tag (GoodTag junction), goodImage column, origin goods tree API
- Admin response interceptor unwraps {data, success} envelope
- Simplified sidebar to single 商品管理 entry with tabs
- SyncView simplified to product sync only
This commit is contained in:
yeuimu
2026-06-22 01:47:20 +08:00
parent c3472a449b
commit a903dd4903
30 changed files with 1970 additions and 1209 deletions
@@ -0,0 +1,92 @@
# Goods Management Redesign
## Date: 2026-06-21
## Problem
1. **Origin Good name not displayed**: GoodsView doesn't show the origin product name/image/price
2. **Single tag per good**: Current schema only allows one tag per good via `goods.tag_id` FK
3. **Easy to miss unconfigured origin products**: Flat paginated table with no visibility into which origin products have been configured vs not
## Solution
### 1. Multi-Tag Support (GoodTag Junction Table)
**Schema change:**
- New `good_tags` junction table: `(good_id, tag_id)` composite PK
- Migrate existing `goods.tag_id` data into `good_tags`
- Set `goods.tag_id` to nullable (will be dropped in a future migration)
**Backend:**
- `CreateGoodDto`: `tagIds: number[]` (replaces `tagId`)
- `UpdateGoodDto`: `tagIds?: number[]` (replaces `tagId`)
- `BatchCreateGoodDto`: `tagIds?: number[]` (replaces `tagId`)
- `QueryGoodDto`: `tagId` filter queries `tags.some` instead of direct FK
- `GoodsService.create/update`: manage `good_tags` records in transaction
- `GOOD_INCLUDE`: include `tags: { include: { tag: true } }`
### 2. Origin Goods Tree API
New endpoint: `GET /origin-goods/tree`
Returns origin goods grouped by SDS category, with configuration status:
```json
[
{
"sdsCategoryId": "4185",
"categoryName": "(包邮)180g纯棉T恤-单面印花",
"originGoods": [
{
"id": "123",
"goodName": "产品A",
"goodImage": "https://...",
"goodPrice": "12.50",
"sdsGoodId": "p-123",
"configuredCount": 2,
"configuredCountries": ["美国", "英国"]
}
]
}
]
```
Origin goods without `sdsCategoryId` are grouped under "未分类".
### 3. Admin UI: Dual Tree Layout
```
┌──────────────────────────────────────────────────────────┐
│ [搜索原产品] [210/439 已配置] │
├───────────────────┬─────────────────────────────────────┤
│ 首页分类树 │ 原产品分类树 │
│ │ │
│ ▼ 美国 │ ▼ (包邮)180g纯棉T恤 │
│ ▼ T恤 │ ✓ 产品A [美国,英国] │
│ ▼ 卫衣 │ ✗ 产品B [未配置] │
│ ▼ 英国 │ ▼ 未分类 │
│ ▼ 韩国 │ ✗ 产品C [未配置] │
├───────────────────┴─────────────────────────────────────┤
│ 详情/操作区域 │
│ - 选左侧分类: 该分类下所有 goods 表格 │
│ - 选右侧原产品: 该原产品的配置详情 + 快捷配置按钮 │
│ - 多标签选择 (el-select multiple) │
└──────────────────────────────────────────────────────────┘
```
**Key interactions:**
- Left tree click: filter goods by selected category
- Right tree click: show origin good detail + all its goods configs
- Right tree badges: ✓ configured / ✗ unconfigured
- Quick configure: select country+category+tags, click origin goods to batch create
- Statistics bar: configured/total count
### 4. Data Migration
```sql
-- Step 1: Create good_tags table
-- Step 2: Copy existing tag_id data
INSERT INTO good_tags (good_id, tag_id, created_at)
SELECT good_id, tag_id, NOW() FROM goods WHERE tag_id IS NOT NULL;
-- Step 3: Good.tag_id becomes nullable (already nullable)
```
@@ -16,7 +16,7 @@ export const categoriesApi = {
// Get category tree // 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 },
})
}, },
} }
+6 -2
View File
@@ -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) {
+5 -15
View File
@@ -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
View File
@@ -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>
+5 -35
View File
@@ -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
+4 -4
View File
@@ -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;
} }
+99 -65
View File
@@ -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>
+18 -1
View File
@@ -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;
} }
} }