feat(product-center): implement full product center with NestJS backend, Vue3 admin, and Nuxt4 website page
- NestJS backend: Prisma + PostgreSQL, JWT auth, CRUD for goods/categories/countries/tags/positions, SDS sync with cron, public API, Swagger docs - Vue3 Admin: Element Plus, goods/categories/countries/tags/positions/sync management pages, batch operations, login with JWT - Nuxt4 Website: product-center page with sidebar navigation, country filter, search, product grid, pagination, skeleton loading - Nitro proxy routes for backend API - 54 backend tests passing - Updated docs and README
This commit is contained in:
@@ -0,0 +1 @@
|
||||
VITE_API_BASE=/api
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>inkreach-official-admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
+2758
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "inkreach-official-admin",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"axios": "^1.18.0",
|
||||
"dayjs": "^1.11.21",
|
||||
"element-plus": "^2.14.2",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.34",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.3",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"sass": "^1.101.0",
|
||||
"typescript": "~6.0.2",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-vue-components": "^32.1.0",
|
||||
"vite": "^8.0.12",
|
||||
"vue-tsc": "^3.2.8"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
// Root component renders router-managed views.
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
import request from './request'
|
||||
import type { LoginRequest, LoginResponse, User } from '@/types'
|
||||
|
||||
export const authApi = {
|
||||
// Login
|
||||
login: (data: LoginRequest) => {
|
||||
return request.post<any, LoginResponse>('/auth/login', data)
|
||||
},
|
||||
|
||||
// Get current user
|
||||
getCurrentUser: () => {
|
||||
return request.get<any, User>('/auth/me')
|
||||
},
|
||||
|
||||
// Logout
|
||||
logout: () => {
|
||||
return request.post('/auth/logout')
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import request from './request'
|
||||
import type {
|
||||
Category,
|
||||
CategoryTree,
|
||||
CreateCategoryRequest,
|
||||
UpdateCategoryRequest,
|
||||
CategoryFilter,
|
||||
PaginatedResult,
|
||||
} from '@/types'
|
||||
|
||||
export const categoriesApi = {
|
||||
// Get categories list
|
||||
getCategoriesList: (params: CategoryFilter) => {
|
||||
return request.get<any, PaginatedResult<Category>>('/categories', { params })
|
||||
},
|
||||
|
||||
// Get category tree
|
||||
getCategoryTree: () => {
|
||||
return request.get<any, CategoryTree[]>('/categories/tree')
|
||||
},
|
||||
|
||||
// Get category by id
|
||||
getCategoryById: (id: string) => {
|
||||
return request.get<any, Category>(`/categories/${id}`)
|
||||
},
|
||||
|
||||
// Create category
|
||||
createCategory: (data: CreateCategoryRequest) => {
|
||||
return request.post<any, Category>('/categories', data)
|
||||
},
|
||||
|
||||
// Update category
|
||||
updateCategory: (id: string, data: UpdateCategoryRequest) => {
|
||||
return request.patch<any, Category>(`/categories/${id}`, data)
|
||||
},
|
||||
|
||||
// Delete category
|
||||
deleteCategory: (id: string) => {
|
||||
return request.delete(`/categories/${id}`)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import request from './request'
|
||||
import type {
|
||||
Country,
|
||||
CreateCountryRequest,
|
||||
UpdateCountryRequest,
|
||||
CountryFilter,
|
||||
PaginatedResult,
|
||||
} from '@/types'
|
||||
|
||||
export const countriesApi = {
|
||||
// Get countries list
|
||||
getCountriesList: (params: CountryFilter) => {
|
||||
return request.get<any, PaginatedResult<Country>>('/countries', { params })
|
||||
},
|
||||
|
||||
// Get country by id
|
||||
getCountryById: (id: string) => {
|
||||
return request.get<any, Country>(`/countries/${id}`)
|
||||
},
|
||||
|
||||
// Create country
|
||||
createCountry: (data: CreateCountryRequest) => {
|
||||
return request.post<any, Country>('/countries', data)
|
||||
},
|
||||
|
||||
// Update country
|
||||
updateCountry: (id: string, data: UpdateCountryRequest) => {
|
||||
return request.patch<any, Country>(`/countries/${id}`, data)
|
||||
},
|
||||
|
||||
// Delete country
|
||||
deleteCountry: (id: string) => {
|
||||
return request.delete(`/countries/${id}`)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import request from './request'
|
||||
import type {
|
||||
Good,
|
||||
CreateGoodRequest,
|
||||
UpdateGoodRequest,
|
||||
BatchCreateGoodsRequest,
|
||||
UpdatePriorityRequest,
|
||||
GoodsFilter,
|
||||
PaginatedResult,
|
||||
} from '@/types'
|
||||
|
||||
export const goodsApi = {
|
||||
// Get goods list
|
||||
getGoodsList: (params: GoodsFilter) => {
|
||||
return request.get<any, PaginatedResult<Good>>('/goods', { params })
|
||||
},
|
||||
|
||||
// Get good by id
|
||||
getGoodById: (id: string) => {
|
||||
return request.get<any, Good>(`/goods/${id}`)
|
||||
},
|
||||
|
||||
// Create good
|
||||
createGood: (data: CreateGoodRequest) => {
|
||||
return request.post<any, Good>('/goods', data)
|
||||
},
|
||||
|
||||
// Update good
|
||||
updateGood: (id: string, data: UpdateGoodRequest) => {
|
||||
return request.patch<any, Good>(`/goods/${id}`, data)
|
||||
},
|
||||
|
||||
// Delete good
|
||||
deleteGood: (id: string) => {
|
||||
return request.delete(`/goods/${id}`)
|
||||
},
|
||||
|
||||
// Batch create goods
|
||||
batchCreateGoods: (data: BatchCreateGoodsRequest) => {
|
||||
return request.post<any, Good[]>('/goods/batch', data)
|
||||
},
|
||||
|
||||
// Update priority
|
||||
updatePriority: (data: UpdatePriorityRequest) => {
|
||||
return request.patch<any, Good[]>('/goods/priority', data)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import request from './request'
|
||||
import type { OriginGood } from '@/types'
|
||||
|
||||
export const originGoodsApi = {
|
||||
// Search origin goods
|
||||
searchOriginGoods: (keyword: string) => {
|
||||
return request.get<any, OriginGood[]>('/origin-goods/search', { params: { keyword } })
|
||||
},
|
||||
|
||||
// Get origin goods list
|
||||
getOriginGoodsList: (page = 1, pageSize = 20) => {
|
||||
return request.get<any, { data: OriginGood[]; total: number }>('/origin-goods', {
|
||||
params: { page, pageSize },
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import request from './request'
|
||||
import type {
|
||||
Position,
|
||||
CreatePositionRequest,
|
||||
UpdatePositionRequest,
|
||||
PositionFilter,
|
||||
PaginatedResult,
|
||||
} from '@/types'
|
||||
|
||||
export const positionsApi = {
|
||||
// Get positions list
|
||||
getPositionsList: (params: PositionFilter) => {
|
||||
return request.get<any, PaginatedResult<Position>>('/positions', { params })
|
||||
},
|
||||
|
||||
// Get position by id
|
||||
getPositionById: (id: string) => {
|
||||
return request.get<any, Position>(`/positions/${id}`)
|
||||
},
|
||||
|
||||
// Create position
|
||||
createPosition: (data: CreatePositionRequest) => {
|
||||
return request.post<any, Position>('/positions', data)
|
||||
},
|
||||
|
||||
// Update position
|
||||
updatePosition: (id: string, data: UpdatePositionRequest) => {
|
||||
return request.patch<any, Position>(`/positions/${id}`, data)
|
||||
},
|
||||
|
||||
// Delete position
|
||||
deletePosition: (id: string) => {
|
||||
return request.delete(`/positions/${id}`)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import axios from 'axios'
|
||||
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import router from '@/router'
|
||||
|
||||
const request: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE || '/api',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Request interceptor
|
||||
request.interceptors.request.use(
|
||||
(config: AxiosRequestConfig) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// Response interceptor
|
||||
request.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
// Unwrap data.data
|
||||
return response.data
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
if (error.response) {
|
||||
const { status, data } = error.response
|
||||
|
||||
switch (status) {
|
||||
case 401:
|
||||
ElMessage.error('Unauthorized, please login')
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
router.push('/login')
|
||||
break
|
||||
case 403:
|
||||
ElMessage.error('Forbidden')
|
||||
break
|
||||
case 404:
|
||||
ElMessage.error('Resource not found')
|
||||
break
|
||||
case 500:
|
||||
ElMessage.error('Server error')
|
||||
break
|
||||
default:
|
||||
const errorMessage = (data as any)?.message || 'Request failed'
|
||||
ElMessage.error(errorMessage)
|
||||
}
|
||||
} else if (error.request) {
|
||||
ElMessage.error('Network error')
|
||||
} else {
|
||||
ElMessage.error('Request failed')
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default request
|
||||
@@ -0,0 +1,24 @@
|
||||
import request from './request'
|
||||
import type { SyncLog, SyncStats, PaginatedResult } 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')
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import request from './request'
|
||||
import type {
|
||||
Tag,
|
||||
CreateTagRequest,
|
||||
UpdateTagRequest,
|
||||
TagFilter,
|
||||
PaginatedResult,
|
||||
} from '@/types'
|
||||
|
||||
export const tagsApi = {
|
||||
// Get tags list
|
||||
getTagsList: (params: TagFilter) => {
|
||||
return request.get<any, PaginatedResult<Tag>>('/tags', { params })
|
||||
},
|
||||
|
||||
// Get tag by id
|
||||
getTagById: (id: string) => {
|
||||
return request.get<any, Tag>(`/tags/${id}`)
|
||||
},
|
||||
|
||||
// Create tag
|
||||
createTag: (data: CreateTagRequest) => {
|
||||
return request.post<any, Tag>('/tags', data)
|
||||
},
|
||||
|
||||
// Update tag
|
||||
updateTag: (id: string, data: UpdateTagRequest) => {
|
||||
return request.patch<any, Tag>(`/tags/${id}`, data)
|
||||
},
|
||||
|
||||
// Delete tag
|
||||
deleteTag: (id: string) => {
|
||||
return request.delete(`/tags/${id}`)
|
||||
},
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const EffectScope: typeof import('vue').EffectScope
|
||||
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
|
||||
const computed: typeof import('vue').computed
|
||||
const createApp: typeof import('vue').createApp
|
||||
const createPinia: typeof import('pinia').createPinia
|
||||
const customRef: typeof import('vue').customRef
|
||||
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
|
||||
const defineComponent: typeof import('vue').defineComponent
|
||||
const defineStore: typeof import('pinia').defineStore
|
||||
const effectScope: typeof import('vue').effectScope
|
||||
const getActivePinia: typeof import('pinia').getActivePinia
|
||||
const getCurrentInstance: typeof import('vue').getCurrentInstance
|
||||
const getCurrentScope: typeof import('vue').getCurrentScope
|
||||
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
|
||||
const h: typeof import('vue').h
|
||||
const inject: typeof import('vue').inject
|
||||
const isProxy: typeof import('vue').isProxy
|
||||
const isReactive: typeof import('vue').isReactive
|
||||
const isReadonly: typeof import('vue').isReadonly
|
||||
const isRef: typeof import('vue').isRef
|
||||
const isShallow: typeof import('vue').isShallow
|
||||
const mapActions: typeof import('pinia').mapActions
|
||||
const mapGetters: typeof import('pinia').mapGetters
|
||||
const mapState: typeof import('pinia').mapState
|
||||
const mapStores: typeof import('pinia').mapStores
|
||||
const mapWritableState: typeof import('pinia').mapWritableState
|
||||
const markRaw: typeof import('vue').markRaw
|
||||
const nextTick: typeof import('vue').nextTick
|
||||
const onActivated: typeof import('vue').onActivated
|
||||
const onBeforeMount: typeof import('vue').onBeforeMount
|
||||
const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
|
||||
const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
|
||||
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
|
||||
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
|
||||
const onDeactivated: typeof import('vue').onDeactivated
|
||||
const onErrorCaptured: typeof import('vue').onErrorCaptured
|
||||
const onMounted: typeof import('vue').onMounted
|
||||
const onRenderTracked: typeof import('vue').onRenderTracked
|
||||
const onRenderTriggered: typeof import('vue').onRenderTriggered
|
||||
const onScopeDispose: typeof import('vue').onScopeDispose
|
||||
const onServerPrefetch: typeof import('vue').onServerPrefetch
|
||||
const onUnmounted: typeof import('vue').onUnmounted
|
||||
const onUpdated: typeof import('vue').onUpdated
|
||||
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
|
||||
const provide: typeof import('vue').provide
|
||||
const reactive: typeof import('vue').reactive
|
||||
const readonly: typeof import('vue').readonly
|
||||
const ref: typeof import('vue').ref
|
||||
const resolveComponent: typeof import('vue').resolveComponent
|
||||
const setActivePinia: typeof import('pinia').setActivePinia
|
||||
const setMapStoreSuffix: typeof import('pinia').setMapStoreSuffix
|
||||
const shallowReactive: typeof import('vue').shallowReactive
|
||||
const shallowReadonly: typeof import('vue').shallowReadonly
|
||||
const shallowRef: typeof import('vue').shallowRef
|
||||
const storeToRefs: typeof import('pinia').storeToRefs
|
||||
const toRaw: typeof import('vue').toRaw
|
||||
const toRef: typeof import('vue').toRef
|
||||
const toRefs: typeof import('vue').toRefs
|
||||
const toValue: typeof import('vue').toValue
|
||||
const triggerRef: typeof import('vue').triggerRef
|
||||
const unref: typeof import('vue').unref
|
||||
const useAttrs: typeof import('vue').useAttrs
|
||||
const useCssModule: typeof import('vue').useCssModule
|
||||
const useCssVars: typeof import('vue').useCssVars
|
||||
const useId: typeof import('vue').useId
|
||||
const useLink: typeof import('vue-router').useLink
|
||||
const useModel: typeof import('vue').useModel
|
||||
const useRoute: typeof import('vue-router').useRoute
|
||||
const useRouter: typeof import('vue-router').useRouter
|
||||
const useSlots: typeof import('vue').useSlots
|
||||
const useTemplateRef: typeof import('vue').useTemplateRef
|
||||
const watch: typeof import('vue').watch
|
||||
const watchEffect: typeof import('vue').watchEffect
|
||||
const watchPostEffect: typeof import('vue').watchPostEffect
|
||||
const watchSyncEffect: typeof import('vue').watchSyncEffect
|
||||
}
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// biome-ignore lint: disable
|
||||
// oxlint-disable
|
||||
// ------
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ElAside: typeof import('element-plus/es')['ElAside']
|
||||
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']
|
||||
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElImage: typeof import('element-plus/es')['ElImage']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElMain: typeof import('element-plus/es')['ElMain']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
export interface GlobalDirectives {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Goods,
|
||||
Menu,
|
||||
Location,
|
||||
CollectionTag,
|
||||
Sort,
|
||||
Refresh,
|
||||
Expand,
|
||||
Fold,
|
||||
ArrowDown,
|
||||
User,
|
||||
SwitchButton,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
interface MenuItem {
|
||||
index: string
|
||||
title: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
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' },
|
||||
])
|
||||
|
||||
const iconMap: Record<string, unknown> = {
|
||||
Goods,
|
||||
Menu,
|
||||
Location,
|
||||
CollectionTag,
|
||||
Sort,
|
||||
Refresh,
|
||||
}
|
||||
|
||||
const activeMenu = computed(() => route.path)
|
||||
const collapsed = computed(() => appStore.sidebarCollapsed)
|
||||
const username = computed(() => authStore.user?.username || 'Admin')
|
||||
|
||||
const breadcrumb = computed(() => {
|
||||
const meta = route.meta as { title?: string } | undefined
|
||||
return meta?.title || 'Dashboard'
|
||||
})
|
||||
|
||||
function toggleSidebar() {
|
||||
appStore.toggleSidebar()
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await ElMessageBox.confirm('Are you sure to sign out?', 'Sign out', {
|
||||
confirmButtonText: 'Sign out',
|
||||
cancelButtonText: 'Cancel',
|
||||
type: 'warning',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
function handleCommand(command: string) {
|
||||
if (command === 'logout') {
|
||||
handleLogout()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="layout-root">
|
||||
<!-- 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>
|
||||
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
:collapse="collapsed"
|
||||
:collapse-transition="false"
|
||||
background-color="#1f1f24"
|
||||
text-color="#d4d4d8"
|
||||
active-text-color="#ff6800"
|
||||
router
|
||||
>
|
||||
<el-menu-item v-for="item in menuItems" :key="item.index" :index="item.index">
|
||||
<el-icon>
|
||||
<component :is="iconMap[item.icon]" />
|
||||
</el-icon>
|
||||
<template #title>{{ item.title }}</template>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<el-container>
|
||||
<!-- Header -->
|
||||
<el-header class="layout-header" height="56px">
|
||||
<div class="header-left">
|
||||
<el-button
|
||||
text
|
||||
class="collapse-btn"
|
||||
:title="collapsed ? 'Expand sidebar' : 'Collapse sidebar'"
|
||||
@click="toggleSidebar"
|
||||
>
|
||||
<el-icon :size="20">
|
||||
<component :is="collapsed ? Expand : Fold" />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
|
||||
<el-breadcrumb separator="/" class="breadcrumb">
|
||||
<el-breadcrumb-item :to="{ path: '/' }">Home</el-breadcrumb-item>
|
||||
<el-breadcrumb-item>{{ breadcrumb }}</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<el-dropdown trigger="click" @command="handleCommand">
|
||||
<span class="user-trigger">
|
||||
<el-icon><User /></el-icon>
|
||||
<span class="user-name">{{ username }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="logout">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
<span>Sign out</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- Main content -->
|
||||
<el-main class="layout-main">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade-slide" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout-root {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.layout-aside {
|
||||
background: #1f1f24;
|
||||
transition: width 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 56px;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid #2b2b33;
|
||||
background: #16161a;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brand.collapsed {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
flex: 0 0 auto;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: var(--brand-color);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.brand-tag {
|
||||
color: #9ca3af;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.layout-aside :deep(.el-menu) {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.layout-aside :deep(.el-menu-item.is-active) {
|
||||
background: rgba(255, 104, 0, 0.12) !important;
|
||||
border-right: 3px solid var(--brand-color);
|
||||
}
|
||||
|
||||
.layout-aside :deep(.el-menu-item:hover) {
|
||||
background: rgba(255, 255, 255, 0.04) !important;
|
||||
}
|
||||
|
||||
.layout-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
color: #374151;
|
||||
font-size: 14px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.user-trigger:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.layout-main {
|
||||
background: #f5f7fa;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* View transition */
|
||||
.fade-slide-enter-active,
|
||||
.fade-slide-leave-active {
|
||||
transition:
|
||||
opacity 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.fade-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
.fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/login/LoginView.vue'),
|
||||
meta: { title: 'Login', public: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/DefaultLayout.vue'),
|
||||
redirect: '/goods',
|
||||
children: [
|
||||
{
|
||||
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' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/goods',
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const authStore = useAuthStore()
|
||||
const isPublic = to.meta?.public === true
|
||||
|
||||
if (!authStore.isLoggedIn && !isPublic) {
|
||||
return { path: '/login', replace: true }
|
||||
}
|
||||
|
||||
if (authStore.isLoggedIn && to.path === '/login') {
|
||||
return { path: '/', replace: true }
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
const title = (to.meta?.title as string) || 'InkReach Admin'
|
||||
document.title = `${title} | InkReach Admin`
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,26 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const SIDEBAR_KEY = 'app:sidebarCollapsed'
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
const sidebarCollapsed = ref<boolean>(
|
||||
localStorage.getItem(SIDEBAR_KEY) === 'true'
|
||||
)
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
localStorage.setItem(SIDEBAR_KEY, String(sidebarCollapsed.value))
|
||||
}
|
||||
|
||||
function setSidebarCollapsed(value: boolean) {
|
||||
sidebarCollapsed.value = value
|
||||
localStorage.setItem(SIDEBAR_KEY, String(value))
|
||||
}
|
||||
|
||||
return {
|
||||
sidebarCollapsed,
|
||||
toggleSidebar,
|
||||
setSidebarCollapsed,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { LoginRequest, User } from '@/types'
|
||||
import { authApi } from '@/api/auth'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// Token persisted to localStorage
|
||||
const token = ref<string>(localStorage.getItem('token') || '')
|
||||
|
||||
// User persisted to localStorage (parsed if available)
|
||||
const user = ref<User | null>(loadUser())
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
|
||||
function loadUser(): User | null {
|
||||
const raw = localStorage.getItem('user')
|
||||
if (!raw) return null
|
||||
try {
|
||||
return JSON.parse(raw) as User
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function setToken(newToken: string) {
|
||||
token.value = newToken
|
||||
if (newToken) {
|
||||
localStorage.setItem('token', newToken)
|
||||
} else {
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
}
|
||||
|
||||
function setUser(newUser: User | null) {
|
||||
user.value = newUser
|
||||
if (newUser) {
|
||||
localStorage.setItem('user', JSON.stringify(newUser))
|
||||
} else {
|
||||
localStorage.removeItem('user')
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
return res;
|
||||
}
|
||||
|
||||
async function fetchCurrentUser() {
|
||||
const current = await authApi.getCurrentUser()
|
||||
setUser(current)
|
||||
return current
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await authApi.logout()
|
||||
} catch {
|
||||
// Ignore network errors during logout
|
||||
}
|
||||
setToken('')
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
isLoggedIn,
|
||||
login,
|
||||
fetchCurrentUser,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
:root {
|
||||
--brand-color: #ff6800;
|
||||
--brand-color-hover: #ff7d1f;
|
||||
--brand-color-active: #e65a00;
|
||||
|
||||
--sidebar-width: 240px;
|
||||
--sidebar-width-collapsed: 64px;
|
||||
--header-height: 56px;
|
||||
|
||||
font-family:
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
'Helvetica Neue',
|
||||
Arial,
|
||||
'PingFang SC',
|
||||
'Hiragino Sans GB',
|
||||
'Microsoft YaHei',
|
||||
sans-serif;
|
||||
color: #1f2937;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Element Plus brand color overrides */
|
||||
:root {
|
||||
--el-color-primary: var(--brand-color);
|
||||
--el-color-primary-light-3: #ff8f3d;
|
||||
--el-color-primary-light-5: #ffb073;
|
||||
--el-color-primary-light-7: #ffd1a8;
|
||||
--el-color-primary-light-8: #ffe0c2;
|
||||
--el-color-primary-light-9: #fff0e0;
|
||||
--el-color-primary-dark-2: #e65a00;
|
||||
}
|
||||
|
||||
/* Brand-colored buttons */
|
||||
.el-button--primary {
|
||||
--el-button-bg-color: var(--brand-color);
|
||||
--el-button-border-color: var(--brand-color);
|
||||
--el-button-hover-bg-color: var(--brand-color-hover);
|
||||
--el-button-hover-border-color: var(--brand-color-hover);
|
||||
--el-button-active-bg-color: var(--brand-color-active);
|
||||
--el-button-active-border-color: var(--brand-color-active);
|
||||
}
|
||||
|
||||
/* Page wrapper helper */
|
||||
.page-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-card {
|
||||
background: #ffffff;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* Compact filter bar */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filter-bar .el-input,
|
||||
.filter-bar .el-select,
|
||||
.filter-bar .el-cascader {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
/* Action cell padding */
|
||||
.table-actions .el-button + .el-button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* Brand-colored dialog titles */
|
||||
.brand-title {
|
||||
color: var(--brand-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// Common types
|
||||
export interface PaginatedResult<T> {
|
||||
data: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
// Auth types
|
||||
export interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
user: User
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
username: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
// Good types
|
||||
export interface Good {
|
||||
id: string
|
||||
name: string
|
||||
originGoodId: string
|
||||
originGood?: OriginGood
|
||||
countryId: string
|
||||
country?: Country
|
||||
categoryId: string
|
||||
category?: Category
|
||||
tagId: string
|
||||
tag?: 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
|
||||
}
|
||||
|
||||
export interface UpdateGoodRequest {
|
||||
name?: string
|
||||
originGoodId?: string
|
||||
countryId?: string
|
||||
categoryId?: string
|
||||
tagId?: string
|
||||
positionId?: string
|
||||
priority?: number
|
||||
}
|
||||
|
||||
export interface BatchCreateGoodsRequest {
|
||||
originGoodIds: string[]
|
||||
countryId: string
|
||||
categoryId: string
|
||||
tagId: string
|
||||
positionId?: string
|
||||
priority: number
|
||||
}
|
||||
|
||||
export interface UpdatePriorityRequest {
|
||||
goodIds: string[]
|
||||
priority: number
|
||||
}
|
||||
|
||||
// Country types
|
||||
export interface Country {
|
||||
id: string
|
||||
name: string
|
||||
icon?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateCountryRequest {
|
||||
name: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
export interface UpdateCountryRequest {
|
||||
name?: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
// Category types
|
||||
export interface Category {
|
||||
id: string
|
||||
name: string
|
||||
icon?: string
|
||||
parentId?: string
|
||||
parent?: Category
|
||||
children?: Category[]
|
||||
_count?: {
|
||||
children: number
|
||||
}
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateCategoryRequest {
|
||||
name: string
|
||||
icon?: string
|
||||
parentId?: string
|
||||
}
|
||||
|
||||
export interface UpdateCategoryRequest {
|
||||
name?: string
|
||||
icon?: string
|
||||
parentId?: string
|
||||
}
|
||||
|
||||
export interface CategoryTree extends Category {
|
||||
children?: CategoryTree[]
|
||||
}
|
||||
|
||||
// Tag types
|
||||
export interface Tag {
|
||||
id: string
|
||||
name: string
|
||||
color?: string
|
||||
timing?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateTagRequest {
|
||||
name: string
|
||||
color?: string
|
||||
timing?: string
|
||||
}
|
||||
|
||||
export interface UpdateTagRequest {
|
||||
name?: string
|
||||
color?: string
|
||||
timing?: string
|
||||
}
|
||||
|
||||
// Position types
|
||||
export interface Position {
|
||||
id: string
|
||||
indexVal: number
|
||||
countryId?: string
|
||||
country?: Country
|
||||
categoryId?: string
|
||||
category?: Category
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreatePositionRequest {
|
||||
indexVal: number
|
||||
countryId?: string
|
||||
categoryId?: string
|
||||
}
|
||||
|
||||
export interface UpdatePositionRequest {
|
||||
indexVal?: number
|
||||
countryId?: string
|
||||
categoryId?: string
|
||||
}
|
||||
|
||||
// Origin Good types
|
||||
export interface OriginGood {
|
||||
id: string
|
||||
name: string
|
||||
productId: string
|
||||
categoryId?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// Sync types
|
||||
export interface SyncLog {
|
||||
id: string
|
||||
type: 'CATEGORY' | 'PRODUCT'
|
||||
status: 'SUCCESS' | 'FAILED'
|
||||
message?: string
|
||||
startTime: string
|
||||
endTime?: string
|
||||
errorCount?: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface SyncStats {
|
||||
lastSyncTime?: string
|
||||
status?: 'IDLE' | 'SYNCING'
|
||||
errorCount?: number
|
||||
}
|
||||
|
||||
// Filter types
|
||||
export interface GoodsFilter {
|
||||
countryId?: string
|
||||
categoryId?: string
|
||||
tagId?: string
|
||||
keyword?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface CategoryFilter {
|
||||
parentId?: string
|
||||
name?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface CountryFilter {
|
||||
name?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface TagFilter {
|
||||
name?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface PositionFilter {
|
||||
countryId?: string
|
||||
categoryId?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Refresh } from '@element-plus/icons-vue'
|
||||
import type {
|
||||
Category,
|
||||
CategoryTree,
|
||||
CreateCategoryRequest,
|
||||
UpdateCategoryRequest,
|
||||
} from '@/types'
|
||||
import { categoriesApi } from '@/api/categories'
|
||||
|
||||
const loading = ref(false)
|
||||
const tree = ref<CategoryTree[]>([])
|
||||
|
||||
async function fetchTree() {
|
||||
loading.value = true
|
||||
try {
|
||||
tree.value = await categoriesApi.getCategoryTree()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Cascader ----------
|
||||
interface CascaderNode {
|
||||
value: string
|
||||
label: string
|
||||
children?: CascaderNode[]
|
||||
}
|
||||
|
||||
const cascaderOptions = ref<CascaderNode[]>([])
|
||||
|
||||
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
|
||||
return nodes.map((n) => ({
|
||||
value: n.id,
|
||||
label: n.name,
|
||||
children: n.children?.length ? buildCascader(n.children) : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
function rebuildCascader() {
|
||||
cascaderOptions.value = buildCascader(tree.value)
|
||||
}
|
||||
|
||||
// ---------- Dialog ----------
|
||||
const dialogRef = ref()
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreateCategoryRequest & { id?: string }>({
|
||||
name: '',
|
||||
icon: '',
|
||||
parentId: '',
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
name: [{ required: true, message: 'Name is required', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
async function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, name: '', icon: '', parentId: '' })
|
||||
rebuildCascader()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function openEditDialog(c: Category) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
icon: c.icon || '',
|
||||
parentId: c.parentId || '',
|
||||
})
|
||||
rebuildCascader()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!dialogRef.value) return
|
||||
await dialogRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreateCategoryRequest = {
|
||||
name: dialogForm.name,
|
||||
icon: dialogForm.icon || undefined,
|
||||
parentId: dialogForm.parentId || undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await categoriesApi.createCategory(payload)
|
||||
ElMessage.success('Category created')
|
||||
} else {
|
||||
await categoriesApi.updateCategory(dialogForm.id!, payload as UpdateCategoryRequest)
|
||||
ElMessage.success('Category updated')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchTree()
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(c: Category) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`Delete "${c.name}"?`, 'Confirm', {
|
||||
type: 'warning',
|
||||
confirmButtonText: 'Delete',
|
||||
cancelButtonText: 'Cancel',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await categoriesApi.deleteCategory(c.id)
|
||||
ElMessage.success('Deleted')
|
||||
fetchTree()
|
||||
}
|
||||
|
||||
onMounted(fetchTree)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-card">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>Add Category</span>
|
||||
</el-button>
|
||||
<el-button @click="fetchTree">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>Refresh</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="tree"
|
||||
border
|
||||
row-key="id"
|
||||
:tree-props="{ children: 'children' }"
|
||||
default-expand-all
|
||||
>
|
||||
<el-table-column prop="name" label="Name" min-width="220" />
|
||||
<el-table-column label="Icon" width="100">
|
||||
<template #default="{ row }: { row: CategoryTree }">
|
||||
<el-image
|
||||
v-if="row.icon"
|
||||
:src="row.icon"
|
||||
:preview-src-list="[row.icon]"
|
||||
fit="cover"
|
||||
style="width: 32px; height: 32px; border-radius: 4px;"
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Parent">
|
||||
<template #default="{ row }: { row: CategoryTree }">
|
||||
{{ row.parent?.name || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Children" 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">
|
||||
<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>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>Delete</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="No categories" />
|
||||
</template>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? 'Add Category' : 'Edit Category'"
|
||||
width="520px"
|
||||
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="Category name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Parent">
|
||||
<el-cascader
|
||||
v-model="dialogForm.parentId"
|
||||
:options="cascaderOptions"
|
||||
:props="{
|
||||
checkStrictly: true,
|
||||
value: 'value',
|
||||
label: 'label',
|
||||
children: 'children',
|
||||
emitPath: false,
|
||||
}"
|
||||
placeholder="Top-level (optional)"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="Icon URL">
|
||||
<el-input v-model="dialogForm.icon" placeholder="https://..." />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">Cancel</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? 'Create' : 'Save' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,229 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import type {
|
||||
Country,
|
||||
CreateCountryRequest,
|
||||
UpdateCountryRequest,
|
||||
CountryFilter,
|
||||
} from '@/types'
|
||||
import { countriesApi } from '@/api/countries'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<Country[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const filter = reactive<Required<CountryFilter>>({
|
||||
name: '',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await countriesApi.getCountriesList(filter)
|
||||
list.value = res.data
|
||||
total.value = res.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filter.name = ''
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const dialogRef = ref()
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreateCountryRequest & { id?: string }>({
|
||||
name: '',
|
||||
icon: '',
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
name: [{ required: true, message: 'Name is required', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, name: '', icon: '' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(c: Country) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, { id: c.id, name: c.name, icon: c.icon || '' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!dialogRef.value) return
|
||||
await dialogRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreateCountryRequest = {
|
||||
name: dialogForm.name,
|
||||
icon: dialogForm.icon || undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await countriesApi.createCountry(payload)
|
||||
ElMessage.success('Country created')
|
||||
} else {
|
||||
await countriesApi.updateCountry(dialogForm.id!, payload as UpdateCountryRequest)
|
||||
ElMessage.success('Country updated')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(c: Country) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`Delete "${c.name}"?`, 'Confirm', {
|
||||
type: 'warning',
|
||||
confirmButtonText: 'Delete',
|
||||
cancelButtonText: 'Cancel',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await countriesApi.deleteCountry(c.id)
|
||||
ElMessage.success('Deleted')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-card">
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="filter.name"
|
||||
placeholder="Search by name"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>Search</span>
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>Reset</span>
|
||||
</el-button>
|
||||
|
||||
<div class="filter-spacer" />
|
||||
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>Add Country</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border stripe>
|
||||
<el-table-column label="Icon" width="80">
|
||||
<template #default="{ row }: { row: Country }">
|
||||
<el-image
|
||||
v-if="row.icon"
|
||||
:src="row.icon"
|
||||
:preview-src-list="[row.icon]"
|
||||
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">
|
||||
<template #default="{ row }: { row: Country }">
|
||||
{{ new Date(row.createdAt).toLocaleString() }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Actions" 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>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>Delete</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="No countries" />
|
||||
</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; fetchList() }"
|
||||
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? 'Add Country' : 'Edit Country'"
|
||||
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>
|
||||
<el-form-item label="Icon URL">
|
||||
<el-input v-model="dialogForm.icon" placeholder="https://..." />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">Cancel</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? 'Create' : 'Save' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,667 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Search, Refresh, Plus, Delete, Edit, Upload } from '@element-plus/icons-vue'
|
||||
import type {
|
||||
Good,
|
||||
CreateGoodRequest,
|
||||
UpdateGoodRequest,
|
||||
BatchCreateGoodsRequest,
|
||||
GoodsFilter,
|
||||
Country,
|
||||
Category,
|
||||
CategoryTree,
|
||||
Tag,
|
||||
Position,
|
||||
OriginGood,
|
||||
} from '@/types'
|
||||
import { goodsApi } from '@/api/goods'
|
||||
import { countriesApi } from '@/api/countries'
|
||||
import { categoriesApi } from '@/api/categories'
|
||||
import { tagsApi } from '@/api/tags'
|
||||
import { positionsApi } from '@/api/positions'
|
||||
import { originGoodsApi } from '@/api/origin-goods'
|
||||
|
||||
// ---------- List state ----------
|
||||
const loading = ref(false)
|
||||
const goods = ref<Good[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const filter = reactive<Required<GoodsFilter>>({
|
||||
countryId: '',
|
||||
categoryId: '',
|
||||
tagId: '',
|
||||
keyword: '',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
// ---------- Lookups ----------
|
||||
const countries = ref<Country[]>([])
|
||||
const categoriesTree = ref<CategoryTree[]>([])
|
||||
const tags = ref<Tag[]>([])
|
||||
const positions = ref<Position[]>([])
|
||||
|
||||
async function loadLookups() {
|
||||
const [c, ct, t] = await Promise.all([
|
||||
countriesApi.getCountriesList({ page: 1, pageSize: 500 }),
|
||||
categoriesApi.getCategoryTree(),
|
||||
tagsApi.getTagsList({ page: 1, pageSize: 500 }),
|
||||
])
|
||||
countries.value = c.data
|
||||
categoriesTree.value = ct
|
||||
tags.value = t.data
|
||||
}
|
||||
|
||||
async function loadPositions(good?: Good) {
|
||||
// Positions filter requires country & category
|
||||
const params = {
|
||||
countryId: good?.countryId || filter.countryId || undefined,
|
||||
categoryId: good?.categoryId || filter.categoryId || undefined,
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
}
|
||||
const res = await positionsApi.getPositionsList(params)
|
||||
positions.value = res.data
|
||||
}
|
||||
|
||||
// ---------- Cascader adapter ----------
|
||||
interface CascaderNode {
|
||||
value: string
|
||||
label: string
|
||||
children?: CascaderNode[]
|
||||
}
|
||||
|
||||
function buildCategoryCascader(tree: CategoryTree[]): CascaderNode[] {
|
||||
return tree.map((node) => ({
|
||||
value: node.id,
|
||||
label: node.name,
|
||||
children: node.children?.length ? buildCategoryCascader(node.children) : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
const categoryCascaderOptions = ref<CascaderNode[]>([])
|
||||
|
||||
watch(categoriesTree, (val) => {
|
||||
categoryCascaderOptions.value = buildCategoryCascader(val)
|
||||
}, { immediate: true })
|
||||
|
||||
// ---------- Origin good search (for add/edit/batch dialogs) ----------
|
||||
const originGoodsList = ref<OriginGood[]>([])
|
||||
const originGoodKeyword = ref('')
|
||||
const originGoodSearching = ref(false)
|
||||
|
||||
async function searchOriginGoods(keyword: string) {
|
||||
originGoodKeyword.value = keyword
|
||||
originGoodSearching.value = true
|
||||
try {
|
||||
const res = await originGoodsApi.searchOriginGoods(keyword)
|
||||
originGoodsList.value = res
|
||||
} finally {
|
||||
originGoodSearching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- CRUD ----------
|
||||
async function fetchGoods() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await goodsApi.getGoodsList(filter)
|
||||
goods.value = res.data
|
||||
total.value = res.total
|
||||
} catch (err) {
|
||||
console.error('Failed to load goods', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
filter.page = 1
|
||||
fetchGoods()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filter.countryId = ''
|
||||
filter.categoryId = ''
|
||||
filter.tagId = ''
|
||||
filter.keyword = ''
|
||||
filter.page = 1
|
||||
fetchGoods()
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
filter.page = page
|
||||
fetchGoods()
|
||||
}
|
||||
|
||||
function handleSizeChange(size: number) {
|
||||
filter.pageSize = size
|
||||
filter.page = 1
|
||||
fetchGoods()
|
||||
}
|
||||
|
||||
function getCategoryName(g: Good): string {
|
||||
if (!g.category) return g.categoryId
|
||||
return g.category.parent ? `${g.category.parent.name} / ${g.category.name}` : g.category.name
|
||||
}
|
||||
|
||||
// ---------- Add / Edit dialog ----------
|
||||
const dialogRef = ref()
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogVisible = ref(false)
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreateGoodRequest & { id?: string }>({
|
||||
name: '',
|
||||
originGoodId: '',
|
||||
countryId: '',
|
||||
categoryId: '',
|
||||
tagId: '',
|
||||
positionId: '',
|
||||
priority: 0,
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
name: [{ required: true, message: 'Name is required', trigger: 'blur' }],
|
||||
originGoodId: [{ required: true, message: 'Origin good is required', trigger: 'change' }],
|
||||
countryId: [{ required: true, message: 'Country is required', trigger: 'change' }],
|
||||
categoryId: [{ required: true, message: 'Category is required', trigger: 'change' }],
|
||||
tagId: [{ required: true, message: 'Tag is required', trigger: 'change' }],
|
||||
priority: [{ required: true, message: 'Priority is required', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
async function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, {
|
||||
id: undefined,
|
||||
name: '',
|
||||
originGoodId: '',
|
||||
countryId: '',
|
||||
categoryId: '',
|
||||
tagId: '',
|
||||
positionId: '',
|
||||
priority: 0,
|
||||
})
|
||||
await loadPositions()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function openEditDialog(g: Good) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, {
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
originGoodId: g.originGoodId,
|
||||
countryId: g.countryId,
|
||||
categoryId: g.categoryId,
|
||||
tagId: g.tagId,
|
||||
positionId: g.positionId || '',
|
||||
priority: g.priority,
|
||||
})
|
||||
await loadPositions(g)
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleDialogSubmit() {
|
||||
if (!dialogRef.value) return
|
||||
await dialogRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreateGoodRequest = {
|
||||
name: dialogForm.name,
|
||||
originGoodId: dialogForm.originGoodId,
|
||||
countryId: dialogForm.countryId,
|
||||
categoryId: dialogForm.categoryId,
|
||||
tagId: dialogForm.tagId,
|
||||
positionId: dialogForm.positionId || undefined,
|
||||
priority: Number(dialogForm.priority),
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await goodsApi.createGood(payload)
|
||||
ElMessage.success('Good created')
|
||||
} else {
|
||||
await goodsApi.updateGood(dialogForm.id!, payload as UpdateGoodRequest)
|
||||
ElMessage.success('Good updated')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchGoods()
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(g: Good) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`Delete "${g.name}"?`, 'Confirm', {
|
||||
type: 'warning',
|
||||
confirmButtonText: 'Delete',
|
||||
cancelButtonText: 'Cancel',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await goodsApi.deleteGood(g.id)
|
||||
ElMessage.success('Deleted')
|
||||
fetchGoods()
|
||||
}
|
||||
|
||||
// ---------- Batch add dialog ----------
|
||||
const batchDialogRef = ref()
|
||||
const batchDialogVisible = ref(false)
|
||||
const batchDialogLoading = ref(false)
|
||||
const batchOriginKeyword = ref('')
|
||||
const batchOriginSearching = ref(false)
|
||||
const batchOriginList = ref<OriginGood[]>([])
|
||||
|
||||
const batchForm = reactive<BatchCreateGoodsRequest>({
|
||||
originGoodIds: [],
|
||||
countryId: '',
|
||||
categoryId: '',
|
||||
tagId: '',
|
||||
positionId: '',
|
||||
priority: 0,
|
||||
})
|
||||
|
||||
const batchRules = {
|
||||
originGoodIds: [{ type: 'array', required: true, message: 'Pick at least one origin good', trigger: 'change' }],
|
||||
countryId: [{ required: true, message: 'Country is required', trigger: 'change' }],
|
||||
categoryId: [{ required: true, message: 'Category is required', trigger: 'change' }],
|
||||
tagId: [{ required: true, message: 'Tag is required', trigger: 'change' }],
|
||||
}
|
||||
|
||||
async function openBatchDialog() {
|
||||
Object.assign(batchForm, {
|
||||
originGoodIds: [],
|
||||
countryId: '',
|
||||
categoryId: '',
|
||||
tagId: '',
|
||||
positionId: '',
|
||||
priority: 0,
|
||||
})
|
||||
batchOriginKeyword.value = ''
|
||||
batchOriginList.value = []
|
||||
await loadPositions()
|
||||
batchDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function batchSearchOrigin() {
|
||||
if (!batchOriginKeyword.value) return
|
||||
batchOriginSearching.value = true
|
||||
try {
|
||||
const res = await originGoodsApi.searchOriginGoods(batchOriginKeyword.value)
|
||||
batchOriginList.value = res
|
||||
} finally {
|
||||
batchOriginSearching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchSubmit() {
|
||||
if (!batchDialogRef.value) return
|
||||
await batchDialogRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
batchDialogLoading.value = true
|
||||
try {
|
||||
const payload: BatchCreateGoodsRequest = {
|
||||
originGoodIds: batchForm.originGoodIds,
|
||||
countryId: batchForm.countryId,
|
||||
categoryId: batchForm.categoryId,
|
||||
tagId: batchForm.tagId,
|
||||
positionId: batchForm.positionId || undefined,
|
||||
priority: Number(batchForm.priority),
|
||||
}
|
||||
const created = await goodsApi.batchCreateGoods(payload)
|
||||
ElMessage.success(`Created ${Array.isArray(created) ? created.length : payload.originGoodIds.length} goods`)
|
||||
batchDialogVisible.value = false
|
||||
fetchGoods()
|
||||
} finally {
|
||||
batchDialogLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- Lifecycle ----------
|
||||
onMounted(async () => {
|
||||
await loadLookups()
|
||||
await fetchGoods()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-card">
|
||||
<!-- Filter bar -->
|
||||
<div class="filter-bar">
|
||||
<el-select
|
||||
v-model="filter.countryId"
|
||||
placeholder="Country"
|
||||
clearable
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="c in countries"
|
||||
:key="c.id"
|
||||
:label="c.name"
|
||||
:value="c.id"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-cascader
|
||||
v-model="filter.categoryId"
|
||||
:options="categoryCascaderOptions"
|
||||
:props="{ checkStrictly: true, value: 'value', label: 'label', children: 'children' }"
|
||||
placeholder="Category"
|
||||
clearable
|
||||
@change="handleSearch"
|
||||
/>
|
||||
|
||||
<el-select
|
||||
v-model="filter.tagId"
|
||||
placeholder="Tag"
|
||||
clearable
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="t in tags"
|
||||
:key="t.id"
|
||||
:label="t.name"
|
||||
:value="t.id"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-input
|
||||
v-model="filter.keyword"
|
||||
placeholder="Search by name"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>Search</span>
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>Reset</span>
|
||||
</el-button>
|
||||
|
||||
<div class="filter-spacer" />
|
||||
|
||||
<el-button type="primary" plain @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>Add Good</span>
|
||||
</el-button>
|
||||
<el-button type="success" plain @click="openBatchDialog">
|
||||
<el-icon><Upload /></el-icon>
|
||||
<span>Batch Add</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<el-table v-loading="loading" :data="goods" border stripe>
|
||||
<el-table-column prop="name" label="Name" min-width="180" />
|
||||
<el-table-column label="Country" min-width="120">
|
||||
<template #default="{ row }: { row: Good }">
|
||||
{{ row.country?.name || row.countryId }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Category" min-width="160">
|
||||
<template #default="{ row }: { row: Good }">
|
||||
{{ getCategoryName(row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Tag" min-width="100">
|
||||
<template #default="{ row }: { row: Good }">
|
||||
<el-tag v-if="row.tag" :color="row.tag.color" effect="dark">
|
||||
{{ row.tag.name }}
|
||||
</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Priority" prop="priority" width="100" sortable />
|
||||
<el-table-column label="Actions" width="180" fixed="right">
|
||||
<template #default="{ row }: { row: Good }">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>Edit</span>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>Delete</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="No goods found" />
|
||||
</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="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Add / Edit dialog -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? 'Add Good' : 'Edit Good'"
|
||||
width="640px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form
|
||||
ref="dialogRef"
|
||||
:model="dialogForm"
|
||||
:rules="dialogRules"
|
||||
label-width="120px"
|
||||
label-position="right"
|
||||
>
|
||||
<el-form-item label="Name" prop="name">
|
||||
<el-input v-model="dialogForm.name" placeholder="Good name" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Origin Good" prop="originGoodId">
|
||||
<el-select
|
||||
v-model="dialogForm.originGoodId"
|
||||
filterable
|
||||
remote
|
||||
:remote-method="searchOriginGoods"
|
||||
:loading="originGoodSearching"
|
||||
placeholder="Search origin good by name"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="og in originGoodsList"
|
||||
:key="og.id"
|
||||
:label="`${og.name} (${og.productId})`"
|
||||
:value="og.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Country" prop="countryId">
|
||||
<el-select v-model="dialogForm.countryId" placeholder="Country" style="width: 100%">
|
||||
<el-option
|
||||
v-for="c in countries"
|
||||
:key="c.id"
|
||||
:label="c.name"
|
||||
:value="c.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Category" prop="categoryId">
|
||||
<el-cascader
|
||||
v-model="dialogForm.categoryId"
|
||||
:options="categoryCascaderOptions"
|
||||
:props="{ checkStrictly: true, value: 'value', label: 'label', children: 'children' }"
|
||||
placeholder="Category"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Tag" prop="tagId">
|
||||
<el-select v-model="dialogForm.tagId" placeholder="Tag" style="width: 100%">
|
||||
<el-option
|
||||
v-for="t in tags"
|
||||
:key="t.id"
|
||||
:label="t.name"
|
||||
:value="t.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Position">
|
||||
<el-select v-model="dialogForm.positionId" placeholder="Optional" clearable style="width: 100%">
|
||||
<el-option
|
||||
v-for="p in positions"
|
||||
:key="p.id"
|
||||
:label="`#${p.indexVal}`"
|
||||
:value="p.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Priority" prop="priority">
|
||||
<el-input-number v-model="dialogForm.priority" :min="0" :max="9999" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">Cancel</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleDialogSubmit">
|
||||
{{ dialogMode === 'create' ? 'Create' : 'Save' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Batch add dialog -->
|
||||
<el-dialog
|
||||
v-model="batchDialogVisible"
|
||||
title="Batch Add Goods"
|
||||
width="720px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form
|
||||
ref="batchDialogRef"
|
||||
:model="batchForm"
|
||||
:rules="batchRules"
|
||||
label-width="120px"
|
||||
label-position="right"
|
||||
>
|
||||
<el-form-item label="Origin Goods" prop="originGoodIds">
|
||||
<div class="batch-search">
|
||||
<el-input
|
||||
v-model="batchOriginKeyword"
|
||||
placeholder="Search origin good by name"
|
||||
clearable
|
||||
@keyup.enter="batchSearchOrigin"
|
||||
>
|
||||
<template #append>
|
||||
<el-button @click="batchSearchOrigin" :loading="batchOriginSearching">
|
||||
<el-icon><Search /></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
<el-select
|
||||
v-model="batchForm.originGoodIds"
|
||||
multiple
|
||||
filterable
|
||||
placeholder="Select origin goods"
|
||||
style="width: 100%; margin-top: 8px;"
|
||||
>
|
||||
<el-option
|
||||
v-for="og in batchOriginList"
|
||||
:key="og.id"
|
||||
:label="`${og.name} (${og.productId})`"
|
||||
:value="og.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Country" prop="countryId">
|
||||
<el-select v-model="batchForm.countryId" placeholder="Country" style="width: 100%">
|
||||
<el-option
|
||||
v-for="c in countries"
|
||||
:key="c.id"
|
||||
:label="c.name"
|
||||
:value="c.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Category" prop="categoryId">
|
||||
<el-cascader
|
||||
v-model="batchForm.categoryId"
|
||||
:options="categoryCascaderOptions"
|
||||
:props="{ checkStrictly: true, value: 'value', label: 'label', children: 'children' }"
|
||||
placeholder="Category"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Tag" prop="tagId">
|
||||
<el-select v-model="batchForm.tagId" placeholder="Tag" style="width: 100%">
|
||||
<el-option
|
||||
v-for="t in tags"
|
||||
:key="t.id"
|
||||
:label="t.name"
|
||||
:value="t.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Position">
|
||||
<el-select v-model="batchForm.positionId" placeholder="Optional" clearable style="width: 100%">
|
||||
<el-option
|
||||
v-for="p in positions"
|
||||
:key="p.id"
|
||||
:label="`#${p.indexVal}`"
|
||||
:value="p.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Priority">
|
||||
<el-input-number v-model="batchForm.priority" :min="0" :max="9999" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="batchDialogVisible = false">Cancel</el-button>
|
||||
<el-button type="primary" :loading="batchDialogLoading" @click="handleBatchSubmit">
|
||||
Create {{ batchForm.originGoodIds.length }} Goods
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.batch-search {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [
|
||||
{ required: true, message: 'Please enter your username', trigger: 'blur' },
|
||||
{ min: 2, max: 64, message: 'Length 2-64', trigger: 'blur' },
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: 'Please enter your password', trigger: 'blur' },
|
||||
{ min: 4, max: 64, message: 'Length 4-64', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
loading.value = true
|
||||
try {
|
||||
await authStore.login({ username: form.username, password: form.password })
|
||||
ElMessage.success('Login successful')
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
// Error toast is shown by axios response interceptor
|
||||
console.error('Login failed', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<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>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
size="large"
|
||||
label-position="top"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<el-form-item label="Username" prop="username">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="Enter your username"
|
||||
clearable
|
||||
autocomplete="username"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><User /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Password" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
show-password
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="handleSubmit"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Lock /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
class="login-button"
|
||||
:loading="loading"
|
||||
native-type="submit"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
Sign in
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="login-footer">
|
||||
<span>© {{ new Date().getFullYear() }} InkReach</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 20%, rgba(255, 104, 0, 0.18), transparent 60%),
|
||||
radial-gradient(circle at 80% 80%, rgba(255, 141, 31, 0.15), transparent 60%),
|
||||
linear-gradient(135deg, #1f1f24 0%, #2b2b33 100%);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 420px;
|
||||
max-width: calc(100vw - 32px);
|
||||
padding: 40px 36px 28px;
|
||||
background: rgba(255, 255, 255, 0.97);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--brand-color);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,315 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import type {
|
||||
Position,
|
||||
CreatePositionRequest,
|
||||
UpdatePositionRequest,
|
||||
PositionFilter,
|
||||
Country,
|
||||
Category,
|
||||
CategoryTree,
|
||||
} from '@/types'
|
||||
import { positionsApi } from '@/api/positions'
|
||||
import { countriesApi } from '@/api/countries'
|
||||
import { categoriesApi } from '@/api/categories'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<Position[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const countries = ref<Country[]>([])
|
||||
const categoriesTree = ref<CategoryTree[]>([])
|
||||
|
||||
const filter = reactive<Required<PositionFilter>>({
|
||||
countryId: '',
|
||||
categoryId: '',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
async function loadLookups() {
|
||||
const [c, ct] = await Promise.all([
|
||||
countriesApi.getCountriesList({ page: 1, pageSize: 500 }),
|
||||
categoriesApi.getCategoryTree(),
|
||||
])
|
||||
countries.value = c.data
|
||||
categoriesTree.value = ct
|
||||
}
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await positionsApi.getPositionsList(filter)
|
||||
list.value = res.data
|
||||
total.value = res.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filter.countryId = ''
|
||||
filter.categoryId = ''
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// ---------- Cascader ----------
|
||||
interface CascaderNode {
|
||||
value: string
|
||||
label: string
|
||||
children?: CascaderNode[]
|
||||
}
|
||||
|
||||
const cascaderOptions = ref<CascaderNode[]>([])
|
||||
|
||||
function buildCascader(nodes: CategoryTree[]): CascaderNode[] {
|
||||
return nodes.map((n) => ({
|
||||
value: n.id,
|
||||
label: n.name,
|
||||
children: n.children?.length ? buildCascader(n.children) : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------- Dialog ----------
|
||||
const dialogRef = ref()
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreatePositionRequest & { id?: string }>({
|
||||
indexVal: 0,
|
||||
countryId: '',
|
||||
categoryId: '',
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
indexVal: [{ required: true, message: 'Index is required', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function rebuildCascader() {
|
||||
cascaderOptions.value = buildCascader(categoriesTree.value)
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, indexVal: 0, countryId: '', categoryId: '' })
|
||||
rebuildCascader()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(p: Position) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, {
|
||||
id: p.id,
|
||||
indexVal: p.indexVal,
|
||||
countryId: p.countryId || '',
|
||||
categoryId: p.categoryId || '',
|
||||
})
|
||||
rebuildCascader()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!dialogRef.value) return
|
||||
await dialogRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreatePositionRequest = {
|
||||
indexVal: Number(dialogForm.indexVal),
|
||||
countryId: dialogForm.countryId || undefined,
|
||||
categoryId: dialogForm.categoryId || undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await positionsApi.createPosition(payload)
|
||||
ElMessage.success('Position created')
|
||||
} else {
|
||||
await positionsApi.updatePosition(dialogForm.id!, payload as UpdatePositionRequest)
|
||||
ElMessage.success('Position updated')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(p: Position) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`Delete position #${p.indexVal}?`, 'Confirm', {
|
||||
type: 'warning',
|
||||
confirmButtonText: 'Delete',
|
||||
cancelButtonText: 'Cancel',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await positionsApi.deletePosition(p.id)
|
||||
ElMessage.success('Deleted')
|
||||
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
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadLookups()
|
||||
await fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-card">
|
||||
<div class="filter-bar">
|
||||
<el-select
|
||||
v-model="filter.countryId"
|
||||
placeholder="Country"
|
||||
clearable
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="c in countries"
|
||||
:key="c.id"
|
||||
:label="c.name"
|
||||
:value="c.id"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-cascader
|
||||
v-model="filter.categoryId"
|
||||
:options="cascaderOptions"
|
||||
:props="{ checkStrictly: true, value: 'value', label: 'label', children: 'children', emitPath: false }"
|
||||
placeholder="Category"
|
||||
clearable
|
||||
@change="handleSearch"
|
||||
/>
|
||||
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>Search</span>
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>Reset</span>
|
||||
</el-button>
|
||||
|
||||
<div class="filter-spacer" />
|
||||
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>Add Position</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">
|
||||
<template #default="{ row }: { row: Position }">
|
||||
{{ row.country?.name || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Category" min-width="200">
|
||||
<template #default="{ row }: { row: Position }">
|
||||
{{ getCategoryName(row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Actions" 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>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>Delete</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="No positions" />
|
||||
</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; fetchList() }"
|
||||
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? 'Add Position' : 'Edit Position'"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="dialogRef" :model="dialogForm" :rules="dialogRules" label-width="100px">
|
||||
<el-form-item label="Index" 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-option
|
||||
v-for="c in countries"
|
||||
:key="c.id"
|
||||
:label="c.name"
|
||||
:value="c.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Category">
|
||||
<el-cascader
|
||||
v-model="dialogForm.categoryId"
|
||||
:options="cascaderOptions"
|
||||
:props="{
|
||||
checkStrictly: true,
|
||||
value: 'value',
|
||||
label: 'label',
|
||||
children: 'children',
|
||||
emitPath: false,
|
||||
}"
|
||||
placeholder="Optional"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">Cancel</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? 'Create' : 'Save' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,330 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, reactive, 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 { 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: '',
|
||||
})
|
||||
|
||||
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
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch sync logs', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
await Promise.all([refreshStats(), refreshLogs()])
|
||||
}
|
||||
|
||||
async function handleSyncCategories() {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'Trigger category sync now? This may take a while.',
|
||||
'Confirm',
|
||||
{
|
||||
type: 'info',
|
||||
confirmButtonText: 'Run',
|
||||
cancelButtonText: 'Cancel',
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
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() {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'Trigger product sync now? This may take a while.',
|
||||
'Confirm',
|
||||
{
|
||||
type: 'info',
|
||||
confirmButtonText: 'Run',
|
||||
cancelButtonText: 'Cancel',
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
syncingProducts.value = true
|
||||
try {
|
||||
const log = await syncApi.syncProducts()
|
||||
ElMessage.success(`Product sync started (${log.id})`)
|
||||
await refreshAll()
|
||||
} finally {
|
||||
syncingProducts.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(s?: string): string {
|
||||
if (!s) return '-'
|
||||
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)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<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>
|
||||
</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-icon><Refresh /></el-icon>
|
||||
<span>Run Product Sync</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>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="logs" border stripe>
|
||||
<el-table-column label="Type" width="120">
|
||||
<template #default="{ row }: { row: SyncLog }">
|
||||
<el-tag :type="row.type === 'CATEGORY' ? 'warning' : 'primary'">
|
||||
{{ typeLabel(row.type) }}
|
||||
</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">
|
||||
<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" />
|
||||
<template #empty>
|
||||
<el-empty description="No sync logs" />
|
||||
</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));
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sync-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sync-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.sync-card-title :deep(.el-icon) {
|
||||
color: var(--brand-color);
|
||||
}
|
||||
|
||||
.sync-card-desc {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
margin: 0 0 16px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.logs-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.logs-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.logs-filter {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,267 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import type {
|
||||
Tag,
|
||||
CreateTagRequest,
|
||||
UpdateTagRequest,
|
||||
TagFilter,
|
||||
} from '@/types'
|
||||
import { tagsApi } from '@/api/tags'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<Tag[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const filter = reactive<Required<TagFilter>>({
|
||||
name: '',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await tagsApi.getTagsList(filter)
|
||||
list.value = res.data
|
||||
total.value = res.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filter.name = ''
|
||||
filter.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const dialogRef = ref()
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogLoading = ref(false)
|
||||
|
||||
const dialogForm = reactive<CreateTagRequest & { id?: string }>({
|
||||
name: '',
|
||||
color: '#ff6800',
|
||||
timing: '',
|
||||
})
|
||||
|
||||
const dialogRules = {
|
||||
name: [{ required: true, message: 'Name is required', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
dialogMode.value = 'create'
|
||||
Object.assign(dialogForm, { id: undefined, name: '', color: '#ff6800', timing: '' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(t: Tag) {
|
||||
dialogMode.value = 'edit'
|
||||
Object.assign(dialogForm, {
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
color: t.color || '#ff6800',
|
||||
timing: t.timing || '',
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!dialogRef.value) return
|
||||
await dialogRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
dialogLoading.value = true
|
||||
try {
|
||||
const payload: CreateTagRequest = {
|
||||
name: dialogForm.name,
|
||||
color: dialogForm.color || undefined,
|
||||
timing: dialogForm.timing || undefined,
|
||||
}
|
||||
if (dialogMode.value === 'create') {
|
||||
await tagsApi.createTag(payload)
|
||||
ElMessage.success('Tag created')
|
||||
} else {
|
||||
await tagsApi.updateTag(dialogForm.id!, payload as UpdateTagRequest)
|
||||
ElMessage.success('Tag updated')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} finally {
|
||||
dialogLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(t: Tag) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`Delete "${t.name}"?`, 'Confirm', {
|
||||
type: 'warning',
|
||||
confirmButtonText: 'Delete',
|
||||
cancelButtonText: 'Cancel',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await tagsApi.deleteTag(t.id)
|
||||
ElMessage.success('Deleted')
|
||||
fetchList()
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-card">
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="filter.name"
|
||||
placeholder="Search by name"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>Search</span>
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span>Reset</span>
|
||||
</el-button>
|
||||
|
||||
<div class="filter-spacer" />
|
||||
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>Add Tag</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border stripe>
|
||||
<el-table-column label="Preview" width="120">
|
||||
<template #default="{ row }: { row: Tag }">
|
||||
<el-tag v-if="row.color" :color="row.color" effect="dark">
|
||||
{{ row.name }}
|
||||
</el-tag>
|
||||
<el-tag v-else effect="plain">{{ row.name }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="Name" min-width="200" />
|
||||
<el-table-column label="Color" 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>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="timing" label="Timing" min-width="120" />
|
||||
<el-table-column label="Actions" 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>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>Delete</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="No tags" />
|
||||
</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; fetchList() }"
|
||||
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogMode === 'create' ? 'Add Tag' : 'Edit Tag'"
|
||||
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>
|
||||
<el-form-item label="Color">
|
||||
<el-color-picker v-model="dialogForm.color" />
|
||||
<span class="color-readout">{{ dialogForm.color }}</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>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">Cancel</el-button>
|
||||
<el-button type="primary" :loading="dialogLoading" @click="handleSubmit">
|
||||
{{ dialogMode === 'create' ? 'Create' : 'Save' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.color-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.color-hex {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.color-readout {
|
||||
margin-left: 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client", "node"],
|
||||
|
||||
/* Path aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue",
|
||||
"src/auto-imports.d.ts",
|
||||
"src/components.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
imports: ['vue', 'vue-router', 'pinia'],
|
||||
dts: 'src/auto-imports.d.ts',
|
||||
}),
|
||||
Components({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
dts: 'src/components.d.ts',
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user