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,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>
|
||||
Reference in New Issue
Block a user