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:
yeuimu
2026-06-15 10:09:42 +08:00
parent 72bc89ab03
commit c3472a449b
125 changed files with 22206 additions and 0 deletions
@@ -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>