fix: 全面修复项目问题
后端修复: - 验证码存储与校验机制 - 订单创建事务+库存扣减 - GetStats字段名错误 - VerifyEmail改为POST - 供应商更新字段白名单 - 文件删除安全检查 - 抽奖安全随机数 - 用户管理CRUD - 订单取消/确认收货 - 工单回复 - Toggle返回新数据 - 移除死代码 前端修复: - 404兜底路由 - 401软跳转 - API层统一 - 面包屑补充banners - 国际化完善 - 购物车并行删除 - 退出清理购物车 - 供应商Dashboard数据 - 工单详情页 - 订单取消/确认收货
This commit is contained in:
+19
-10
@@ -29,7 +29,7 @@ export const authApi = {
|
||||
login: (data: any) => api.post('/auth/login', data),
|
||||
forgotPassword: (data: any) => api.post('/auth/forgot-password', data),
|
||||
resetPassword: (data: any) => api.post('/auth/reset-password', data),
|
||||
verifyEmail: (params: any) => api.get('/auth/verify-email', { params }),
|
||||
verifyEmail: (data: any) => api.post('/auth/verify-email', data),
|
||||
sendVerifyCode: () => api.post('/users/send-verify-code'),
|
||||
}
|
||||
|
||||
@@ -72,6 +72,8 @@ export const orderApi = {
|
||||
getById: (id: number) => api.get(`/orders/${id}`),
|
||||
create: (data: any) => api.post('/orders', data),
|
||||
refund: (id: number, data: any) => api.post(`/orders/${id}/refund`, data),
|
||||
cancel: (id: number) => api.put(`/orders/${id}/cancel`),
|
||||
confirmReceipt: (id: number) => api.put(`/orders/${id}/confirm-receipt`),
|
||||
}
|
||||
|
||||
export const articleApi = {
|
||||
@@ -79,6 +81,10 @@ export const articleApi = {
|
||||
getById: (id: number) => api.get(`/articles/${id}`),
|
||||
}
|
||||
|
||||
export const bannerApi = {
|
||||
list: () => api.get('/banners'),
|
||||
}
|
||||
|
||||
export const lotteryApi = {
|
||||
list: () => api.get('/lotteries'),
|
||||
getById: (id: number) => api.get(`/lotteries/${id}`),
|
||||
@@ -89,10 +95,7 @@ export const ticketApi = {
|
||||
list: (params?: any) => api.get('/tickets', { params }),
|
||||
create: (data: any) => api.post('/tickets', data),
|
||||
getById: (id: number) => api.get(`/tickets/${id}`),
|
||||
}
|
||||
|
||||
export const menuApi = {
|
||||
list: () => api.get('/menus'),
|
||||
reply: (id: number, data: any) => api.post(`/tickets/${id}/reply`, data),
|
||||
}
|
||||
|
||||
export const supplierApi = {
|
||||
@@ -105,6 +108,11 @@ export const supplierApi = {
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
getStats: () => api.get('/admin/stats'),
|
||||
getUsers: () => api.get('/admin/users'),
|
||||
updateUser: (id: number, data: any) => api.put(`/admin/users/${id}`, data),
|
||||
deleteUser: (id: number) => api.delete(`/admin/users/${id}`),
|
||||
|
||||
getCategories: () => api.get('/categories'),
|
||||
createCategory: (data: any) => api.post('/admin/categories', data),
|
||||
updateCategory: (id: number, data: any) => api.put(`/admin/categories/${id}`, data),
|
||||
@@ -147,11 +155,6 @@ export const adminApi = {
|
||||
updateSMTP: (data: any) => api.put('/admin/settings/smtp', data),
|
||||
updatePayment: (data: any) => api.put('/admin/settings/payment', data),
|
||||
|
||||
getMenus: () => api.get('/admin/menus'),
|
||||
createMenu: (data: any) => api.post('/admin/menus', data),
|
||||
updateMenu: (id: number, data: any) => api.put(`/admin/menus/${id}`, data),
|
||||
deleteMenu: (id: number) => api.delete(`/admin/menus/${id}`),
|
||||
|
||||
getInventory: () => api.get('/admin/inventory'),
|
||||
|
||||
getArticles: (params?: any) => api.get('/admin/articles', { params }),
|
||||
@@ -159,4 +162,10 @@ export const adminApi = {
|
||||
updateArticle: (id: number, data: any) => api.put(`/admin/articles/${id}`, data),
|
||||
deleteArticle: (id: number) => api.delete(`/admin/articles/${id}`),
|
||||
togglePinArticle: (id: number) => api.put(`/admin/articles/${id}/pin`),
|
||||
|
||||
getBanners: () => api.get('/admin/banners'),
|
||||
createBanner: (data: any) => api.post('/admin/banners', data),
|
||||
updateBanner: (id: number, data: any) => api.put(`/admin/banners/${id}`, data),
|
||||
deleteBanner: (id: number) => api.delete(`/admin/banners/${id}`),
|
||||
toggleBanner: (id: number) => api.put(`/admin/banners/${id}/toggle`),
|
||||
}
|
||||
|
||||
@@ -123,6 +123,8 @@
|
||||
"supplierManagement": "Supplier Management",
|
||||
"lotteryManagement": "Lottery Management",
|
||||
"ticketManagement": "Ticket Management",
|
||||
"articleManagement": "Article Management",
|
||||
"bannerManagement": "Banner Management",
|
||||
"systemSettings": "System Settings",
|
||||
"menuManagement": "Menu Management",
|
||||
"smtpSettings": "SMTP Settings",
|
||||
|
||||
@@ -123,6 +123,8 @@
|
||||
"supplierManagement": "サプライヤー管理",
|
||||
"lotteryManagement": "抽選管理",
|
||||
"ticketManagement": "チケット管理",
|
||||
"articleManagement": "記事管理",
|
||||
"bannerManagement": "バナー管理",
|
||||
"systemSettings": "システム設定",
|
||||
"menuManagement": "メニュー管理",
|
||||
"smtpSettings": "SMTP設定",
|
||||
|
||||
@@ -123,6 +123,8 @@
|
||||
"supplierManagement": "供货商管理",
|
||||
"lotteryManagement": "抽奖管理",
|
||||
"ticketManagement": "工单管理",
|
||||
"articleManagement": "资讯管理",
|
||||
"bannerManagement": "轮播图管理",
|
||||
"systemSettings": "系统设置",
|
||||
"menuManagement": "菜单管理",
|
||||
"smtpSettings": "SMTP设置",
|
||||
|
||||
@@ -47,7 +47,11 @@
|
||||
</router-link>
|
||||
<router-link to="/admin/articles" class="nav-link">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span class="nav-text">资讯管理</span>
|
||||
<span class="nav-text">{{ $t('admin.articleManagement') }}</span>
|
||||
</router-link>
|
||||
<router-link to="/admin/banners" class="nav-link">
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span class="nav-text">{{ $t('admin.bannerManagement') }}</span>
|
||||
</router-link>
|
||||
<router-link to="/admin/settings" class="nav-link">
|
||||
<el-icon><Setting /></el-icon>
|
||||
@@ -95,7 +99,45 @@
|
||||
<button class="menu-btn" @click="mobileMenuOpen = !mobileMenuOpen">
|
||||
<el-icon><Expand /></el-icon>
|
||||
</button>
|
||||
<h1 class="page-title">{{ currentPageTitle }}</h1>
|
||||
<nav class="breadcrumb">
|
||||
<span class="breadcrumb-item" @click="$router.push('/admin')">
|
||||
<el-icon><HomeFilled /></el-icon>
|
||||
<span>首页</span>
|
||||
</span>
|
||||
<el-icon class="breadcrumb-sep"><ArrowRight /></el-icon>
|
||||
<span class="breadcrumb-current">{{ currentPageTitle }}</span>
|
||||
</nav>
|
||||
<div class="topbar-actions">
|
||||
<el-tooltip content="返回前台" placement="bottom">
|
||||
<button class="action-btn" @click="router.push('/')">
|
||||
<el-icon><HomeFilled /></el-icon>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="全屏" placement="bottom">
|
||||
<button class="action-btn" @click="toggleFullscreen">
|
||||
<el-icon><FullScreen /></el-icon>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
<el-dropdown trigger="click" @command="handleCommand" placement="bottom-end">
|
||||
<div class="user-dropdown">
|
||||
<el-avatar :size="32" class="user-avatar">{{ user?.username?.charAt(0).toUpperCase() }}</el-avatar>
|
||||
<span class="user-name">{{ user?.username }}</span>
|
||||
<el-icon class="dropdown-arrow"><ArrowDown /></el-icon>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="home">
|
||||
<el-icon><HomeFilled /></el-icon>
|
||||
返回前台
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided command="logout">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
退出登录
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</header>
|
||||
<main class="page-content">
|
||||
<router-view />
|
||||
@@ -110,13 +152,15 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { DataAnalysis, User, Folder, Goods, List, Van, Trophy, ChatDotSquare, Setting, Document, Compass, Fold, Expand } from '@element-plus/icons-vue'
|
||||
import { DataAnalysis, User, Folder, Goods, List, Van, Trophy, ChatDotSquare, Setting, Document, Picture, Compass, Fold, Expand, HomeFilled, FullScreen, ArrowDown, SwitchButton, ArrowRight } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '../store/user'
|
||||
import { useCartStore } from '../store/cart'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { locale, t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
const cartStore = useCartStore()
|
||||
const sidebarCollapsed = ref(false)
|
||||
const mobileMenuOpen = ref(false)
|
||||
|
||||
@@ -132,6 +176,7 @@ const pageTitles: Record<string, string> = {
|
||||
'/admin/lotteries': '抽奖管理',
|
||||
'/admin/tickets': '工单管理',
|
||||
'/admin/articles': '资讯管理',
|
||||
'/admin/banners': '轮播图管理',
|
||||
'/admin/settings': '系统设置'
|
||||
}
|
||||
|
||||
@@ -147,7 +192,15 @@ function changeLocale(lang: string) {
|
||||
function handleCommand(command: string) {
|
||||
switch (command) {
|
||||
case 'home': router.push('/'); break
|
||||
case 'logout': userStore.logout(); router.push('/login'); break
|
||||
case 'logout': userStore.logout(); cartStore.clearCart(); router.push('/login'); break
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen()
|
||||
} else {
|
||||
document.exitFullscreen()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -328,11 +381,99 @@ function handleCommand(command: string) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.breadcrumb-item:hover {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.breadcrumb-sep {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.breadcrumb-current {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 4px 8px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.user-dropdown:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
background: linear-gradient(135deg, #4e6ef2, #7c5cfc);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
@@ -364,10 +505,30 @@ function handleCommand(command: string) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.breadcrumb-item span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
display: none;
|
||||
}
|
||||
@@ -379,5 +540,9 @@ function handleCommand(command: string) {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 16px 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -70,11 +70,13 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { DataAnalysis, List, Box, Fold, Expand } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '../store/user'
|
||||
import { useCartStore } from '../store/cart'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
const cartStore = useCartStore()
|
||||
const sidebarCollapsed = ref(false)
|
||||
const mobileMenuOpen = ref(false)
|
||||
|
||||
@@ -93,7 +95,7 @@ const currentPageTitle = computed(() => {
|
||||
function handleCommand(command: string) {
|
||||
switch (command) {
|
||||
case 'home': router.push('/'); break
|
||||
case 'logout': userStore.logout(); router.push('/login'); break
|
||||
case 'logout': userStore.logout(); cartStore.clearCart(); router.push('/login'); break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -117,8 +117,8 @@ import { ref, computed, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { HomeFilled, Goods, Document, Trophy, ShoppingCart, Compass, User, Fold, Expand, Search } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '../../store/user'
|
||||
import { useCartStore } from '../../store/cart'
|
||||
import { useUserStore } from '../store/user'
|
||||
import { useCartStore } from '../store/cart'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -147,7 +147,7 @@ function handleCommand(command: string) {
|
||||
case 'orders': router.push('/orders'); break
|
||||
case 'admin': router.push('/admin'); break
|
||||
case 'supplier': router.push('/supplier'); break
|
||||
case 'logout': userStore.logout(); router.push('/login'); break
|
||||
case 'logout': userStore.logout(); cartStore.clearCart(); router.push('/login'); break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,6 +442,11 @@ if (isLoggedIn.value) cartStore.fetchCart()
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.layout-wrapper {
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
width: 220px !important;
|
||||
@@ -458,6 +463,9 @@ if (isLoggedIn.value) cartStore.fetchCart()
|
||||
.main-container,
|
||||
.main-container.sidebar-collapsed {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
@@ -468,6 +476,14 @@ if (isLoggedIn.value) cartStore.fetchCart()
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 16px;
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
display: block;
|
||||
position: fixed;
|
||||
@@ -10,9 +10,9 @@ const routes = [
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../layouts/scheme4/UserLayout.vue'),
|
||||
component: () => import('../layouts/UserLayout.vue'),
|
||||
children: [
|
||||
{ path: '', name: 'Home', component: () => import('../views/scheme4/Home.vue') },
|
||||
{ path: '', name: 'Home', component: () => import('../views/user/Home.vue') },
|
||||
{ path: 'products', name: 'Products', component: () => import('../views/user/Products.vue') },
|
||||
{ path: 'products/:id', name: 'ProductDetail', component: () => import('../views/user/ProductDetail.vue') },
|
||||
{ path: 'cart', name: 'Cart', component: () => import('../views/user/Cart.vue') },
|
||||
@@ -24,6 +24,7 @@ const routes = [
|
||||
{ path: 'lotteries/:id', name: 'LotteryDetail', component: () => import('../views/user/LotteryDetail.vue') },
|
||||
{ path: 'tickets', name: 'Tickets', component: () => import('../views/user/Tickets.vue') },
|
||||
{ path: 'tickets/create', name: 'CreateTicket', component: () => import('../views/user/CreateTicket.vue') },
|
||||
{ path: 'tickets/:id', name: 'TicketDetail', component: () => import('../views/user/TicketDetail.vue') },
|
||||
{ path: 'articles', name: 'Articles', component: () => import('../views/user/Articles.vue') },
|
||||
{ path: 'articles/:id', name: 'ArticleDetail', component: () => import('../views/user/ArticleDetail.vue') },
|
||||
{ path: 'login', name: 'Login', component: () => import('../views/auth/Login.vue'), meta: { guest: true, hideSidebar: true } },
|
||||
@@ -46,6 +47,7 @@ const routes = [
|
||||
{ path: 'tickets', name: 'AdminTickets', component: () => import('../views/admin/Tickets.vue') },
|
||||
{ path: 'settings', name: 'AdminSettings', component: () => import('../views/admin/Settings.vue') },
|
||||
{ path: 'articles', name: 'AdminArticles', component: () => import('../views/admin/Articles.vue') },
|
||||
{ path: 'banners', name: 'AdminBanners', component: () => import('../views/admin/Banners.vue') },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -58,6 +60,11 @@ const routes = [
|
||||
{ path: 'inventory', name: 'SupplierInventory', component: () => import('../views/supplier/Inventory.vue') },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: () => import('../views/NotFound.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
+17
-13
@@ -13,7 +13,6 @@ export const useCartStore = defineStore('cart', () => {
|
||||
try {
|
||||
const res: any = await cartApi.list()
|
||||
items.value = res.data || []
|
||||
calcTotals()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -35,12 +34,16 @@ export const useCartStore = defineStore('cart', () => {
|
||||
}
|
||||
|
||||
async function removeItems(ids: number[]) {
|
||||
for (const id of ids) {
|
||||
await cartApi.delete(id)
|
||||
}
|
||||
await Promise.all(ids.map(id => cartApi.delete(id)))
|
||||
await fetchCart()
|
||||
}
|
||||
|
||||
function clearCart() {
|
||||
items.value = []
|
||||
totalAmount.value = 0
|
||||
totalCount.value = 0
|
||||
}
|
||||
|
||||
function calcTotals() {
|
||||
totalAmount.value = items.value.reduce((sum, item) => sum + (item.product?.price || 0) * item.quantity, 0)
|
||||
totalCount.value = items.value.reduce((sum, item) => sum + item.quantity, 0)
|
||||
@@ -48,15 +51,16 @@ export const useCartStore = defineStore('cart', () => {
|
||||
|
||||
watch(items, calcTotals, { deep: true })
|
||||
|
||||
return {
|
||||
items,
|
||||
loading,
|
||||
totalAmount,
|
||||
totalCount,
|
||||
fetchCart,
|
||||
addItem,
|
||||
updateItem,
|
||||
return {
|
||||
items,
|
||||
loading,
|
||||
totalAmount,
|
||||
totalCount,
|
||||
fetchCart,
|
||||
addItem,
|
||||
updateItem,
|
||||
removeItem,
|
||||
removeItems
|
||||
removeItems,
|
||||
clearCart
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,10 +2,18 @@ import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { authApi, userApi } from '../api'
|
||||
|
||||
function safeParseUser(str: string | null): any {
|
||||
if (!str || str === 'undefined' || str === 'null') return null
|
||||
try {
|
||||
return JSON.parse(str)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const token = ref(localStorage.getItem('token') || '')
|
||||
const userStr = localStorage.getItem('user')
|
||||
const user = ref<any>(userStr && userStr !== 'undefined' ? JSON.parse(userStr) : null)
|
||||
const user = ref<any>(safeParseUser(localStorage.getItem('user')))
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import router from '../router'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api',
|
||||
@@ -29,7 +30,8 @@ api.interceptors.response.use(
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
if (hadToken) {
|
||||
window.location.href = '/login'
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
router.push('/login')
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(message)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="not-found">
|
||||
<div class="error-code">404</div>
|
||||
<h2>页面不存在</h2>
|
||||
<p>您访问的页面不存在或已被移除</p>
|
||||
<el-button type="primary" @click="$router.push('/')">返回首页</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.not-found {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 120px;
|
||||
font-weight: 700;
|
||||
color: rgba(78, 110, 242, 0.3);
|
||||
line-height: 1;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="articles-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">资讯管理</h2>
|
||||
<el-button type="primary" @click="showAdd = true">创建资讯</el-button>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
@@ -40,7 +39,21 @@
|
||||
<el-form-item label="标题"><el-input v-model="form.title" /></el-form-item>
|
||||
<el-form-item label="摘要"><el-input v-model="form.summary" type="textarea" :rows="2" /></el-form-item>
|
||||
<el-form-item label="内容"><el-input v-model="form.content" type="textarea" :rows="10" /></el-form-item>
|
||||
<el-form-item label="封面图"><el-input v-model="form.cover_image" placeholder="URL" /></el-form-item>
|
||||
<el-form-item label="封面图">
|
||||
<el-upload
|
||||
class="cover-uploader"
|
||||
:action="uploadUrl"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:on-success="handleCoverSuccess"
|
||||
:before-upload="beforeCoverUpload"
|
||||
accept="image/*"
|
||||
>
|
||||
<img v-if="form.cover_image" :src="form.cover_image" class="cover-image" />
|
||||
<el-icon v-else class="cover-uploader-icon"><Plus /></el-icon>
|
||||
</el-upload>
|
||||
<div class="cover-tip">建议尺寸: 800x400px,支持 jpg、png、webp 格式</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序"><el-input-number v-model="form.sort_order" /></el-form-item>
|
||||
<el-form-item label="发布"><el-switch v-model="form.is_published" /></el-form-item>
|
||||
<el-form-item label="置顶"><el-switch v-model="form.is_pinned" /></el-form-item>
|
||||
@@ -56,8 +69,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { adminApi } from '../../api'
|
||||
|
||||
const uploadUrl = import.meta.env.VITE_API_URL + '/upload'
|
||||
const uploadHeaders = { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||
|
||||
const articles = ref<any[]>([])
|
||||
const showAdd = ref(false)
|
||||
const editing = ref<any>(null)
|
||||
@@ -74,6 +91,30 @@ function handlePageChange() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
function beforeCoverUpload(file: File) {
|
||||
const isImage = file.type.startsWith('image/')
|
||||
const isLt5M = file.size / 1024 / 1024 < 5
|
||||
|
||||
if (!isImage) {
|
||||
ElMessage.error('只能上传图片文件!')
|
||||
return false
|
||||
}
|
||||
if (!isLt5M) {
|
||||
ElMessage.error('图片大小不能超过 5MB!')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function handleCoverSuccess(response: any) {
|
||||
if (response.url) {
|
||||
form.cover_image = response.url
|
||||
ElMessage.success('封面上传成功')
|
||||
} else {
|
||||
ElMessage.error('上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date: string) {
|
||||
if (!date) return '-'
|
||||
const d = new Date(date)
|
||||
@@ -127,9 +168,46 @@ onMounted(fetchArticles)
|
||||
<style scoped lang="scss">
|
||||
.articles-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
||||
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
|
||||
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
|
||||
|
||||
.cover-uploader {
|
||||
:deep(.el-upload) {
|
||||
border: 1px dashed rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s;
|
||||
width: 200px;
|
||||
height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
border-color: #4e6ef2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
width: 200px;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.cover-uploader-icon {
|
||||
font-size: 28px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.cover-tip {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
<template>
|
||||
<div class="banners-page">
|
||||
<div class="page-header">
|
||||
<el-button type="primary" @click="openAdd">添加轮播图</el-button>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
<el-table :data="banners">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column label="图片" width="150">
|
||||
<template #default="{ row }">
|
||||
<img :src="getImageUrl(row.image)" style="width: 120px; height: 60px; object-fit: cover; border-radius: 4px;" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="标题" />
|
||||
<el-table-column prop="desc" label="描述" />
|
||||
<el-table-column prop="button" label="按钮文字" width="120" />
|
||||
<el-table-column prop="sort_order" label="排序" width="80" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'info'">
|
||||
{{ row.is_active ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="editBanner(row)">编辑</el-button>
|
||||
<el-button link :type="row.is_active ? 'warning' : 'success'" size="small" @click="toggleStatus(row)">
|
||||
{{ row.is_active ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button link type="danger" size="small" @click="deleteBanner(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="showAdd" :title="editing ? '编辑轮播图' : '添加轮播图'" width="600px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="标题" required>
|
||||
<el-input v-model="form.title" placeholder="请输入标题" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="form.desc" type="textarea" :rows="2" placeholder="请输入描述" />
|
||||
</el-form-item>
|
||||
<el-form-item label="图片" required>
|
||||
<el-upload
|
||||
class="banner-uploader"
|
||||
:action="uploadUrl"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:on-success="handleImageSuccess"
|
||||
:before-upload="beforeImageUpload"
|
||||
accept="image/*"
|
||||
>
|
||||
<img v-if="form.image" :src="form.image" class="banner-image" />
|
||||
<el-icon v-else class="banner-uploader-icon"><Plus /></el-icon>
|
||||
</el-upload>
|
||||
<div class="image-tip">建议尺寸: 1920x400px,支持 jpg、png、webp 格式</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="链接类型">
|
||||
<el-radio-group v-model="linkType" @change="handleLinkTypeChange">
|
||||
<el-radio-button value="custom">自定义</el-radio-button>
|
||||
<el-radio-button value="product">商品</el-radio-button>
|
||||
<el-radio-button value="article">资讯</el-radio-button>
|
||||
<el-radio-button value="lottery">活动</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="链接" v-if="linkType === 'custom'">
|
||||
<el-input v-model="form.link" placeholder="点击跳转链接,如: /products" />
|
||||
</el-form-item>
|
||||
<el-form-item label="选择商品" v-if="linkType === 'product'">
|
||||
<el-select v-model="selectedProduct" placeholder="请选择商品" filterable @change="handleProductSelect" style="width: 100%">
|
||||
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="选择资讯" v-if="linkType === 'article'">
|
||||
<el-select v-model="selectedArticle" placeholder="请选择资讯" filterable @change="handleArticleSelect" style="width: 100%">
|
||||
<el-option v-for="a in articles" :key="a.id" :label="a.title" :value="a.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="选择活动" v-if="linkType === 'lottery'">
|
||||
<el-select v-model="selectedLottery" placeholder="请选择活动" filterable @change="handleLotterySelect" style="width: 100%">
|
||||
<el-option v-for="l in lotteries" :key="l.id" :label="l.name" :value="l.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="按钮文字">
|
||||
<el-input v-model="form.button" placeholder="如: 立即选购" />
|
||||
</el-form-item>
|
||||
<el-form-item label="背景颜色">
|
||||
<el-input v-model="form.bg_color" placeholder="如: linear-gradient(135deg, #4e6ef2 0%, #7c5cfc 100%)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sort_order" :min="0" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="form.is_active" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAdd = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveBanner">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { adminApi, productApi, articleApi, lotteryApi } from '../../api'
|
||||
import { getImageUrl } from '../../utils/image'
|
||||
|
||||
const uploadUrl = import.meta.env.VITE_API_URL + '/upload'
|
||||
const uploadHeaders = { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||
|
||||
const banners = ref<any[]>([])
|
||||
const products = ref<any[]>([])
|
||||
const articles = ref<any[]>([])
|
||||
const lotteries = ref<any[]>([])
|
||||
const showAdd = ref(false)
|
||||
const editing = ref<any>(null)
|
||||
const linkType = ref('custom')
|
||||
const selectedProduct = ref<number | null>(null)
|
||||
const selectedArticle = ref<number | null>(null)
|
||||
const selectedLottery = ref<number | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
desc: '',
|
||||
image: '',
|
||||
link: '',
|
||||
button: '',
|
||||
bg_color: '',
|
||||
sort_order: 0,
|
||||
is_active: true
|
||||
})
|
||||
|
||||
async function fetchBanners() {
|
||||
const res: any = await adminApi.getBanners()
|
||||
banners.value = res.data || []
|
||||
}
|
||||
|
||||
async function fetchProducts() {
|
||||
const res: any = await productApi.list({ page: 1, page_size: 100 })
|
||||
products.value = res.data || []
|
||||
}
|
||||
|
||||
async function fetchArticles() {
|
||||
const res: any = await articleApi.list({ page: 1, page_size: 100 })
|
||||
articles.value = res.data || []
|
||||
}
|
||||
|
||||
async function fetchLotteries() {
|
||||
const res: any = await lotteryApi.list()
|
||||
lotteries.value = res.data || []
|
||||
}
|
||||
|
||||
function handleLinkTypeChange() {
|
||||
form.link = ''
|
||||
selectedProduct.value = null
|
||||
selectedArticle.value = null
|
||||
selectedLottery.value = null
|
||||
}
|
||||
|
||||
function handleProductSelect(productId: number) {
|
||||
form.link = `/products/${productId}`
|
||||
}
|
||||
|
||||
function handleArticleSelect(articleId: number) {
|
||||
form.link = `/articles/${articleId}`
|
||||
}
|
||||
|
||||
function handleLotterySelect(lotteryId: number) {
|
||||
form.link = `/lotteries/${lotteryId}`
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
editing.value = null
|
||||
linkType.value = 'custom'
|
||||
selectedProduct.value = null
|
||||
selectedArticle.value = null
|
||||
selectedLottery.value = null
|
||||
Object.assign(form, { title: '', desc: '', image: '', link: '', button: '', bg_color: '', sort_order: 0, is_active: true })
|
||||
showAdd.value = true
|
||||
}
|
||||
|
||||
function editBanner(row: any) {
|
||||
editing.value = row
|
||||
Object.assign(form, {
|
||||
title: row.title,
|
||||
desc: row.desc || '',
|
||||
image: row.image || '',
|
||||
link: row.link || '',
|
||||
button: row.button || '',
|
||||
bg_color: row.bg_color || '',
|
||||
sort_order: row.sort_order,
|
||||
is_active: row.is_active
|
||||
})
|
||||
|
||||
// 根据链接判断类型
|
||||
if (row.link) {
|
||||
if (row.link.startsWith('/products/')) {
|
||||
linkType.value = 'product'
|
||||
const id = parseInt(row.link.split('/')[2])
|
||||
selectedProduct.value = isNaN(id) ? null : id
|
||||
} else if (row.link.startsWith('/articles/')) {
|
||||
linkType.value = 'article'
|
||||
const id = parseInt(row.link.split('/')[2])
|
||||
selectedArticle.value = isNaN(id) ? null : id
|
||||
} else if (row.link.startsWith('/lotteries/')) {
|
||||
linkType.value = 'lottery'
|
||||
const id = parseInt(row.link.split('/')[2])
|
||||
selectedLottery.value = isNaN(id) ? null : id
|
||||
} else {
|
||||
linkType.value = 'custom'
|
||||
}
|
||||
} else {
|
||||
linkType.value = 'custom'
|
||||
}
|
||||
|
||||
showAdd.value = true
|
||||
}
|
||||
|
||||
function beforeImageUpload(file: File) {
|
||||
const isImage = file.type.startsWith('image/')
|
||||
const isLt5M = file.size / 1024 / 1024 < 5
|
||||
|
||||
if (!isImage) {
|
||||
ElMessage.error('只能上传图片文件!')
|
||||
return false
|
||||
}
|
||||
if (!isLt5M) {
|
||||
ElMessage.error('图片大小不能超过 5MB!')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function handleImageSuccess(response: any) {
|
||||
if (response.url) {
|
||||
form.image = response.url
|
||||
ElMessage.success('图片上传成功')
|
||||
} else {
|
||||
ElMessage.error('上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBanner() {
|
||||
if (!form.title || !form.image) {
|
||||
ElMessage.warning('请填写必填项')
|
||||
return
|
||||
}
|
||||
|
||||
if (editing.value) {
|
||||
await adminApi.updateBanner(editing.value.id, form)
|
||||
} else {
|
||||
await adminApi.createBanner(form)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
showAdd.value = false
|
||||
editing.value = null
|
||||
await fetchBanners()
|
||||
}
|
||||
|
||||
async function toggleStatus(row: any) {
|
||||
await adminApi.toggleBanner(row.id)
|
||||
ElMessage.success('操作成功')
|
||||
await fetchBanners()
|
||||
}
|
||||
|
||||
async function deleteBanner(id: number) {
|
||||
await ElMessageBox.confirm('确定删除此轮播图?', '确认删除')
|
||||
await adminApi.deleteBanner(id)
|
||||
ElMessage.success('删除成功')
|
||||
await fetchBanners()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
fetchBanners(),
|
||||
fetchProducts(),
|
||||
fetchArticles(),
|
||||
fetchLotteries()
|
||||
])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.banners-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
||||
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
|
||||
|
||||
.banner-uploader {
|
||||
:deep(.el-upload) {
|
||||
border: 1px dashed rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s;
|
||||
width: 300px;
|
||||
height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
border-color: #4e6ef2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.banner-image {
|
||||
width: 300px;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.banner-uploader-icon {
|
||||
font-size: 28px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.image-tip {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="categories-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">分类管理</h2>
|
||||
<el-button type="primary" @click="openAdd">创建分类</el-button>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
@@ -114,7 +113,6 @@ onMounted(fetchCategories)
|
||||
<style scoped lang="scss">
|
||||
.categories-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
||||
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
|
||||
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
|
||||
|
||||
.pagination-wrap {
|
||||
|
||||
@@ -1,57 +1,48 @@
|
||||
<template>
|
||||
<div class="dashboard-page">
|
||||
<h2 class="page-title">管理后台</h2>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon users"><el-icon><User /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<p class="stat-value">{{ stats.users }}</p>
|
||||
<p class="stat-label">用户数</p>
|
||||
</div>
|
||||
<div class="stat-cards">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon users"><el-icon><User /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<p class="stat-value">{{ stats.users }}</p>
|
||||
<p class="stat-label">用户数</p>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon products"><el-icon><Goods /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<p class="stat-value">{{ stats.products }}</p>
|
||||
<p class="stat-label">商品数</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon products"><el-icon><Goods /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<p class="stat-value">{{ stats.products }}</p>
|
||||
<p class="stat-label">商品数</p>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon orders"><el-icon><List /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<p class="stat-value">{{ stats.orders }}</p>
|
||||
<p class="stat-label">订单数</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon orders"><el-icon><List /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<p class="stat-value">{{ stats.orders }}</p>
|
||||
<p class="stat-label">订单数</p>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon revenue"><el-icon><Money /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<p class="stat-value">{{ stats.revenue }}</p>
|
||||
<p class="stat-label">营收</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon revenue"><el-icon><Money /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<p class="stat-value">{{ stats.revenue }}</p>
|
||||
<p class="stat-label">营收</p>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { User, Goods, List, Money } from '@element-plus/icons-vue'
|
||||
import api from '../../utils/request'
|
||||
import { adminApi } from '../../api'
|
||||
|
||||
const stats = ref({ users: '--', products: '--', orders: '--', revenue: '--' })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res: any = await api.get('/admin/stats')
|
||||
const res: any = await adminApi.getStats()
|
||||
if (res.data) {
|
||||
stats.value.users = res.data.users || 0
|
||||
stats.value.products = res.data.products || 0
|
||||
@@ -66,7 +57,13 @@ onMounted(async () => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dashboard-page { padding: 0; }
|
||||
.page-title { font-size: 24px; font-weight: 600; color: #fff; margin-bottom: 24px; }
|
||||
|
||||
.stat-cards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #2d2d44;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
@@ -75,7 +72,10 @@ onMounted(async () => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex: 1 1 200px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
@@ -84,6 +84,7 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.stat-icon.users { background: rgba(78, 110, 242, 0.15); color: #4e6ef2; }
|
||||
.stat-icon.products { background: rgba(16, 185, 129, 0.15); color: #10b981; }
|
||||
@@ -92,4 +93,32 @@ onMounted(async () => {
|
||||
.stat-info { flex: 1; }
|
||||
.stat-value { font-size: 28px; font-weight: 700; color: #fff; }
|
||||
.stat-label { font-size: 14px; color: rgba(255, 255, 255, 0.5); margin-top: 4px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stat-cards {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
flex: 1 1 calc(50% - 6px);
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 20px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="lotteries-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">抽奖管理</h2>
|
||||
<el-button type="primary" @click="openAdd">创建抽奖</el-button>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
@@ -45,6 +44,21 @@
|
||||
<el-form-item label="抽奖描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="请输入抽奖描述" />
|
||||
</el-form-item>
|
||||
<el-form-item label="活动图片">
|
||||
<el-upload
|
||||
class="lottery-image-uploader"
|
||||
:action="uploadUrl"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:on-success="handleImageSuccess"
|
||||
:before-upload="beforeImageUpload"
|
||||
accept="image/*"
|
||||
>
|
||||
<img v-if="form.image" :src="form.image" class="lottery-image" />
|
||||
<el-icon v-else class="lottery-uploader-icon"><Plus /></el-icon>
|
||||
</el-upload>
|
||||
<div class="image-tip">建议尺寸: 400x300px,支持 jpg、png、webp 格式</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="开始时间" required>
|
||||
<el-date-picker
|
||||
v-model="form.start_time"
|
||||
@@ -142,8 +156,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { adminApi } from '../../api'
|
||||
|
||||
const uploadUrl = import.meta.env.VITE_API_URL + '/upload'
|
||||
const uploadHeaders = { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||
|
||||
const lotteries = ref<any[]>([])
|
||||
const showAdd = ref(false)
|
||||
const showPrize = ref(false)
|
||||
@@ -159,6 +177,7 @@ const paginatedLotteries = computed(() => {
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
image: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
cycle: 'daily',
|
||||
@@ -186,6 +205,30 @@ function handlePageChange() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
function beforeImageUpload(file: File) {
|
||||
const isImage = file.type.startsWith('image/')
|
||||
const isLt5M = file.size / 1024 / 1024 < 5
|
||||
|
||||
if (!isImage) {
|
||||
ElMessage.error('只能上传图片文件!')
|
||||
return false
|
||||
}
|
||||
if (!isLt5M) {
|
||||
ElMessage.error('图片大小不能超过 5MB!')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function handleImageSuccess(response: any) {
|
||||
if (response.url) {
|
||||
form.image = response.url
|
||||
ElMessage.success('图片上传成功')
|
||||
} else {
|
||||
ElMessage.error('上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLotteries() {
|
||||
const res: any = await adminApi.getLotteries()
|
||||
lotteries.value = res.data || []
|
||||
@@ -193,7 +236,7 @@ async function fetchLotteries() {
|
||||
|
||||
function openAdd() {
|
||||
editing.value = null
|
||||
Object.assign(form, { name: '', description: '', start_time: '', end_time: '', cycle: 'daily', daily_quota: undefined, total_quota: undefined, registration_validity: undefined })
|
||||
Object.assign(form, { name: '', description: '', image: '', start_time: '', end_time: '', cycle: 'daily', daily_quota: undefined, total_quota: undefined, registration_validity: undefined })
|
||||
showAdd.value = true
|
||||
}
|
||||
|
||||
@@ -202,6 +245,7 @@ function editLottery(row: any) {
|
||||
Object.assign(form, {
|
||||
name: row.name,
|
||||
description: row.description || '',
|
||||
image: row.image || '',
|
||||
start_time: row.start_time,
|
||||
end_time: row.end_time,
|
||||
cycle: row.cycle || 'daily',
|
||||
@@ -267,9 +311,46 @@ onMounted(fetchLotteries)
|
||||
<style scoped lang="scss">
|
||||
.lotteries-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
||||
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
|
||||
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
|
||||
|
||||
.lottery-image-uploader {
|
||||
:deep(.el-upload) {
|
||||
border: 1px dashed rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s;
|
||||
width: 200px;
|
||||
height: 150px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
border-color: #4e6ef2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lottery-image {
|
||||
width: 200px;
|
||||
height: 150px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.lottery-uploader-icon {
|
||||
font-size: 28px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.image-tip {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="orders-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">订单管理</h2>
|
||||
<el-button @click="exportOrders">导出订单</el-button>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
@@ -91,7 +90,6 @@ onMounted(fetchOrders)
|
||||
<style scoped lang="scss">
|
||||
.orders-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
||||
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
|
||||
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
|
||||
|
||||
.pagination-wrap {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="products-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">商品管理</h2>
|
||||
<el-button type="primary" @click="openAdd">创建商品</el-button>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
@@ -354,7 +353,6 @@ onMounted(() => {
|
||||
<style scoped lang="scss">
|
||||
.products-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
||||
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
|
||||
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
|
||||
.table-image-placeholder { width: 50px; height: 50px; display: flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.04); border-radius: 4px; color: rgba(255,255,255,0.3); }
|
||||
.upload-tip { font-size: 12px; color: rgba(255,255,255,0.4); margin-top: 8px; }
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">系统设置</h2>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<el-tabs>
|
||||
<el-tab-pane label="基础设置">
|
||||
@@ -116,8 +113,6 @@ onMounted(fetchSettings)
|
||||
|
||||
<style scoped lang="scss">
|
||||
.settings-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
||||
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
|
||||
.settings-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 24px; }
|
||||
|
||||
:deep(.el-checkbox__label) { color: rgba(255, 255, 255, 0.8); }
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="suppliers-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">供应商管理</h2>
|
||||
<el-button type="primary" @click="showAdd = true">创建供应商</el-button>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
@@ -110,7 +109,6 @@ onMounted(fetchSuppliers)
|
||||
<style scoped lang="scss">
|
||||
.suppliers-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
||||
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
|
||||
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
|
||||
|
||||
.pagination-wrap {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<template>
|
||||
<div class="tickets-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">工单管理</h2>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
<el-table :data="paginatedTickets">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
@@ -85,8 +82,6 @@ onMounted(fetchTickets)
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tickets-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
||||
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
|
||||
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
|
||||
|
||||
.pagination-wrap {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<template>
|
||||
<div class="users-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">用户管理</h2>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
<el-table :data="paginatedUsers">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
@@ -16,11 +13,24 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="purchase_credits" label="积分" width="100" />
|
||||
<el-table-column prop="is_active" label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'danger'" size="small">
|
||||
{{ row.is_active ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="注册时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="handleDelete(row)" :disabled="row.role === 'admin'">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination-wrap">
|
||||
<el-pagination
|
||||
@@ -32,12 +42,38 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="editVisible" title="编辑用户" width="480px">
|
||||
<el-form :model="editForm" label-width="80px">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="editForm.username" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="editForm.email" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="editForm.role">
|
||||
<el-option label="用户" value="user" />
|
||||
<el-option label="供应商" value="supplier" />
|
||||
<el-option label="管理员" value="admin" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-switch v-model="editForm.is_active" active-text="启用" inactive-text="禁用" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleEdit" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import api from '../../utils/request'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { adminApi } from '../../api'
|
||||
|
||||
const users = ref<any[]>([])
|
||||
const page = ref(1)
|
||||
@@ -48,16 +84,24 @@ const paginatedUsers = computed(() => {
|
||||
return users.value.slice(start, start + pageSize)
|
||||
})
|
||||
|
||||
const editVisible = ref(false)
|
||||
const editForm = ref<any>({})
|
||||
const saving = ref(false)
|
||||
|
||||
function handlePageChange() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
async function fetchUsers() {
|
||||
try {
|
||||
const res: any = await api.get('/admin/users')
|
||||
const res: any = await adminApi.getUsers()
|
||||
users.value = res.data || []
|
||||
} catch {}
|
||||
})
|
||||
} catch {
|
||||
ElMessage.error('获取用户列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchUsers)
|
||||
|
||||
function formatDate(date: string) {
|
||||
if (!date) return '-'
|
||||
@@ -69,12 +113,43 @@ function formatDate(date: string) {
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
function openEdit(row: any) {
|
||||
editForm.value = { ...row }
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
async function handleEdit() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminApi.updateUser(editForm.value.id, {
|
||||
username: editForm.value.username,
|
||||
email: editForm.value.email,
|
||||
role: editForm.value.role,
|
||||
is_active: editForm.value.is_active,
|
||||
})
|
||||
ElMessage.success('更新成功')
|
||||
editVisible.value = false
|
||||
fetchUsers()
|
||||
} catch {
|
||||
ElMessage.error('更新失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该用户吗?', '提示', { type: 'warning' })
|
||||
await adminApi.deleteUser(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchUsers()
|
||||
} catch {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.users-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
||||
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
|
||||
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
|
||||
|
||||
.pagination-wrap {
|
||||
|
||||
@@ -1,523 +0,0 @@
|
||||
<template>
|
||||
<div class="home-page">
|
||||
<div class="banner-section">
|
||||
<el-carousel height="280px" :interval="5000" arrow="hover" indicator-position="inside">
|
||||
<el-carousel-item v-for="(banner, index) in banners" :key="index">
|
||||
<div class="banner-slide" :style="{ background: banner.bg }">
|
||||
<div class="banner-content">
|
||||
<h2>{{ banner.title }}</h2>
|
||||
<p>{{ banner.desc }}</p>
|
||||
<button class="banner-btn" @click="$router.push(banner.link)">{{ banner.btn }}</button>
|
||||
</div>
|
||||
<div class="banner-icon">
|
||||
<el-icon :size="80"><component :is="banner.icon" /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</el-carousel-item>
|
||||
</el-carousel>
|
||||
</div>
|
||||
|
||||
<div class="quick-actions">
|
||||
<div class="action-item" @click="$router.push('/products')">
|
||||
<div class="action-icon" style="background: rgba(78, 110, 242, 0.15); color: #4e6ef2;">
|
||||
<el-icon size="22"><Goods /></el-icon>
|
||||
</div>
|
||||
<span>商品</span>
|
||||
</div>
|
||||
<div class="action-item" @click="$router.push('/lotteries')">
|
||||
<div class="action-icon" style="background: rgba(245, 158, 11, 0.15); color: #f59e0b;">
|
||||
<el-icon size="22"><Trophy /></el-icon>
|
||||
</div>
|
||||
<span>抽奖</span>
|
||||
</div>
|
||||
<div class="action-item" @click="$router.push('/articles')">
|
||||
<div class="action-icon" style="background: rgba(16, 185, 129, 0.15); color: #10b981;">
|
||||
<el-icon size="22"><Document /></el-icon>
|
||||
</div>
|
||||
<span>资讯</span>
|
||||
</div>
|
||||
<div class="action-item" @click="$router.push('/cart')">
|
||||
<div class="action-icon" style="background: rgba(124, 92, 252, 0.15); color: #7c5cfc;">
|
||||
<el-icon size="22"><ShoppingCart /></el-icon>
|
||||
</div>
|
||||
<span>购物车</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section" v-if="lotteries.length">
|
||||
<div class="section-head">
|
||||
<h2>热门活动</h2>
|
||||
</div>
|
||||
<div class="lottery-cards">
|
||||
<div v-for="l in lotteries" :key="l.id" class="lottery-card" @click="$router.push(`/lotteries/${l.id}`)">
|
||||
<div class="lottery-icon">
|
||||
<el-icon size="20"><Trophy /></el-icon>
|
||||
</div>
|
||||
<div class="lottery-info">
|
||||
<h3>{{ l.name }}</h3>
|
||||
<p>{{ l.description }}</p>
|
||||
</div>
|
||||
<div class="lottery-right">
|
||||
<span class="lottery-status" :class="l.status === 'active' ? 'active' : ''">
|
||||
{{ l.status === 'active' ? '进行中' : '已结束' }}
|
||||
</span>
|
||||
<button class="lottery-btn">参与</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section" v-if="articles.length">
|
||||
<div class="section-head">
|
||||
<h2>最新资讯</h2>
|
||||
<span class="section-more" @click="$router.push('/articles')">查看全部 →</span>
|
||||
</div>
|
||||
<div class="article-list">
|
||||
<div v-for="a in articles" :key="a.id" class="article-item" @click="$router.push(`/articles/${a.id}`)">
|
||||
<div class="article-icon">
|
||||
<el-icon size="18"><Document /></el-icon>
|
||||
</div>
|
||||
<div class="article-info">
|
||||
<h3>{{ a.title }}</h3>
|
||||
<p>{{ a.summary || a.content?.slice(0, 60) }}...</p>
|
||||
</div>
|
||||
<span class="article-date">{{ formatDate(a.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section" v-if="categories.length">
|
||||
<div class="section-head">
|
||||
<h2>热门分类</h2>
|
||||
<span class="section-more" @click="$router.push('/products')">查看全部 →</span>
|
||||
</div>
|
||||
<div class="category-scroll">
|
||||
<div v-for="cat in categories" :key="cat.id" class="category-card" @click="goCategory(cat.id)">
|
||||
<div class="category-icon">
|
||||
<el-icon size="24"><Folder /></el-icon>
|
||||
</div>
|
||||
<div class="category-name">{{ cat.name }}</div>
|
||||
<div class="category-count">{{ cat.product_count || 0 }} 件</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, shallowRef } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Goods, Trophy, Document, ShoppingCart, Folder, Present, Star, Timer } from '@element-plus/icons-vue'
|
||||
import { categoryApi, lotteryApi, articleApi } from '../../api'
|
||||
|
||||
const router = useRouter()
|
||||
const categories = ref<any[]>([])
|
||||
const lotteries = ref<any[]>([])
|
||||
const articles = ref<any[]>([])
|
||||
|
||||
const banners = [
|
||||
{ title: '新品上市', desc: '精选优质商品,限时特惠', btn: '立即选购', link: '/products', icon: shallowRef(Present), bg: 'linear-gradient(135deg, #4e6ef2 0%, #7c5cfc 100%)' },
|
||||
{ title: '幸运抽奖', desc: '参与抽奖赢取好礼', btn: '参与活动', link: '/lotteries', icon: shallowRef(Star), bg: 'linear-gradient(135deg, #f59e0b 0%, #f97316 100%)' },
|
||||
{ title: '限时秒杀', desc: '每日精选,超值优惠', btn: '查看详情', link: '/products', icon: shallowRef(Timer), bg: 'linear-gradient(135deg, #10b981 0%, #059669 100%)' },
|
||||
]
|
||||
|
||||
function goCategory(id: number) {
|
||||
router.push({ path: '/products', query: { category_id: String(id) } })
|
||||
}
|
||||
|
||||
function formatDate(date: string) {
|
||||
if (!date) return '-'
|
||||
const d = new Date(date)
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [catRes, lotRes, artRes]: any[] = await Promise.all([
|
||||
categoryApi.list(),
|
||||
lotteryApi.list(),
|
||||
articleApi.list({ page: 1, page_size: 5 }),
|
||||
])
|
||||
categories.value = (catRes.data || []).slice(0, 6)
|
||||
lotteries.value = (lotRes.data || []).slice(0, 3)
|
||||
articles.value = (artRes.data || []).slice(0, 5)
|
||||
} catch (e) {
|
||||
console.log('加载失败:', e)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.home-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.banner-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.banner-slide {
|
||||
height: 280px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 60px;
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.banner-content h2 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.banner-content p {
|
||||
font-size: 16px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.banner-btn {
|
||||
padding: 12px 32px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.banner-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
background: #2d2d44;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.action-item:hover {
|
||||
background: #35355a;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-item span {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.section-head h2 {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.section-more {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.section-more:hover {
|
||||
color: #4e6ef2;
|
||||
}
|
||||
|
||||
.category-scroll {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.category-card {
|
||||
flex-shrink: 0;
|
||||
width: 140px;
|
||||
padding: 16px;
|
||||
background: #2d2d44;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.category-card:hover {
|
||||
background: #35355a;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(78, 110, 242, 0.12);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #4e6ef2;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.category-count {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.lottery-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.lottery-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
background: #2d2d44;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.lottery-card:hover {
|
||||
background: #35355a;
|
||||
}
|
||||
|
||||
.lottery-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #4e6ef2, #7c5cfc);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lottery-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.lottery-info h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.lottery-info p {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lottery-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lottery-status {
|
||||
font-size: 12px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.lottery-status.active {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.lottery-btn {
|
||||
padding: 6px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
background: #4e6ef2;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.lottery-btn:hover {
|
||||
background: #5a7af5;
|
||||
}
|
||||
|
||||
.article-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.article-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: #2d2d44;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.article-item:hover {
|
||||
background: #35355a;
|
||||
}
|
||||
|
||||
.article-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #10b981;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.article-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.article-info h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin-bottom: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.article-info p {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.article-date {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.el-carousel__indicators--inside) {
|
||||
bottom: 12px;
|
||||
}
|
||||
|
||||
:deep(.el-carousel__indicator--horizontal .el-carousel__button) {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
:deep(.el-carousel__indicator--horizontal.is-active .el-carousel__button) {
|
||||
width: 24px;
|
||||
border-radius: 4px;
|
||||
background: #4e6ef2;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.banner-slide {
|
||||
height: 200px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.banner-content h2 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
flex: 1 1 45%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,35 +1,28 @@
|
||||
<template>
|
||||
<div class="dashboard-page">
|
||||
<h2 class="page-title">{{ $t('supplier.dashboard') }}</h2>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon pending"><el-icon><Clock /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<p class="stat-value">{{ stats.pending }}</p>
|
||||
<p class="stat-label">待处理订单</p>
|
||||
</div>
|
||||
<div class="stat-cards">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon pending"><el-icon><Clock /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<p class="stat-value">{{ stats.pending }}</p>
|
||||
<p class="stat-label">待处理订单</p>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon total"><el-icon><List /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<p class="stat-value">{{ stats.total }}</p>
|
||||
<p class="stat-label">总订单数</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon total"><el-icon><List /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<p class="stat-value">{{ stats.total }}</p>
|
||||
<p class="stat-label">总订单数</p>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon products"><el-icon><Goods /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<p class="stat-value">{{ stats.products }}</p>
|
||||
<p class="stat-label">商品数量</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon products"><el-icon><Goods /></el-icon></div>
|
||||
<div class="stat-info">
|
||||
<p class="stat-value">{{ stats.products }}</p>
|
||||
<p class="stat-label">商品数量</p>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -42,17 +35,28 @@ const stats = ref({ pending: '--', total: '--', products: '--' })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res: any = await supplierApi.getOrders()
|
||||
const orders = res.data || []
|
||||
const [ordersRes, inventoryRes]: any[] = await Promise.all([
|
||||
supplierApi.getOrders(),
|
||||
supplierApi.getInventory(),
|
||||
])
|
||||
const orders = ordersRes.data || []
|
||||
const inventory = inventoryRes.data || []
|
||||
stats.value.pending = orders.filter((o: any) => o.status === 'pending_confirm').length
|
||||
stats.value.total = orders.length
|
||||
stats.value.products = inventory.length
|
||||
} catch {}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dashboard-page { padding: 0; }
|
||||
.page-title { font-size: 24px; font-weight: 600; color: #fff; margin-bottom: 24px; }
|
||||
|
||||
.stat-cards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #2d2d44;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
@@ -61,7 +65,10 @@ onMounted(async () => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex: 1 1 200px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
@@ -70,6 +77,7 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.stat-icon.pending { background: rgba(245, 158, 11, 0.15); color: #f59e0b; }
|
||||
.stat-icon.total { background: rgba(78, 110, 242, 0.15); color: #4e6ef2; }
|
||||
@@ -77,4 +85,17 @@ onMounted(async () => {
|
||||
.stat-info { flex: 1; }
|
||||
.stat-value { font-size: 28px; font-weight: 700; color: #fff; }
|
||||
.stat-label { font-size: 14px; color: rgba(255, 255, 255, 0.5); margin-top: 4px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stat-cards { gap: 12px; }
|
||||
.stat-card {
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
flex: 1 1 calc(50% - 6px);
|
||||
min-width: 140px;
|
||||
}
|
||||
.stat-icon { width: 40px; height: 40px; font-size: 20px; border-radius: 10px; }
|
||||
.stat-value { font-size: 22px; }
|
||||
.stat-label { font-size: 12px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<template>
|
||||
<div class="inventory-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">{{ $t('supplier.inventoryManagement') }}</h2>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
<el-table :data="inventory">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
@@ -27,14 +24,22 @@ import { supplierApi } from '../../api'
|
||||
const inventory = ref<any[]>([])
|
||||
|
||||
async function fetchInventory() {
|
||||
const res: any = await supplierApi.getInventory()
|
||||
inventory.value = (res.data || []).map((i: any) => ({ ...i, newQty: i.quantity }))
|
||||
try {
|
||||
const res: any = await supplierApi.getInventory()
|
||||
inventory.value = (res.data || []).map((i: any) => ({ ...i, newQty: i.quantity }))
|
||||
} catch {
|
||||
ElMessage.error('获取库存失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function updateQty(row: any) {
|
||||
await supplierApi.updateInventory(row.id, { quantity: row.newQty })
|
||||
ElMessage.success('Updated')
|
||||
await fetchInventory()
|
||||
try {
|
||||
await supplierApi.updateInventory(row.id, { quantity: row.newQty })
|
||||
ElMessage.success('更新成功')
|
||||
await fetchInventory()
|
||||
} catch {
|
||||
ElMessage.error('更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchInventory)
|
||||
@@ -42,7 +47,5 @@ onMounted(fetchInventory)
|
||||
|
||||
<style scoped lang="scss">
|
||||
.inventory-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
||||
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
|
||||
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
|
||||
</style>
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
<template>
|
||||
<div class="orders-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">{{ $t('supplier.orderManagement') }}</h2>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
<el-table :data="orders">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="total_amount" label="Amount" width="100"><template #default="{ row }">¥{{ row.total_amount }}</template></el-table-column>
|
||||
<el-table-column prop="status" label="Status" width="120">
|
||||
<el-table-column prop="total_amount" label="金额" width="100"><template #default="{ row }">¥{{ row.total_amount }}</template></el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Created" width="180">
|
||||
<el-table-column label="创建时间" width="180">
|
||||
<template #default="{ row }">{{ formatDate(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Actions" width="200">
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status === 'pending_confirm'" link type="primary" size="small" @click="confirmOrder(row.id)">{{ $t('supplier.confirmOrder') }}</el-button>
|
||||
<el-button v-if="row.status === 'pending_ship'" link type="success" size="small" @click="openShip(row)">{{ $t('supplier.shipOrder') }}</el-button>
|
||||
@@ -71,14 +68,22 @@ function formatDate(date: string) {
|
||||
}
|
||||
|
||||
async function fetchOrders() {
|
||||
const res: any = await supplierApi.getOrders()
|
||||
orders.value = res.data || []
|
||||
try {
|
||||
const res: any = await supplierApi.getOrders()
|
||||
orders.value = res.data || []
|
||||
} catch {
|
||||
ElMessage.error('获取订单失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmOrder(id: number) {
|
||||
await supplierApi.confirmOrder(id)
|
||||
ElMessage.success('Order confirmed')
|
||||
await fetchOrders()
|
||||
try {
|
||||
await supplierApi.confirmOrder(id)
|
||||
ElMessage.success('订单已确认')
|
||||
await fetchOrders()
|
||||
} catch {
|
||||
ElMessage.error('确认失败')
|
||||
}
|
||||
}
|
||||
|
||||
function openShip(row: any) {
|
||||
@@ -91,11 +96,15 @@ async function shipOrder() {
|
||||
ElMessage.warning('请输入快递单号')
|
||||
return
|
||||
}
|
||||
await supplierApi.shipOrder(shipOrderId.value, shipForm)
|
||||
ElMessage.success('Order shipped')
|
||||
showShip.value = false
|
||||
Object.assign(shipForm, { tracking_number: '', shipping_photo: '', express_photo: '', customs_photo: '' })
|
||||
await fetchOrders()
|
||||
try {
|
||||
await supplierApi.shipOrder(shipOrderId.value, shipForm)
|
||||
ElMessage.success('发货成功')
|
||||
showShip.value = false
|
||||
Object.assign(shipForm, { tracking_number: '', shipping_photo: '', express_photo: '', customs_photo: '' })
|
||||
await fetchOrders()
|
||||
} catch {
|
||||
ElMessage.error('发货失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchOrders)
|
||||
@@ -103,7 +112,5 @@ onMounted(fetchOrders)
|
||||
|
||||
<style scoped lang="scss">
|
||||
.orders-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
||||
.page-title { font-size: 24px; font-weight: 600; color: #fff; }
|
||||
.table-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 20px; }
|
||||
</style>
|
||||
|
||||
@@ -445,8 +445,6 @@ onMounted(() => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cart-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
|
||||
+737
-165
@@ -1,218 +1,790 @@
|
||||
<template>
|
||||
<div class="home-page">
|
||||
<section class="hero">
|
||||
<div class="hero-content">
|
||||
<h1>Discover Premium Products</h1>
|
||||
<p>Find the best products from trusted suppliers worldwide</p>
|
||||
<el-button type="primary" size="large" @click="$router.push('/products')">
|
||||
{{ $t('common.products') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">{{ $t('product.category') }}</h2>
|
||||
</div>
|
||||
<div class="category-grid">
|
||||
<div v-for="cat in categories" :key="cat.id" class="category-card" @click="goCategory(cat.id)">
|
||||
<div class="category-icon">
|
||||
<el-icon size="32"><Folder /></el-icon>
|
||||
</div>
|
||||
<span class="category-name">{{ cat.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">{{ $t('common.products') }}</h2>
|
||||
<el-button link @click="$router.push('/products')">{{ $t('common.search') }} →</el-button>
|
||||
</div>
|
||||
<div class="product-grid">
|
||||
<div v-for="p in products" :key="p.id" class="product-card" @click="$router.push(`/products/${p.id}`)">
|
||||
<div class="product-image">
|
||||
<el-image v-if="getFirstImage(p.images)" :src="getFirstImage(p.images)" fit="cover" lazy>
|
||||
<template #error>
|
||||
<div class="image-placeholder">
|
||||
<el-icon size="48"><Goods /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
<div v-else class="image-placeholder">
|
||||
<el-icon size="48"><Goods /></el-icon>
|
||||
<div class="banner-section">
|
||||
<el-carousel height="280px" :interval="5000" arrow="hover" trigger="click">
|
||||
<el-carousel-item v-for="(banner, index) in banners" :key="index">
|
||||
<div class="banner-slide" :style="{ background: banner.bg_color || banner.bg }">
|
||||
<img v-if="banner.image" :src="banner.image" class="banner-bg-image" />
|
||||
<div class="banner-content">
|
||||
<h2>{{ banner.title }}</h2>
|
||||
<p>{{ banner.desc }}</p>
|
||||
<button class="banner-btn" @click="$router.push(banner.link)">{{ banner.button || banner.btn }}</button>
|
||||
</div>
|
||||
<div class="banner-icon" v-if="banner.icon">
|
||||
<el-icon :size="80"><component :is="banner.icon" /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="product-info">
|
||||
<h3 class="product-name">{{ p.name }}</h3>
|
||||
<p class="product-price">¥{{ p.price }}</p>
|
||||
<div class="product-tags">
|
||||
<el-tag v-if="p.require_credit" size="small" type="warning">{{ $t('product.requireCredit') }}</el-tag>
|
||||
<el-tag v-if="p.credit_reward > 0" size="small" type="success">+{{ p.credit_reward }} {{ $t('product.creditReward') }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</el-carousel-item>
|
||||
</el-carousel>
|
||||
</div>
|
||||
|
||||
<section class="section" v-if="lotteries.length">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">{{ $t('lottery.title') }}</h2>
|
||||
<el-button link @click="$router.push('/lotteries')">View All →</el-button>
|
||||
<div class="quick-actions">
|
||||
<div class="action-item" @click="$router.push('/products')">
|
||||
<div class="action-icon" style="background: rgba(78, 110, 242, 0.15); color: #4e6ef2;">
|
||||
<el-icon size="22"><Goods /></el-icon>
|
||||
</div>
|
||||
<span>商品</span>
|
||||
</div>
|
||||
<div class="lottery-grid">
|
||||
<div class="action-item" @click="$router.push('/lotteries')">
|
||||
<div class="action-icon" style="background: rgba(245, 158, 11, 0.15); color: #f59e0b;">
|
||||
<el-icon size="22"><Trophy /></el-icon>
|
||||
</div>
|
||||
<span>抽奖</span>
|
||||
</div>
|
||||
<div class="action-item" @click="$router.push('/articles')">
|
||||
<div class="action-icon" style="background: rgba(16, 185, 129, 0.15); color: #10b981;">
|
||||
<el-icon size="22"><Document /></el-icon>
|
||||
</div>
|
||||
<span>资讯</span>
|
||||
</div>
|
||||
<div class="action-item" @click="$router.push('/cart')">
|
||||
<div class="action-icon" style="background: rgba(124, 92, 252, 0.15); color: #7c5cfc;">
|
||||
<el-icon size="22"><ShoppingCart /></el-icon>
|
||||
</div>
|
||||
<span>购物车</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section" v-if="lotteries.length">
|
||||
<div class="section-head">
|
||||
<h2>热门活动</h2>
|
||||
</div>
|
||||
<div class="lottery-cards">
|
||||
<div v-for="l in lotteries" :key="l.id" class="lottery-card" @click="$router.push(`/lotteries/${l.id}`)">
|
||||
<h3>{{ l.name }}</h3>
|
||||
<p>{{ l.description }}</p>
|
||||
<el-button type="primary" size="small">{{ $t('lottery.register') }}</el-button>
|
||||
<div class="lottery-icon">
|
||||
<el-icon size="20"><Trophy /></el-icon>
|
||||
</div>
|
||||
<div class="lottery-info">
|
||||
<h3>{{ l.name }}</h3>
|
||||
<p>{{ l.description }}</p>
|
||||
</div>
|
||||
<div class="lottery-right">
|
||||
<span class="lottery-status" :class="l.status === 'active' ? 'active' : ''">
|
||||
{{ l.status === 'active' ? '进行中' : '已结束' }}
|
||||
</span>
|
||||
<button class="lottery-btn">参与</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="section" v-if="articles.length">
|
||||
<div class="section-head">
|
||||
<h2>最新资讯</h2>
|
||||
<span class="section-more" @click="$router.push('/articles')">查看全部 →</span>
|
||||
</div>
|
||||
<div class="article-list">
|
||||
<div v-for="a in articles" :key="a.id" class="article-item" @click="$router.push(`/articles/${a.id}`)">
|
||||
<div class="article-icon">
|
||||
<el-icon size="18"><Document /></el-icon>
|
||||
</div>
|
||||
<div class="article-info">
|
||||
<h3>{{ a.title }}</h3>
|
||||
<p>{{ a.summary || a.content?.slice(0, 60) }}...</p>
|
||||
</div>
|
||||
<span class="article-date">{{ formatDate(a.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section" v-if="categories.length">
|
||||
<div class="section-head">
|
||||
<h2>热门分类</h2>
|
||||
<span class="section-more" @click="$router.push('/products')">查看全部 →</span>
|
||||
</div>
|
||||
<div class="category-list">
|
||||
<div v-for="cat in categories" :key="cat.id" class="category-item" @click="goCategory(cat.id)">
|
||||
<div class="category-icon">
|
||||
<el-icon size="18"><Folder /></el-icon>
|
||||
</div>
|
||||
<div class="category-info">
|
||||
<h3>{{ cat.name }}</h3>
|
||||
<p>{{ cat.product_count || 0 }} 件商品</p>
|
||||
</div>
|
||||
<el-icon class="category-arrow"><ArrowRight /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, shallowRef } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Folder, Goods } from '@element-plus/icons-vue'
|
||||
import { categoryApi, productApi, lotteryApi } from '../../api'
|
||||
import { getFirstImage } from '../../utils/image'
|
||||
import { Goods, Trophy, Document, ShoppingCart, Folder, Present, Star, Timer, ArrowRight } from '@element-plus/icons-vue'
|
||||
import { categoryApi, lotteryApi, articleApi, bannerApi } from '../../api'
|
||||
|
||||
const router = useRouter()
|
||||
const categories = ref<any[]>([])
|
||||
const products = ref<any[]>([])
|
||||
const lotteries = ref<any[]>([])
|
||||
const articles = ref<any[]>([])
|
||||
const banners = ref<any[]>([])
|
||||
|
||||
const defaultBanners = [
|
||||
{ title: '新品上市', desc: '精选优质商品,限时特惠', btn: '立即选购', link: '/products', icon: shallowRef(Present), bg: 'linear-gradient(135deg, #4e6ef2 0%, #7c5cfc 100%)' },
|
||||
{ title: '幸运抽奖', desc: '参与抽奖赢取好礼', btn: '参与活动', link: '/lotteries', icon: shallowRef(Star), bg: 'linear-gradient(135deg, #f59e0b 0%, #f97316 100%)' },
|
||||
{ title: '限时秒杀', desc: '每日精选,超值优惠', btn: '查看详情', link: '/products', icon: shallowRef(Timer), bg: 'linear-gradient(135deg, #10b981 0%, #059669 100%)' },
|
||||
]
|
||||
|
||||
function goCategory(id: number) {
|
||||
router.push({ path: '/products', query: { category_id: String(id) } })
|
||||
}
|
||||
|
||||
function formatDate(date: string) {
|
||||
if (!date) return '-'
|
||||
const d = new Date(date)
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [catRes, prodRes, lotRes]: any[] = await Promise.all([
|
||||
const [catRes, lotRes, artRes, banRes]: any[] = await Promise.all([
|
||||
categoryApi.list(),
|
||||
productApi.list({ page: 1, page_size: 8 }),
|
||||
lotteryApi.list(),
|
||||
articleApi.list({ page: 1, page_size: 5 }),
|
||||
bannerApi.list(),
|
||||
])
|
||||
categories.value = catRes.data || []
|
||||
products.value = prodRes.data || []
|
||||
categories.value = (catRes.data || []).slice(0, 6)
|
||||
lotteries.value = (lotRes.data || []).slice(0, 3)
|
||||
} catch {}
|
||||
articles.value = (artRes.data || []).slice(0, 5)
|
||||
|
||||
if (banRes.data && banRes.data.length > 0) {
|
||||
banners.value = banRes.data
|
||||
} else {
|
||||
banners.value = defaultBanners as any
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('加载失败:', e)
|
||||
banners.value = defaultBanners as any
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.hero {
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
color: #fff;
|
||||
padding: 80px 24px;
|
||||
text-align: center;
|
||||
|
||||
h1 { font-size: 42px; font-weight: 700; margin-bottom: 16px; }
|
||||
p { font-size: 18px; opacity: 0.8; margin-bottom: 32px; }
|
||||
.home-page {
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.section {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
.banner-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.category-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.category-card {
|
||||
background: #2d2d44;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover { transform: translateY(-4px); border-color: rgba(78, 110, 242, 0.3); }
|
||||
}
|
||||
|
||||
.category-icon { margin-bottom: 8px; color: #4e6ef2; }
|
||||
.category-name { font-size: 14px; font-weight: 500; color: rgba(255, 255, 255, 0.85); }
|
||||
|
||||
.product-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
background: #2d2d44;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover { transform: translateY(-4px); border-color: rgba(78, 110, 242, 0.3); }
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
height: 200px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
.banner-slide {
|
||||
height: 280px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 60px;
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.banner-bg-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.banner-content h2 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
margin-bottom: 12px;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.banner-content p {
|
||||
font-size: 16px;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
margin-bottom: 24px;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.banner-btn {
|
||||
padding: 12px 32px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.banner-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 28px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
background: #2d2d44;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.action-item:hover {
|
||||
background: #35355a;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
|
||||
.el-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.image-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.product-info { padding: 16px; }
|
||||
.product-name { font-size: 16px; font-weight: 500; margin-bottom: 8px; color: rgba(255, 255, 255, 0.9); }
|
||||
.product-price { font-size: 20px; font-weight: 700; color: #4e6ef2; margin-bottom: 8px; }
|
||||
.product-tags { display: flex; gap: 8px; }
|
||||
.action-item span {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.lottery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
.section {
|
||||
margin-bottom: 28px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.section-head h2 {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.section-more {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.section-more:hover {
|
||||
color: #4e6ef2;
|
||||
}
|
||||
|
||||
.category-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.category-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
background: #2d2d44;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.category-item:hover {
|
||||
background: #35355a;
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #f59e0b, #f97316);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.category-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.category-info h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.category-info p {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.category-arrow {
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lottery-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.lottery-card {
|
||||
background: linear-gradient(135deg, #4e6ef2, #7c5cfc);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
background: #2d2d44;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
transition: all 0.15s;
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
h3 { margin-bottom: 8px; }
|
||||
p { opacity: 0.8; margin-bottom: 16px; font-size: 14px; }
|
||||
.lottery-card:hover {
|
||||
background: #35355a;
|
||||
}
|
||||
|
||||
.lottery-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #4e6ef2, #7c5cfc);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lottery-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.lottery-info h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.lottery-info p {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lottery-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lottery-status {
|
||||
font-size: 12px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.lottery-status.active {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.lottery-btn {
|
||||
padding: 6px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
background: #4e6ef2;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.lottery-btn:hover {
|
||||
background: #5a7af5;
|
||||
}
|
||||
|
||||
.article-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.article-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: #2d2d44;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.article-item:hover {
|
||||
background: #35355a;
|
||||
}
|
||||
|
||||
.article-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #10b981;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.article-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.article-info h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin-bottom: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.article-info p {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.article-date {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.el-carousel) {
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
:deep(.el-carousel__container) {
|
||||
position: relative !important;
|
||||
height: 280px;
|
||||
}
|
||||
|
||||
:deep(.el-carousel__arrow) {
|
||||
top: 50% !important;
|
||||
transform: translateY(-50%) !important;
|
||||
background: rgba(0, 0, 0, 0.3) !important;
|
||||
color: #fff !important;
|
||||
border: none !important;
|
||||
width: 36px !important;
|
||||
height: 36px !important;
|
||||
font-size: 16px !important;
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
:deep(.el-carousel__arrow:hover) {
|
||||
background: rgba(0, 0, 0, 0.5) !important;
|
||||
}
|
||||
|
||||
:deep(.el-carousel__arrow--left) {
|
||||
left: 16px !important;
|
||||
}
|
||||
|
||||
:deep(.el-carousel__arrow--right) {
|
||||
right: 16px !important;
|
||||
}
|
||||
|
||||
:deep(.el-carousel__indicators) {
|
||||
position: absolute !important;
|
||||
bottom: 16px !important;
|
||||
left: 50% !important;
|
||||
transform: translateX(-50%) !important;
|
||||
z-index: 10 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
:deep(.el-carousel__indicator--horizontal .el-carousel__button) {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
width: 24px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
:deep(.el-carousel__indicator--horizontal.is-active .el-carousel__button) {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.home-page {
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.banner-section {
|
||||
margin-bottom: 16px;
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.banner-slide {
|
||||
height: 200px;
|
||||
padding: 0 20px;
|
||||
border-radius: 0;
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.banner-content h2 {
|
||||
font-size: 20px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.banner-content p {
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.banner-btn {
|
||||
padding: 8px 20px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
flex: 1 1 45%;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.action-item span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 20px;
|
||||
padding: 0 12px;
|
||||
width: 100% !important;
|
||||
max-width: 100vw !important;
|
||||
box-sizing: border-box !important;
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-head h2 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.section-more {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.category-list {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.category-item {
|
||||
padding: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.category-info h3 {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.category-info p {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.lottery-cards {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.lottery-card {
|
||||
padding: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.lottery-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.lottery-info h3 {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.lottery-info p {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.lottery-status {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.lottery-btn {
|
||||
padding: 5px 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.article-list {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.article-item {
|
||||
padding: 10px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.article-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.article-info h3 {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.article-info p {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.article-date {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
:deep(.el-carousel) {
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
:deep(.el-carousel__container) {
|
||||
height: 200px !important;
|
||||
}
|
||||
|
||||
:deep(.el-carousel__arrow) {
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
font-size: 14px !important;
|
||||
top: 50% !important;
|
||||
transform: translateY(-50%) !important;
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
:deep(.el-carousel__arrow--left) {
|
||||
left: 10px !important;
|
||||
}
|
||||
|
||||
:deep(.el-carousel__arrow--right) {
|
||||
right: 10px !important;
|
||||
}
|
||||
|
||||
:deep(.el-carousel__indicators) {
|
||||
bottom: 12px !important;
|
||||
position: absolute !important;
|
||||
left: 50% !important;
|
||||
transform: translateX(-50%) !important;
|
||||
z-index: 10 !important;
|
||||
}
|
||||
|
||||
:deep(.el-carousel__indicator--horizontal .el-carousel__button) {
|
||||
width: 20px;
|
||||
height: 3px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,9 +15,11 @@
|
||||
<el-table-column label="创建时间" width="180">
|
||||
<template #default="{ row }">{{ formatDate(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('common.edit')" width="120">
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="$router.push(`/orders/${row.id}`)">查看</el-button>
|
||||
<el-button v-if="row.status === 'pending_payment' || row.status === 'pending_confirm'" link type="warning" size="small" @click="cancelOrder(row.id)">取消</el-button>
|
||||
<el-button v-if="row.status === 'shipped'" link type="success" size="small" @click="confirmReceipt(row.id)">确认收货</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -27,6 +29,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { orderApi } from '../../api'
|
||||
import BackNav from '../../components/BackNav.vue'
|
||||
|
||||
@@ -54,10 +57,34 @@ function formatDate(date: string) {
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const res: any = await orderApi.list()
|
||||
orders.value = res.data || []
|
||||
})
|
||||
async function fetchOrders() {
|
||||
try {
|
||||
const res: any = await orderApi.list()
|
||||
orders.value = res.data || []
|
||||
} catch {
|
||||
ElMessage.error('获取订单失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelOrder(id: number) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要取消该订单吗?', '提示', { type: 'warning' })
|
||||
await orderApi.cancel(id)
|
||||
ElMessage.success('订单已取消')
|
||||
fetchOrders()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function confirmReceipt(id: number) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认已收到商品?', '提示', { type: 'info' })
|
||||
await orderApi.confirmReceipt(id)
|
||||
ElMessage.success('已确认收货')
|
||||
fetchOrders()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
onMounted(fetchOrders)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -94,8 +94,7 @@ onMounted(async () => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.products-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<div class="ticket-detail-page">
|
||||
<BackNav />
|
||||
<div v-if="ticket" class="ticket-card">
|
||||
<div class="ticket-header">
|
||||
<h2>{{ ticket.subject }}</h2>
|
||||
<el-tag :type="statusType(ticket.status)">{{ statusText(ticket.status) }}</el-tag>
|
||||
</div>
|
||||
<div class="ticket-meta">
|
||||
<span>分类:{{ ticket.category }}</span>
|
||||
<span>创建时间:{{ formatDate(ticket.created_at) }}</span>
|
||||
</div>
|
||||
<div class="ticket-content">
|
||||
<p>{{ ticket.content }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="ticket.reply" class="reply-section">
|
||||
<h3>回复</h3>
|
||||
<div class="reply-content" v-html="formatReply(ticket.reply)"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="ticket.status !== 'resolved' && ticket.status !== 'closed'" class="reply-form">
|
||||
<h3>追加回复</h3>
|
||||
<el-input v-model="replyContent" type="textarea" :rows="4" placeholder="请输入回复内容" />
|
||||
<el-button type="primary" @click="submitReply" :loading="submitting" style="margin-top: 12px">提交回复</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ticketApi } from '../../api'
|
||||
import BackNav from '../../components/BackNav.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const ticket = ref<any>(null)
|
||||
const replyContent = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
const statusMap: Record<string, string> = {
|
||||
pending: '待处理', processing: '处理中', resolved: '已解决', closed: '已关闭',
|
||||
}
|
||||
const statusTypeMap: Record<string, string> = {
|
||||
pending: 'warning', processing: 'primary', resolved: 'success', closed: 'info',
|
||||
}
|
||||
function statusText(s: string) { return statusMap[s] || s }
|
||||
function statusType(s: string) { return statusTypeMap[s] || 'info' }
|
||||
|
||||
function formatDate(date: string) {
|
||||
if (!date) return '-'
|
||||
const d = new Date(date)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatReply(reply: string) {
|
||||
return reply.replace(/\n/g, '<br>').replace(/---/g, '<hr style="border-color: rgba(255,255,255,0.1); margin: 12px 0;">')
|
||||
}
|
||||
|
||||
async function fetchTicket() {
|
||||
try {
|
||||
const id = Number(route.params.id)
|
||||
const res: any = await ticketApi.getById(id)
|
||||
ticket.value = res.data
|
||||
} catch {
|
||||
ElMessage.error('获取工单失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function submitReply() {
|
||||
if (!replyContent.value.trim()) {
|
||||
ElMessage.warning('请输入回复内容')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await ticketApi.reply(Number(route.params.id), { content: replyContent.value })
|
||||
ElMessage.success('回复成功')
|
||||
replyContent.value = ''
|
||||
fetchTicket()
|
||||
} catch {
|
||||
ElMessage.error('回复失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchTicket)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ticket-detail-page { padding: 0; }
|
||||
|
||||
.ticket-card {
|
||||
background: #2d2d44;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.ticket-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ticket-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ticket-content {
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 8px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.reply-section {
|
||||
margin-bottom: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
|
||||
h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.reply-content {
|
||||
padding: 16px;
|
||||
background: rgba(78, 110, 242, 0.08);
|
||||
border-radius: 8px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.reply-form {
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
|
||||
h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="table-card">
|
||||
<el-table :data="tickets">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="title" :label="$t('ticket.subject')" />
|
||||
<el-table-column prop="subject" :label="$t('ticket.subject')" />
|
||||
<el-table-column prop="status" :label="$t('ticket.status')" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag>
|
||||
@@ -16,6 +16,11 @@
|
||||
<el-table-column label="创建时间" width="180">
|
||||
<template #default="{ row }">{{ formatDate(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="$router.push(`/tickets/${row.id}`)">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user