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:
@@ -16,7 +16,7 @@ export const categoriesApi = {
|
||||
|
||||
// Get category tree
|
||||
getCategoryTree: () => {
|
||||
return request.get<any, CategoryTree[]>('/categories/tree')
|
||||
return request.get<any, CategoryTree[]>('/categories')
|
||||
},
|
||||
|
||||
// Get category by id
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import request from './request'
|
||||
import type { OriginGood } from '@/types'
|
||||
import type { OriginGood, OriginGoodsTreeResponse, PaginatedResult } from '@/types'
|
||||
|
||||
export const originGoodsApi = {
|
||||
// Search origin goods
|
||||
searchOriginGoods: (keyword: string) => {
|
||||
return request.get<any, OriginGood[]>('/origin-goods/search', { params: { keyword } })
|
||||
getTree: () => {
|
||||
return request.get<any, OriginGoodsTreeResponse>('/origin-goods/tree')
|
||||
},
|
||||
|
||||
// Get origin goods list
|
||||
getOriginGoodsList: (page = 1, pageSize = 20) => {
|
||||
return request.get<any, { data: OriginGood[]; total: number }>('/origin-goods', {
|
||||
params: { page, pageSize },
|
||||
})
|
||||
getOriginGoodsList: (params: { page?: number; pageSize?: number; keyword?: string }) => {
|
||||
return request.get<any, PaginatedResult<OriginGood>>('/origin-goods', { params })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +28,12 @@ request.interceptors.request.use(
|
||||
// Response interceptor
|
||||
request.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
// Unwrap data.data
|
||||
return response.data
|
||||
// Backend wraps everything in { data, success } — unwrap to data
|
||||
const body = response.data
|
||||
if (body && typeof body === 'object' && 'success' in body && 'data' in body) {
|
||||
return body.data
|
||||
}
|
||||
return body
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
if (error.response) {
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
import request from './request'
|
||||
import type { SyncLog, SyncStats, PaginatedResult } from '@/types'
|
||||
import type { SyncLog } from '@/types'
|
||||
|
||||
export const syncApi = {
|
||||
// Trigger category sync
|
||||
syncCategories: () => {
|
||||
return request.post<any, SyncLog>('/sync/categories')
|
||||
},
|
||||
|
||||
// Trigger product sync
|
||||
syncProducts: () => {
|
||||
return request.post<any, SyncLog>('/sync/products')
|
||||
},
|
||||
|
||||
// Get sync logs
|
||||
getSyncLogs: (params: { page?: number; pageSize?: number; type?: 'CATEGORY' | 'PRODUCT' }) => {
|
||||
return request.get<any, PaginatedResult<SyncLog>>('/sync/logs', { params })
|
||||
},
|
||||
|
||||
// Get sync stats
|
||||
getSyncStats: () => {
|
||||
return request.get<any, SyncStats>('/sync/stats')
|
||||
getSyncStatus: (limit?: number) => {
|
||||
return request.get<any, SyncLog[]>('/sync/status', {
|
||||
params: limit ? { limit } : undefined,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
+13
@@ -12,13 +12,18 @@ export {}
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ElAside: typeof import('element-plus/es')['ElAside']
|
||||
ElBadge: typeof import('element-plus/es')['ElBadge']
|
||||
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCascader: typeof import('element-plus/es')['ElCascader']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
@@ -36,10 +41,18 @@ declare module 'vue' {
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
ElTree: typeof import('element-plus/es')['ElTree']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
|
||||
@@ -4,11 +4,6 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Goods,
|
||||
Menu,
|
||||
Location,
|
||||
CollectionTag,
|
||||
Sort,
|
||||
Refresh,
|
||||
Expand,
|
||||
Fold,
|
||||
ArrowDown,
|
||||
@@ -30,21 +25,11 @@ interface MenuItem {
|
||||
}
|
||||
|
||||
const menuItems = ref<MenuItem[]>([
|
||||
{ index: '/goods', title: 'Goods', icon: 'Goods' },
|
||||
{ index: '/categories', title: 'Categories', icon: 'Menu' },
|
||||
{ index: '/countries', title: 'Countries', icon: 'Location' },
|
||||
{ index: '/tags', title: 'Tags', icon: 'CollectionTag' },
|
||||
{ index: '/positions', title: 'Positions', icon: 'Sort' },
|
||||
{ index: '/sync', title: 'Sync', icon: 'Refresh' },
|
||||
{ index: '/goods', title: '商品管理', icon: 'Goods' },
|
||||
])
|
||||
|
||||
const iconMap: Record<string, unknown> = {
|
||||
Goods,
|
||||
Menu,
|
||||
Location,
|
||||
CollectionTag,
|
||||
Sort,
|
||||
Refresh,
|
||||
}
|
||||
|
||||
const activeMenu = computed(() => route.path)
|
||||
@@ -53,7 +38,7 @@ const username = computed(() => authStore.user?.username || 'Admin')
|
||||
|
||||
const breadcrumb = computed(() => {
|
||||
const meta = route.meta as { title?: string } | undefined
|
||||
return meta?.title || 'Dashboard'
|
||||
return meta?.title || '商品管理'
|
||||
})
|
||||
|
||||
function toggleSidebar() {
|
||||
@@ -62,9 +47,9 @@ function toggleSidebar() {
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await ElMessageBox.confirm('Are you sure to sign out?', 'Sign out', {
|
||||
confirmButtonText: 'Sign out',
|
||||
cancelButtonText: 'Cancel',
|
||||
await ElMessageBox.confirm('确定退出登录吗?', '退出登录', {
|
||||
confirmButtonText: '退出登录',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
} catch {
|
||||
@@ -86,11 +71,11 @@ function handleCommand(command: string) {
|
||||
<!-- Sidebar -->
|
||||
<el-aside :width="collapsed ? '64px' : '240px'" class="layout-aside">
|
||||
<div class="brand" :class="{ collapsed }">
|
||||
<div class="brand-mark">IR</div>
|
||||
<div v-if="!collapsed" class="brand-text">
|
||||
<div class="brand-name">InkReach</div>
|
||||
<div class="brand-tag">Product Center</div>
|
||||
</div>
|
||||
<div class="brand-mark">IR</div>
|
||||
<div v-if="!collapsed" class="brand-text">
|
||||
<div class="brand-name">Inkreach</div>
|
||||
<div class="brand-tag">官网后台</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-menu
|
||||
@@ -118,7 +103,7 @@ function handleCommand(command: string) {
|
||||
<el-button
|
||||
text
|
||||
class="collapse-btn"
|
||||
:title="collapsed ? 'Expand sidebar' : 'Collapse sidebar'"
|
||||
:title="collapsed ? '展开侧边栏' : '折叠侧边栏'"
|
||||
@click="toggleSidebar"
|
||||
>
|
||||
<el-icon :size="20">
|
||||
@@ -127,7 +112,7 @@ function handleCommand(command: string) {
|
||||
</el-button>
|
||||
|
||||
<el-breadcrumb separator="/" class="breadcrumb">
|
||||
<el-breadcrumb-item :to="{ path: '/' }">Home</el-breadcrumb-item>
|
||||
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
||||
<el-breadcrumb-item>{{ breadcrumb }}</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
@@ -143,7 +128,7 @@ function handleCommand(command: string) {
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="logout">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
<span>Sign out</span>
|
||||
<span>退出登录</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
|
||||
@@ -6,7 +6,7 @@ const routes: RouteRecordRaw[] = [
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/login/LoginView.vue'),
|
||||
meta: { title: 'Login', public: true },
|
||||
meta: { title: '登录', public: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
@@ -16,38 +16,8 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'goods',
|
||||
name: 'Goods',
|
||||
component: () => import('@/views/goods/GoodsView.vue'),
|
||||
meta: { title: 'Goods', icon: 'Goods' },
|
||||
},
|
||||
{
|
||||
path: 'categories',
|
||||
name: 'Categories',
|
||||
component: () => import('@/views/categories/CategoriesView.vue'),
|
||||
meta: { title: 'Categories', icon: 'Menu' },
|
||||
},
|
||||
{
|
||||
path: 'countries',
|
||||
name: 'Countries',
|
||||
component: () => import('@/views/countries/CountriesView.vue'),
|
||||
meta: { title: 'Countries', icon: 'Location' },
|
||||
},
|
||||
{
|
||||
path: 'tags',
|
||||
name: 'Tags',
|
||||
component: () => import('@/views/tags/TagsView.vue'),
|
||||
meta: { title: 'Tags', icon: 'CollectionTag' },
|
||||
},
|
||||
{
|
||||
path: 'positions',
|
||||
name: 'Positions',
|
||||
component: () => import('@/views/positions/PositionsView.vue'),
|
||||
meta: { title: 'Positions', icon: 'Sort' },
|
||||
},
|
||||
{
|
||||
path: 'sync',
|
||||
name: 'Sync',
|
||||
component: () => import('@/views/sync/SyncView.vue'),
|
||||
meta: { title: 'Sync', icon: 'Refresh' },
|
||||
component: () => import('@/views/product-management/ProductManagementView.vue'),
|
||||
meta: { title: '商品管理' },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -78,8 +48,8 @@ router.beforeEach((to) => {
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
const title = (to.meta?.title as string) || 'InkReach Admin'
|
||||
document.title = `${title} | InkReach Admin`
|
||||
const title = (to.meta?.title as string) || 'Inkreach 官网后台'
|
||||
document.title = `${title} | Inkreach 官网后台`
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -42,10 +42,10 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
async function login(payload: LoginRequest) {
|
||||
const res = await authApi.login(payload) as any;
|
||||
const accessToken = res.data?.accessToken ?? res.accessToken ?? res.token;
|
||||
const userData = res.data?.user ?? res.user;
|
||||
setToken(accessToken);
|
||||
setUser(userData);
|
||||
const accessToken: string = res.accessToken ?? res.data?.accessToken ?? '';
|
||||
const userData: User | null = res.user ?? res.data?.user ?? null;
|
||||
if (accessToken) setToken(accessToken);
|
||||
if (userData) setUser(userData);
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Common types
|
||||
export interface PaginatedResult<T> {
|
||||
data: T[]
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
@@ -13,7 +13,7 @@ export interface LoginRequest {
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
accessToken: string
|
||||
user: User
|
||||
}
|
||||
|
||||
@@ -26,49 +26,58 @@ export interface User {
|
||||
// Good types
|
||||
export interface Good {
|
||||
id: string
|
||||
name: string
|
||||
goodName: string
|
||||
goodImage: string | null
|
||||
goodPriority: number
|
||||
originGoodId: string
|
||||
originGood?: OriginGood
|
||||
countryId: string
|
||||
country?: Country
|
||||
categoryId: string
|
||||
category?: Category
|
||||
tagId: string
|
||||
tagId?: string
|
||||
tag?: Tag
|
||||
tags: Tag[]
|
||||
positionId?: string
|
||||
position?: Position
|
||||
priority: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateGoodRequest {
|
||||
name: string
|
||||
originGoodId: string
|
||||
countryId: string
|
||||
categoryId: string
|
||||
tagId: string
|
||||
positionId?: string
|
||||
priority: number
|
||||
goodName: string
|
||||
goodImage?: string
|
||||
originGoodId: number
|
||||
countryId: number
|
||||
categoryId: number
|
||||
tagIds?: number[]
|
||||
positionId?: number
|
||||
goodPriority?: number
|
||||
}
|
||||
|
||||
export interface UpdateGoodRequest {
|
||||
name?: string
|
||||
originGoodId?: string
|
||||
countryId?: string
|
||||
categoryId?: string
|
||||
tagId?: string
|
||||
positionId?: string
|
||||
goodName?: string
|
||||
goodImage?: string | null
|
||||
originGoodId?: number
|
||||
countryId?: number
|
||||
categoryId?: number
|
||||
tagIds?: number[]
|
||||
positionId?: number | null
|
||||
goodPriority?: number
|
||||
}
|
||||
|
||||
export interface BatchCreateItem {
|
||||
originGoodId: number
|
||||
priority?: number
|
||||
}
|
||||
|
||||
export interface BatchCreateGoodsRequest {
|
||||
originGoodIds: string[]
|
||||
countryId: string
|
||||
categoryId: string
|
||||
tagId: string
|
||||
positionId?: string
|
||||
priority: number
|
||||
items: BatchCreateItem[]
|
||||
countryId: number
|
||||
categoryId: number
|
||||
tagIds?: number[]
|
||||
positionId?: number
|
||||
defaultPriority?: number
|
||||
}
|
||||
|
||||
export interface UpdatePriorityRequest {
|
||||
@@ -79,28 +88,29 @@ export interface UpdatePriorityRequest {
|
||||
// Country types
|
||||
export interface Country {
|
||||
id: string
|
||||
name: string
|
||||
icon?: string
|
||||
countryName: string
|
||||
countryIcon?: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateCountryRequest {
|
||||
name: string
|
||||
icon?: string
|
||||
countryName: string
|
||||
countryIcon?: string
|
||||
}
|
||||
|
||||
export interface UpdateCountryRequest {
|
||||
name?: string
|
||||
icon?: string
|
||||
countryName?: string
|
||||
countryIcon?: string | null
|
||||
}
|
||||
|
||||
// Category types
|
||||
export interface Category {
|
||||
id: string
|
||||
name: string
|
||||
icon?: string
|
||||
parentId?: string
|
||||
categoryName: string
|
||||
categoryIcon?: string | null
|
||||
sdsCategoryId?: string | null
|
||||
parentCategoryId?: string | null
|
||||
parent?: Category
|
||||
children?: Category[]
|
||||
_count?: {
|
||||
@@ -111,15 +121,15 @@ export interface Category {
|
||||
}
|
||||
|
||||
export interface CreateCategoryRequest {
|
||||
name: string
|
||||
icon?: string
|
||||
parentId?: string
|
||||
categoryName: string
|
||||
categoryIcon?: string
|
||||
parentCategoryId?: number
|
||||
}
|
||||
|
||||
export interface UpdateCategoryRequest {
|
||||
name?: string
|
||||
icon?: string
|
||||
parentId?: string
|
||||
categoryName?: string
|
||||
categoryIcon?: string | null
|
||||
parentCategoryId?: number | null
|
||||
}
|
||||
|
||||
export interface CategoryTree extends Category {
|
||||
@@ -129,23 +139,23 @@ export interface CategoryTree extends Category {
|
||||
// Tag types
|
||||
export interface Tag {
|
||||
id: string
|
||||
name: string
|
||||
color?: string
|
||||
timing?: string
|
||||
tagName: string
|
||||
tagColor?: string | null
|
||||
timing?: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateTagRequest {
|
||||
name: string
|
||||
color?: string
|
||||
tagName: string
|
||||
tagColor?: string
|
||||
timing?: string
|
||||
}
|
||||
|
||||
export interface UpdateTagRequest {
|
||||
name?: string
|
||||
color?: string
|
||||
timing?: string
|
||||
tagName?: string
|
||||
tagColor?: string | null
|
||||
timing?: string | null
|
||||
}
|
||||
|
||||
// Position types
|
||||
@@ -162,26 +172,56 @@ export interface Position {
|
||||
|
||||
export interface CreatePositionRequest {
|
||||
indexVal: number
|
||||
countryId?: string
|
||||
categoryId?: string
|
||||
countryId?: number
|
||||
categoryId?: number
|
||||
}
|
||||
|
||||
export interface UpdatePositionRequest {
|
||||
indexVal?: number
|
||||
countryId?: string
|
||||
categoryId?: string
|
||||
countryId?: number | null
|
||||
categoryId?: number | null
|
||||
}
|
||||
|
||||
// Origin Good types
|
||||
export interface OriginGood {
|
||||
id: string
|
||||
name: string
|
||||
productId: string
|
||||
categoryId?: string
|
||||
goodName: string | null
|
||||
goodImage: string | null
|
||||
goodPrice: string | null
|
||||
sdsGoodId: string
|
||||
sdsCategoryId: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// Origin Goods Tree types
|
||||
export interface OriginGoodsTreeNode {
|
||||
id: string
|
||||
goodName: string
|
||||
goodImage: string | null
|
||||
goodPrice: string | null
|
||||
sdsGoodId: string
|
||||
configuredCount: number
|
||||
configuredCountries: string[]
|
||||
configuredTags: { tagName: string; tagColor: string | null }[]
|
||||
}
|
||||
|
||||
export interface OriginGoodsTreeCategoryNode {
|
||||
categoryId: string
|
||||
categoryName: string
|
||||
sdsCategoryId: string | null
|
||||
configuredCount: number
|
||||
totalCount: number
|
||||
children: OriginGoodsTreeCategoryNode[]
|
||||
originGoods: OriginGoodsTreeNode[]
|
||||
}
|
||||
|
||||
export interface OriginGoodsTreeResponse {
|
||||
tree: OriginGoodsTreeCategoryNode[]
|
||||
totalOriginGoods: number
|
||||
configuredCount: number
|
||||
}
|
||||
|
||||
// Sync types
|
||||
export interface SyncLog {
|
||||
id: string
|
||||
@@ -194,12 +234,6 @@ export interface SyncLog {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface SyncStats {
|
||||
lastSyncTime?: string
|
||||
status?: 'IDLE' | 'SYNCING'
|
||||
errorCount?: number
|
||||
}
|
||||
|
||||
// Filter types
|
||||
export interface GoodsFilter {
|
||||
countryId?: string
|
||||
@@ -211,20 +245,20 @@ export interface GoodsFilter {
|
||||
}
|
||||
|
||||
export interface CategoryFilter {
|
||||
parentId?: string
|
||||
name?: string
|
||||
parentCategoryId?: string
|
||||
categoryName?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface CountryFilter {
|
||||
name?: string
|
||||
countryName?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface TagFilter {
|
||||
name?: string
|
||||
tagName?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
@@ -234,4 +268,4 @@ export interface PositionFilter {
|
||||
categoryId?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ const cascaderOptions = ref<CascaderNode[]>([])
|
||||
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
|
||||
return nodes.map((n) => ({
|
||||
value: n.id,
|
||||
label: n.name,
|
||||
label: n.categoryName,
|
||||
children: n.children?.length ? buildCascader(n.children) : undefined,
|
||||
}))
|
||||
}
|
||||
@@ -50,18 +50,18 @@ const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreateCategoryRequest & { id?: string }>({
|
||||
name: '',
|
||||
icon: '',
|
||||
parentId: '',
|
||||
categoryName: '',
|
||||
categoryIcon: '',
|
||||
parentCategoryId: '',
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
name: [{ required: true, message: 'Name is required', trigger: 'blur' }],
|
||||
categoryName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
async function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, name: '', icon: '', parentId: '' })
|
||||
Object.assign(dialogForm, { id: undefined, categoryName: '', categoryIcon: '', parentCategoryId: '' })
|
||||
rebuildCascader()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
@@ -70,9 +70,9 @@ async function openEditDialog(c: Category) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
icon: c.icon || '',
|
||||
parentId: c.parentId || '',
|
||||
categoryName: c.categoryName,
|
||||
categoryIcon: c.categoryIcon || '',
|
||||
parentCategoryId: c.parentCategoryId || '',
|
||||
})
|
||||
rebuildCascader()
|
||||
dialogVisible.value = true
|
||||
@@ -85,16 +85,16 @@ async function handleSubmit() {
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreateCategoryRequest = {
|
||||
name: dialogForm.name,
|
||||
icon: dialogForm.icon || undefined,
|
||||
parentId: dialogForm.parentId || undefined,
|
||||
categoryName: dialogForm.categoryName,
|
||||
categoryIcon: dialogForm.categoryIcon || undefined,
|
||||
parentCategoryId: dialogForm.parentCategoryId || undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await categoriesApi.createCategory(payload)
|
||||
ElMessage.success('Category created')
|
||||
ElMessage.success('分类创建成功')
|
||||
} else {
|
||||
await categoriesApi.updateCategory(dialogForm.id!, payload as UpdateCategoryRequest)
|
||||
ElMessage.success('Category updated')
|
||||
ElMessage.success('分类更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchTree()
|
||||
@@ -106,16 +106,16 @@ async function handleSubmit() {
|
||||
|
||||
async function handleDelete(c: Category) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`Delete "${c.name}"?`, 'Confirm', {
|
||||
await ElMessageBox.confirm(`确定删除「${c.categoryName}」吗?`, '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: 'Delete',
|
||||
cancelButtonText: 'Cancel',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await categoriesApi.deleteCategory(c.id)
|
||||
ElMessage.success('Deleted')
|
||||
ElMessage.success('删除成功')
|
||||
fetchTree()
|
||||
}
|
||||
|
||||
@@ -128,11 +128,11 @@ onMounted(fetchTree)
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>Add Category</span>
|
||||
<span>新增分类</span>
|
||||
</el-button>
|
||||
<el-button @click="fetchTree">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>Refresh</span>
|
||||
<span>刷新</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
@@ -144,52 +144,52 @@ onMounted(fetchTree)
|
||||
:tree-props="{ children: 'children' }"
|
||||
default-expand-all
|
||||
>
|
||||
<el-table-column prop="name" label="Name" min-width="220" />
|
||||
<el-table-column label="Icon" width="100">
|
||||
<el-table-column prop="categoryName" label="名称" min-width="220" />
|
||||
<el-table-column label="图标" width="100">
|
||||
<template #default="{ row }: { row: CategoryTree }">
|
||||
<el-image
|
||||
v-if="row.icon"
|
||||
:src="row.icon"
|
||||
:preview-src-list="[row.icon]"
|
||||
v-if="row.categoryIcon"
|
||||
:src="row.categoryIcon"
|
||||
:preview-src-list="[row.categoryIcon]"
|
||||
fit="cover"
|
||||
style="width: 32px; height: 32px; border-radius: 4px;"
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Parent">
|
||||
<el-table-column label="父级">
|
||||
<template #default="{ row }: { row: CategoryTree }">
|
||||
{{ row.parent?.name || '-' }}
|
||||
{{ row.parent?.categoryName || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Children" width="100">
|
||||
<el-table-column label="子级数" width="100">
|
||||
<template #default="{ row }: { row: CategoryTree }">
|
||||
{{ row._count?.children ?? row.children?.length ?? 0 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Actions" width="180" fixed="right">
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }: { row: CategoryTree }">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>Edit</span>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>Delete</span>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="No categories" />
|
||||
<el-empty description="暂无分类" />
|
||||
</template>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? 'Add Category' : 'Edit Category'"
|
||||
:title="dialogMode === 'create' ? '新增分类' : '编辑分类'"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
>
|
||||
@@ -199,12 +199,12 @@ onMounted(fetchTree)
|
||||
:rules="dialogRules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="Name" prop="name">
|
||||
<el-input v-model="dialogForm.name" placeholder="Category name" />
|
||||
<el-form-item label="名称" prop="categoryName">
|
||||
<el-input v-model="dialogForm.categoryName" placeholder="请输入分类名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Parent">
|
||||
<el-form-item label="父级分类">
|
||||
<el-cascader
|
||||
v-model="dialogForm.parentId"
|
||||
v-model="dialogForm.parentCategoryId"
|
||||
:options="cascaderOptions"
|
||||
:props="{
|
||||
checkStrictly: true,
|
||||
@@ -213,19 +213,19 @@ onMounted(fetchTree)
|
||||
children: 'children',
|
||||
emitPath: false,
|
||||
}"
|
||||
placeholder="Top-level (optional)"
|
||||
placeholder="顶级(可选)"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="Icon URL">
|
||||
<el-input v-model="dialogForm.icon" placeholder="https://..." />
|
||||
<el-form-item label="图标 URL">
|
||||
<el-input v-model="dialogForm.categoryIcon" placeholder="https://..." />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">Cancel</el-button>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? 'Create' : 'Save' }}
|
||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -15,7 +15,7 @@ const list = ref<Country[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const filter = reactive<Required<CountryFilter>>({
|
||||
name: '',
|
||||
countryName: '',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})
|
||||
@@ -24,8 +24,10 @@ async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await countriesApi.getCountriesList(filter)
|
||||
list.value = res.data
|
||||
total.value = res.total
|
||||
const data = await countriesApi.getCountriesList(filter) as any
|
||||
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||
list.value = arr
|
||||
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -37,7 +39,7 @@ function handleSearch() {
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filter.name = ''
|
||||
filter.countryName = ''
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
@@ -48,23 +50,23 @@ const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreateCountryRequest & { id?: string }>({
|
||||
name: '',
|
||||
icon: '',
|
||||
countryName: '',
|
||||
countryIcon: '',
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
name: [{ required: true, message: 'Name is required', trigger: 'blur' }],
|
||||
countryName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, name: '', icon: '' })
|
||||
Object.assign(dialogForm, { id: undefined, countryName: '', countryIcon: '' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(c: Country) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, { id: c.id, name: c.name, icon: c.icon || '' })
|
||||
Object.assign(dialogForm, { id: c.id, countryName: c.countryName, countryIcon: c.countryIcon || '' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -75,15 +77,15 @@ async function handleSubmit() {
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreateCountryRequest = {
|
||||
name: dialogForm.name,
|
||||
icon: dialogForm.icon || undefined,
|
||||
countryName: dialogForm.countryName,
|
||||
countryIcon: dialogForm.countryIcon || undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await countriesApi.createCountry(payload)
|
||||
ElMessage.success('Country created')
|
||||
ElMessage.success('国家创建成功')
|
||||
} else {
|
||||
await countriesApi.updateCountry(dialogForm.id!, payload as UpdateCountryRequest)
|
||||
ElMessage.success('Country updated')
|
||||
ElMessage.success('国家更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
@@ -95,16 +97,16 @@ async function handleSubmit() {
|
||||
|
||||
async function handleDelete(c: Country) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`Delete "${c.name}"?`, 'Confirm', {
|
||||
await ElMessageBox.confirm(`确定删除「${c.countryName}」吗?`, '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: 'Delete',
|
||||
cancelButtonText: 'Cancel',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await countriesApi.deleteCountry(c.id)
|
||||
ElMessage.success('Deleted')
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
@@ -116,8 +118,8 @@ onMounted(fetchList)
|
||||
<div class="page-card">
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="filter.name"
|
||||
placeholder="Search by name"
|
||||
v-model="filter.countryName"
|
||||
placeholder="按名称搜索"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
@@ -128,56 +130,56 @@ onMounted(fetchList)
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>Search</span>
|
||||
<span>搜索</span>
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>Reset</span>
|
||||
<span>重置</span>
|
||||
</el-button>
|
||||
|
||||
<div class="filter-spacer" />
|
||||
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>Add Country</span>
|
||||
<span>新增国家</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border stripe>
|
||||
<el-table-column label="Icon" width="80">
|
||||
<el-table-column label="图标" width="80">
|
||||
<template #default="{ row }: { row: Country }">
|
||||
<el-image
|
||||
v-if="row.icon"
|
||||
:src="row.icon"
|
||||
:preview-src-list="[row.icon]"
|
||||
v-if="row.countryIcon"
|
||||
:src="row.countryIcon"
|
||||
:preview-src-list="[row.countryIcon]"
|
||||
fit="cover"
|
||||
style="width: 32px; height: 32px; border-radius: 4px;"
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="Name" min-width="200" />
|
||||
<el-table-column label="Created" width="180">
|
||||
<el-table-column prop="countryName" label="名称" min-width="200" />
|
||||
<el-table-column label="创建时间" width="180">
|
||||
<template #default="{ row }: { row: Country }">
|
||||
{{ new Date(row.createdAt).toLocaleString() }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Actions" width="180" fixed="right">
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }: { row: Country }">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>Edit</span>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>Delete</span>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="No countries" />
|
||||
<el-empty description="暂无国家" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
@@ -195,22 +197,22 @@ onMounted(fetchList)
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? 'Add Country' : 'Edit Country'"
|
||||
:title="dialogMode === 'create' ? '新增国家' : '编辑国家'"
|
||||
width="480px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
||||
<el-form-item label="Name" prop="name">
|
||||
<el-input v-model="dialogForm.name" placeholder="Country name" />
|
||||
<el-form-item label="名称" prop="countryName">
|
||||
<el-input v-model="dialogForm.countryName" placeholder="请输入国家名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Icon URL">
|
||||
<el-input v-model="dialogForm.icon" placeholder="https://..." />
|
||||
<el-form-item label="图标 URL">
|
||||
<el-input v-model="dialogForm.countryIcon" placeholder="https://..." />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">Cancel</el-button>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? 'Create' : 'Save' }}
|
||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,12 +17,12 @@ const form = reactive({
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [
|
||||
{ required: true, message: 'Please enter your username', trigger: 'blur' },
|
||||
{ min: 2, max: 64, message: 'Length 2-64', trigger: 'blur' },
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 2, max: 64, message: '长度 2-64', trigger: 'blur' },
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: 'Please enter your password', trigger: 'blur' },
|
||||
{ min: 4, max: 64, message: 'Length 4-64', trigger: 'blur' },
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 4, max: 64, message: '长度 4-64', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ async function handleSubmit() {
|
||||
loading.value = true
|
||||
try {
|
||||
await authStore.login({ username: form.username, password: form.password })
|
||||
ElMessage.success('Login successful')
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
// Error toast is shown by axios response interceptor
|
||||
@@ -50,8 +50,8 @@ async function handleSubmit() {
|
||||
<div class="login-bg" />
|
||||
<div class="login-card">
|
||||
<div class="login-brand">
|
||||
<div class="brand-logo">InkReach</div>
|
||||
<div class="brand-subtitle">Product Center · Admin Console</div>
|
||||
<div class="brand-logo">Inkreach</div>
|
||||
<div class="brand-subtitle">官网后台</div>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
@@ -62,10 +62,10 @@ async function handleSubmit() {
|
||||
label-position="top"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<el-form-item label="Username" prop="username">
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="Enter your username"
|
||||
placeholder="请输入用户名"
|
||||
clearable
|
||||
autocomplete="username"
|
||||
>
|
||||
@@ -75,11 +75,11 @@ async function handleSubmit() {
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Password" prop="password">
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
placeholder="请输入密码"
|
||||
show-password
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="handleSubmit"
|
||||
@@ -98,13 +98,13 @@ async function handleSubmit() {
|
||||
native-type="submit"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
Sign in
|
||||
登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="login-footer">
|
||||
<span>© {{ new Date().getFullYear() }} InkReach</span>
|
||||
<span>© {{ new Date().getFullYear() }} Inkreach</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@ async function loadLookups() {
|
||||
countriesApi.getCountriesList({ page: 1, pageSize: 500 }),
|
||||
categoriesApi.getCategoryTree(),
|
||||
])
|
||||
countries.value = c.data
|
||||
countries.value = c.items
|
||||
categoriesTree.value = ct
|
||||
}
|
||||
|
||||
@@ -42,8 +42,10 @@ async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await positionsApi.getPositionsList(filter)
|
||||
list.value = res.data
|
||||
total.value = res.total
|
||||
const data = await positionsApi.getPositionsList(filter) as any
|
||||
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||
list.value = arr
|
||||
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -73,7 +75,7 @@ const cascaderOptions = ref<CascaderNode[]>([])
|
||||
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
|
||||
return nodes.map((n) => ({
|
||||
value: n.id,
|
||||
label: n.name,
|
||||
label: n.categoryName,
|
||||
children: n.children?.length ? buildCascader(n.children) : undefined,
|
||||
}))
|
||||
}
|
||||
@@ -91,7 +93,7 @@ const dialogForm = reactive<CreatePositionRequest & { id?: string }>({
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
indexVal: [{ required: true, message: 'Index is required', trigger: 'blur' }],
|
||||
indexVal: [{ required: true, message: '排序值是必填项', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function rebuildCascader() {
|
||||
@@ -130,10 +132,10 @@ async function handleSubmit() {
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await positionsApi.createPosition(payload)
|
||||
ElMessage.success('Position created')
|
||||
ElMessage.success('位置创建成功')
|
||||
} else {
|
||||
await positionsApi.updatePosition(dialogForm.id!, payload as UpdatePositionRequest)
|
||||
ElMessage.success('Position updated')
|
||||
ElMessage.success('位置更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
@@ -145,22 +147,22 @@ async function handleSubmit() {
|
||||
|
||||
async function handleDelete(p: Position) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`Delete position #${p.indexVal}?`, 'Confirm', {
|
||||
await ElMessageBox.confirm(`确定删除位置 #${p.indexVal} 吗?`, '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: 'Delete',
|
||||
cancelButtonText: 'Cancel',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await positionsApi.deletePosition(p.id)
|
||||
ElMessage.success('Deleted')
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function getCategoryName(p: Position): string {
|
||||
if (!p.category) return p.categoryId || '-'
|
||||
return p.category.parent ? `${p.category.parent.name} / ${p.category.name}` : p.category.name
|
||||
return p.category.parent ? `${p.category.parent.categoryName} / ${p.category.categoryName}` : p.category.categoryName
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -175,14 +177,14 @@ onMounted(async () => {
|
||||
<div class="filter-bar">
|
||||
<el-select
|
||||
v-model="filter.countryId"
|
||||
placeholder="Country"
|
||||
placeholder="请选择国家"
|
||||
clearable
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="c in countries"
|
||||
:key="c.id"
|
||||
:label="c.name"
|
||||
:label="c.countryName"
|
||||
:value="c.id"
|
||||
/>
|
||||
</el-select>
|
||||
@@ -191,56 +193,56 @@ onMounted(async () => {
|
||||
v-model="filter.categoryId"
|
||||
:options="cascaderOptions"
|
||||
:props="{ checkStrictly: true, value: 'value', label: 'label', children: 'children', emitPath: false }"
|
||||
placeholder="Category"
|
||||
placeholder="请选择分类"
|
||||
clearable
|
||||
@change="handleSearch"
|
||||
/>
|
||||
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>Search</span>
|
||||
<span>搜索</span>
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>Reset</span>
|
||||
<span>重置</span>
|
||||
</el-button>
|
||||
|
||||
<div class="filter-spacer" />
|
||||
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>Add Position</span>
|
||||
<span>新增位置</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border stripe>
|
||||
<el-table-column label="Index" prop="indexVal" width="100" sortable />
|
||||
<el-table-column label="Country" min-width="160">
|
||||
<el-table-column label="排序值" prop="indexVal" width="100" sortable />
|
||||
<el-table-column label="国家" min-width="160">
|
||||
<template #default="{ row }: { row: Position }">
|
||||
{{ row.country?.name || '-' }}
|
||||
{{ row.country?.countryName || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Category" min-width="200">
|
||||
<el-table-column label="分类" min-width="200">
|
||||
<template #default="{ row }: { row: Position }">
|
||||
{{ getCategoryName(row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Actions" width="180" fixed="right">
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }: { row: Position }">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>Edit</span>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>Delete</span>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="No positions" />
|
||||
<el-empty description="暂无位置" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
@@ -258,25 +260,25 @@ onMounted(async () => {
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? 'Add Position' : 'Edit Position'"
|
||||
:title="dialogMode === 'create' ? '新增位置' : '编辑位置'"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
||||
<el-form-item label="Index" prop="indexVal">
|
||||
<el-form-item label="排序值" prop="indexVal">
|
||||
<el-input-number v-model="dialogForm.indexVal" :min="0" :max="9999" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Country">
|
||||
<el-select v-model="dialogForm.countryId" placeholder="Optional" clearable style="width: 100%">
|
||||
<el-form-item label="国家">
|
||||
<el-select v-model="dialogForm.countryId" placeholder="可选" clearable style="width: 100%">
|
||||
<el-option
|
||||
v-for="c in countries"
|
||||
:key="c.id"
|
||||
:label="c.name"
|
||||
:label="c.countryName"
|
||||
:value="c.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Category">
|
||||
<el-form-item label="分类">
|
||||
<el-cascader
|
||||
v-model="dialogForm.categoryId"
|
||||
:options="cascaderOptions"
|
||||
@@ -287,16 +289,16 @@ onMounted(async () => {
|
||||
children: 'children',
|
||||
emitPath: false,
|
||||
}"
|
||||
placeholder="Optional"
|
||||
placeholder="可选"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">Cancel</el-button>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? 'Create' : 'Save' }}
|
||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { markRaw, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Goods, Refresh } from '@element-plus/icons-vue'
|
||||
import GoodsView from '@/views/goods/GoodsView.vue'
|
||||
import SyncView from '@/views/sync/SyncView.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const tabs = [
|
||||
{ name: 'goods', label: '商品配置', icon: markRaw(Goods), comp: markRaw(GoodsView) },
|
||||
{ name: 'sync', label: '数据同步', icon: markRaw(Refresh), comp: markRaw(SyncView) },
|
||||
]
|
||||
|
||||
const activeTab = ref((route.query.tab as string) || 'goods')
|
||||
|
||||
watch(activeTab, (val) => {
|
||||
router.replace({ query: { ...route.query, tab: val } })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pm-wrapper">
|
||||
<el-tabs v-model="activeTab" class="pm-tabs" type="card">
|
||||
<el-tab-pane
|
||||
v-for="t in tabs"
|
||||
:key="t.name"
|
||||
:name="t.name"
|
||||
>
|
||||
<template #label>
|
||||
<span class="pm-tab-label">
|
||||
<el-icon class="pm-tab-icon"><component :is="t.icon" /></el-icon>
|
||||
{{ t.label }}
|
||||
</span>
|
||||
</template>
|
||||
<component :is="t.comp" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pm-wrapper { height: 100%; display: flex; flex-direction: column; }
|
||||
.pm-tabs { height: 100%; display: flex; flex-direction: column; }
|
||||
.pm-tabs :deep(.el-tabs__header) { margin-bottom: 0; flex-shrink: 0; }
|
||||
.pm-tabs :deep(.el-tabs__content) { flex: 1; min-height: 0; overflow: hidden; }
|
||||
.pm-tabs :deep(.el-tab-pane) { height: 100%; }
|
||||
.pm-tab-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.pm-tab-icon { font-size: 14px; }
|
||||
</style>
|
||||
@@ -1,99 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh, Folder, Box } from '@element-plus/icons-vue'
|
||||
import type { SyncLog, SyncStats } from '@/types'
|
||||
import { Refresh, Box } from '@element-plus/icons-vue'
|
||||
import type { SyncLog } from '@/types'
|
||||
import { syncApi } from '@/api/sync'
|
||||
|
||||
const stats = ref<SyncStats | null>(null)
|
||||
const logs = ref<SyncLog[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const syncingCategories = ref(false)
|
||||
const syncingProducts = ref(false)
|
||||
|
||||
const filter = reactive<{ page: number; pageSize: number; type: '' | 'CATEGORY' | 'PRODUCT' }>({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
type: '',
|
||||
})
|
||||
const syncing = ref(false)
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function refreshStats() {
|
||||
try {
|
||||
stats.value = await syncApi.getSyncStats()
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch sync stats', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await syncApi.getSyncLogs({
|
||||
page: filter.page,
|
||||
pageSize: filter.pageSize,
|
||||
type: filter.type || undefined,
|
||||
})
|
||||
logs.value = res.data
|
||||
total.value = res.total
|
||||
const data = await syncApi.getSyncStatus(50) as any
|
||||
logs.value = Array.isArray(data) ? data : []
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch sync logs', err)
|
||||
console.warn('Failed to fetch sync status', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
await Promise.all([refreshStats(), refreshLogs()])
|
||||
}
|
||||
|
||||
async function handleSyncCategories() {
|
||||
async function handleSync() {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'Trigger category sync now? This may take a while.',
|
||||
'Confirm',
|
||||
{
|
||||
type: 'info',
|
||||
confirmButtonText: 'Run',
|
||||
cancelButtonText: 'Cancel',
|
||||
}
|
||||
'确定立即执行产品同步吗?此操作可能需要一些时间。',
|
||||
'确认',
|
||||
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
syncingCategories.value = true
|
||||
try {
|
||||
const log = await syncApi.syncCategories()
|
||||
ElMessage.success(`Category sync started (${log.id})`)
|
||||
await refreshAll()
|
||||
} finally {
|
||||
syncingCategories.value = false
|
||||
}
|
||||
}
|
||||
} catch { return }
|
||||
|
||||
async function handleSyncProducts() {
|
||||
syncing.value = true
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'Trigger product sync now? This may take a while.',
|
||||
'Confirm',
|
||||
{
|
||||
type: 'info',
|
||||
confirmButtonText: 'Run',
|
||||
cancelButtonText: 'Cancel',
|
||||
}
|
||||
)
|
||||
await syncApi.syncProducts()
|
||||
ElMessage.success('产品同步完成')
|
||||
await refreshLogs()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
syncingProducts.value = true
|
||||
try {
|
||||
const log = await syncApi.syncProducts()
|
||||
ElMessage.success(`Product sync started (${log.id})`)
|
||||
await refreshAll()
|
||||
ElMessage.error('产品同步失败')
|
||||
} finally {
|
||||
syncingProducts.value = false
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,17 +49,9 @@ function formatDate(s?: string): string {
|
||||
return new Date(s).toLocaleString()
|
||||
}
|
||||
|
||||
function statusType(status: SyncLog['status']) {
|
||||
return status === 'SUCCESS' ? 'success' : 'danger'
|
||||
}
|
||||
|
||||
function typeLabel(type: SyncLog['type']) {
|
||||
return type === 'CATEGORY' ? 'Category' : 'Product'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshAll()
|
||||
timer = setInterval(refreshAll, 30_000)
|
||||
refreshLogs()
|
||||
timer = setInterval(refreshLogs, 30_000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -122,156 +61,58 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-card sync-status">
|
||||
<div class="status-item">
|
||||
<div class="status-label">Last Sync</div>
|
||||
<div class="status-value">{{ formatDate(stats?.lastSyncTime) }}</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<div class="status-label">Current Status</div>
|
||||
<div class="status-value">
|
||||
<el-tag :type="stats?.status === 'SYNCING' ? 'warning' : 'success'">
|
||||
{{ stats?.status || 'IDLE' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<div class="status-label">Error Count</div>
|
||||
<div class="status-value">{{ stats?.errorCount ?? 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sync-cards">
|
||||
<el-card class="sync-card">
|
||||
<template #header>
|
||||
<div class="sync-card-header">
|
||||
<div class="sync-card-title">
|
||||
<el-icon><Folder /></el-icon>
|
||||
<span>Category Sync</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p class="sync-card-desc">
|
||||
Pull latest categories from upstream and reconcile local database.
|
||||
</p>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="syncingCategories"
|
||||
@click="handleSyncCategories"
|
||||
>
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>Run Category Sync</span>
|
||||
</el-button>
|
||||
</el-card>
|
||||
|
||||
<el-card class="sync-card">
|
||||
<template #header>
|
||||
<div class="sync-card-header">
|
||||
<div class="sync-card-title">
|
||||
<el-icon><Box /></el-icon>
|
||||
<span>Product Sync</span>
|
||||
<span>产品同步</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p class="sync-card-desc">
|
||||
Pull latest products/origin goods from upstream and reconcile local database.
|
||||
从上游拉取最新产品和原产品并与本地数据库进行同步。
|
||||
</p>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="syncingProducts"
|
||||
@click="handleSyncProducts"
|
||||
>
|
||||
<el-button type="primary" :loading="syncing" @click="handleSync">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>Run Product Sync</span>
|
||||
<span>执行产品同步</span>
|
||||
</el-button>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div class="page-card logs-card">
|
||||
<div class="logs-toolbar">
|
||||
<h3 class="logs-title">Sync Logs</h3>
|
||||
<div class="logs-filter">
|
||||
<el-select v-model="filter.type" placeholder="All types" clearable style="width: 160px;">
|
||||
<el-option label="Category" value="CATEGORY" />
|
||||
<el-option label="Product" value="PRODUCT" />
|
||||
</el-select>
|
||||
<el-button @click="refreshLogs">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>Refresh</span>
|
||||
</el-button>
|
||||
</div>
|
||||
<h3 class="logs-title">同步日志</h3>
|
||||
<el-button @click="refreshLogs">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>刷新</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="logs" border stripe>
|
||||
<el-table-column label="Type" width="120">
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }: { row: SyncLog }">
|
||||
<el-tag :type="row.type === 'CATEGORY' ? 'warning' : 'primary'">
|
||||
{{ typeLabel(row.type) }}
|
||||
<el-tag :type="row.status === 'SUCCESS' ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 'SUCCESS' ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Status" width="120">
|
||||
<template #default="{ row }: { row: SyncLog }">
|
||||
<el-tag :type="statusType(row.status)">{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Started" width="180">
|
||||
<el-table-column label="时间" width="200">
|
||||
<template #default="{ row }: { row: SyncLog }">
|
||||
{{ formatDate(row.startTime) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Ended" width="180">
|
||||
<template #default="{ row }: { row: SyncLog }">
|
||||
{{ formatDate(row.endTime) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Errors" prop="errorCount" width="90" />
|
||||
<el-table-column prop="message" label="Message" min-width="220" />
|
||||
<el-table-column prop="message" label="信息" min-width="200" show-overflow-tooltip />
|
||||
<template #empty>
|
||||
<el-empty description="No sync logs" />
|
||||
<el-empty description="暂无同步记录" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
class="pagination"
|
||||
v-model:current-page="filter.page"
|
||||
v-model:page-size="filter.pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="(p: number) => { filter.page = p; refreshLogs() }"
|
||||
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; refreshLogs() }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sync-status {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
flex: 1;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.sync-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
@@ -316,15 +157,4 @@ onUnmounted(() => {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.logs-filter {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,7 +15,7 @@ const list = ref<Tag[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const filter = reactive<Required<TagFilter>>({
|
||||
name: '',
|
||||
tagName: '',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})
|
||||
@@ -24,8 +24,10 @@ async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await tagsApi.getTagsList(filter)
|
||||
list.value = res.data
|
||||
total.value = res.total
|
||||
const data = await tagsApi.getTagsList(filter) as any
|
||||
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||
list.value = arr
|
||||
total.value = Array.isArray(data) ? data.length : (data.total ?? 0)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -37,7 +39,7 @@ function handleSearch() {
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filter.name = ''
|
||||
filter.tagName = ''
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
@@ -48,18 +50,18 @@ const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreateTagRequest & { id?: string }>({
|
||||
name: '',
|
||||
color: '#ff6800',
|
||||
tagName: '',
|
||||
tagColor: '#ff6800',
|
||||
timing: '',
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
name: [{ required: true, message: 'Name is required', trigger: 'blur' }],
|
||||
tagName: [{ required: true, message: '名称是必填项', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, name: '', color: '#ff6800', timing: '' })
|
||||
Object.assign(dialogForm, { id: undefined, tagName: '', tagColor: '#ff6800', timing: '' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -67,8 +69,8 @@ function openEditDialog(t: Tag) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, {
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
color: t.color || '#ff6800',
|
||||
tagName: t.tagName,
|
||||
tagColor: t.tagColor || '#ff6800',
|
||||
timing: t.timing || '',
|
||||
})
|
||||
dialogVisible.value = true
|
||||
@@ -81,16 +83,16 @@ async function handleSubmit() {
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreateTagRequest = {
|
||||
name: dialogForm.name,
|
||||
color: dialogForm.color || undefined,
|
||||
tagName: dialogForm.tagName,
|
||||
tagColor: dialogForm.tagColor || undefined,
|
||||
timing: dialogForm.timing || undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await tagsApi.createTag(payload)
|
||||
ElMessage.success('Tag created')
|
||||
ElMessage.success('标签创建成功')
|
||||
} else {
|
||||
await tagsApi.updateTag(dialogForm.id!, payload as UpdateTagRequest)
|
||||
ElMessage.success('Tag updated')
|
||||
ElMessage.success('标签更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
@@ -102,16 +104,16 @@ async function handleSubmit() {
|
||||
|
||||
async function handleDelete(t: Tag) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`Delete "${t.name}"?`, 'Confirm', {
|
||||
await ElMessageBox.confirm(`确定删除「${t.tagName}」吗?`, '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: 'Delete',
|
||||
cancelButtonText: 'Cancel',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await tagsApi.deleteTag(t.id)
|
||||
ElMessage.success('Deleted')
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
@@ -123,8 +125,8 @@ onMounted(fetchList)
|
||||
<div class="page-card">
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="filter.name"
|
||||
placeholder="Search by name"
|
||||
v-model="filter.tagName"
|
||||
placeholder="按名称搜索"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
@@ -135,56 +137,56 @@ onMounted(fetchList)
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>Search</span>
|
||||
<span>搜索</span>
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>Reset</span>
|
||||
<span>重置</span>
|
||||
</el-button>
|
||||
|
||||
<div class="filter-spacer" />
|
||||
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>Add Tag</span>
|
||||
<span>新增标签</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border stripe>
|
||||
<el-table-column label="Preview" width="120">
|
||||
<el-table-column label="预览" width="120">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<el-tag v-if="row.color" :color="row.color" effect="dark">
|
||||
{{ row.name }}
|
||||
<el-tag v-if="row.tagColor" :color="row.tagColor" effect="dark">
|
||||
{{ row.tagName }}
|
||||
</el-tag>
|
||||
<el-tag v-else effect="plain">{{ row.name }}</el-tag>
|
||||
<el-tag v-else effect="plain">{{ row.tagName }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="Name" min-width="200" />
|
||||
<el-table-column label="Color" width="140">
|
||||
<el-table-column prop="tagName" label="名称" min-width="200" />
|
||||
<el-table-column label="颜色" width="140">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<div class="color-cell">
|
||||
<span class="color-swatch" :style="{ background: row.color || '#d1d5db' }" />
|
||||
<span class="color-hex">{{ row.color || '-' }}</span>
|
||||
<span class="color-swatch" :style="{ background: row.tagColor || '#d1d5db' }" />
|
||||
<span class="color-hex">{{ row.tagColor || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="timing" label="Timing" min-width="120" />
|
||||
<el-table-column label="Actions" width="180" fixed="right">
|
||||
<el-table-column prop="timing" label="定时" min-width="120" />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>Edit</span>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>Delete</span>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="No tags" />
|
||||
<el-empty description="暂无标签" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
@@ -202,26 +204,26 @@ onMounted(fetchList)
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? 'Add Tag' : 'Edit Tag'"
|
||||
:title="dialogMode === 'create' ? '新增标签' : '编辑标签'"
|
||||
width="480px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
||||
<el-form-item label="Name" prop="name">
|
||||
<el-input v-model="dialogForm.name" placeholder="Tag name" />
|
||||
<el-form-item label="名称" prop="tagName">
|
||||
<el-input v-model="dialogForm.tagName" placeholder="请输入标签名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Color">
|
||||
<el-color-picker v-model="dialogForm.color" />
|
||||
<span class="color-readout">{{ dialogForm.color }}</span>
|
||||
<el-form-item label="颜色">
|
||||
<el-color-picker v-model="dialogForm.tagColor" />
|
||||
<span class="color-readout">{{ dialogForm.tagColor }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="Timing">
|
||||
<el-input v-model="dialogForm.timing" placeholder="e.g. 9:00-12:00" />
|
||||
<el-form-item label="定时">
|
||||
<el-input v-model="dialogForm.timing" placeholder="例如 9:00-12:00" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">Cancel</el-button>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? 'Create' : 'Save' }}
|
||||
{{ dialogMode === 'create' ? '创建' : '保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
Reference in New Issue
Block a user