feat: 重构后台路由并使用嵌套路由;添加图片上传功能
- 拆分 AdminView 为多个子页面组件,使用嵌套路由 - 拆分 MerchantView 为多个子页面组件,使用嵌套路由 - 创建 AdminLayout 和 MerchantLayout 布局组件 - 修复编译错误:IconSend 重复导入、IconDatabase 不存在 - 修复 HomeView 缺失 onMounted 导入 - 添加文件上传后端接口和静态资源映射 - 为商品和轮播图添加图片上传功能(支持预览、清除)
This commit is contained in:
@@ -1,25 +1,37 @@
|
||||
package com.maternalmall.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
private final AuthInterceptor authInterceptor;
|
||||
|
||||
@Value("${app.upload-path:./uploads}")
|
||||
private String uploadPath;
|
||||
|
||||
public WebMvcConfig(AuthInterceptor authInterceptor) {
|
||||
this.authInterceptor = authInterceptor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(authInterceptor).addPathPatterns("/**");
|
||||
registry.addInterceptor(authInterceptor).addPathPatterns("/**")
|
||||
.excludePathPatterns("/uploads/**");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/**").allowedOrigins("*").allowedMethods("*").allowedHeaders("*");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
registry.addResourceHandler("/uploads/**")
|
||||
.addResourceLocations("file:" + uploadPath + "/");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.maternalmall.controller;
|
||||
|
||||
import com.maternalmall.common.ApiResponse;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
public class FileUploadController {
|
||||
|
||||
@Value("${app.upload-path:./uploads}")
|
||||
private String uploadPath;
|
||||
|
||||
@Value("${app.upload-url-prefix:/uploads}")
|
||||
private String uploadUrlPrefix;
|
||||
|
||||
@PostMapping("/upload")
|
||||
public ApiResponse<String> uploadFile(@RequestParam("file") MultipartFile file) {
|
||||
if (file.isEmpty()) {
|
||||
return ApiResponse.error("请选择要上传的文件");
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建上传目录
|
||||
Path uploadDir = Paths.get(uploadPath);
|
||||
if (!Files.exists(uploadDir)) {
|
||||
Files.createDirectories(uploadDir);
|
||||
}
|
||||
|
||||
// 生成唯一文件名
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
String extension = "";
|
||||
if (originalFilename != null && originalFilename.contains(".")) {
|
||||
extension = originalFilename.substring(originalFilename.lastIndexOf("."));
|
||||
}
|
||||
String newFilename = UUID.randomUUID().toString().replace("-", "") + extension;
|
||||
|
||||
// 保存文件
|
||||
Path filePath = uploadDir.resolve(newFilename);
|
||||
file.transferTo(filePath.toFile());
|
||||
|
||||
// 返回文件URL
|
||||
String fileUrl = uploadUrlPrefix + "/" + newFilename;
|
||||
return ApiResponse.success(fileUrl);
|
||||
|
||||
} catch (IOException e) {
|
||||
return ApiResponse.error("文件上传失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/upload/image")
|
||||
public ApiResponse<String> uploadImage(@RequestParam("file") MultipartFile file) {
|
||||
if (file.isEmpty()) {
|
||||
return ApiResponse.error("请选择要上传的图片");
|
||||
}
|
||||
|
||||
// 检查文件类型
|
||||
String contentType = file.getContentType();
|
||||
if (contentType == null || !contentType.startsWith("image/")) {
|
||||
return ApiResponse.error("只能上传图片文件");
|
||||
}
|
||||
|
||||
return uploadFile(file);
|
||||
}
|
||||
}
|
||||
@@ -61,5 +61,21 @@ export const api = {
|
||||
adminDeleteProduct: (id) => http.delete(`/api/admin/products/${id}`),
|
||||
adminReviews: () => http.get('/api/admin/reviews'),
|
||||
adminLogistics: () => http.get('/api/admin/logistics'),
|
||||
adminInventory: () => http.get('/api/admin/inventory')
|
||||
adminInventory: () => http.get('/api/admin/inventory'),
|
||||
|
||||
// File Upload
|
||||
uploadImage: (file) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return http.post('/api/upload/image', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
},
|
||||
uploadFile: (file) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return http.post('/api/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,30 @@ import CartView from '../views/CartView.vue'
|
||||
import OrdersView from '../views/OrdersView.vue'
|
||||
import FavoritesView from '../views/FavoritesView.vue'
|
||||
import ProfileView from '../views/ProfileView.vue'
|
||||
import AdminView from '../views/AdminView.vue'
|
||||
import MerchantView from '../views/MerchantView.vue'
|
||||
|
||||
// Admin Layout & Pages
|
||||
import AdminLayout from '../views/admin/AdminLayout.vue'
|
||||
import AdminOverview from '../views/admin/AdminOverview.vue'
|
||||
import AdminOrders from '../views/admin/AdminOrders.vue'
|
||||
import AdminRisk from '../views/admin/AdminRisk.vue'
|
||||
import AdminProducts from '../views/admin/AdminProducts.vue'
|
||||
import AdminAudit from '../views/admin/AdminAudit.vue'
|
||||
import AdminUsers from '../views/admin/AdminUsers.vue'
|
||||
import AdminBanners from '../views/admin/AdminBanners.vue'
|
||||
import AdminReviews from '../views/admin/AdminReviews.vue'
|
||||
import AdminLogistics from '../views/admin/AdminLogistics.vue'
|
||||
import AdminInventory from '../views/admin/AdminInventory.vue'
|
||||
import AdminProfile from '../views/admin/AdminProfile.vue'
|
||||
|
||||
// Merchant Layout & Pages
|
||||
import MerchantLayout from '../views/merchant/MerchantLayout.vue'
|
||||
import MerchantOverview from '../views/merchant/MerchantOverview.vue'
|
||||
import MerchantProducts from '../views/merchant/MerchantProducts.vue'
|
||||
import MerchantOrders from '../views/merchant/MerchantOrders.vue'
|
||||
import MerchantReviews from '../views/merchant/MerchantReviews.vue'
|
||||
import MerchantLogistics from '../views/merchant/MerchantLogistics.vue'
|
||||
import MerchantInventory from '../views/merchant/MerchantInventory.vue'
|
||||
import MerchantProfile from '../views/merchant/MerchantProfile.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', redirect: '/products' },
|
||||
@@ -16,9 +38,43 @@ const routes = [
|
||||
{ path: '/favorites', component: FavoritesView },
|
||||
{ path: '/profile', component: ProfileView },
|
||||
{ path: '/login', component: LoginView },
|
||||
{ path: '/admin', component: AdminView },
|
||||
{ path: '/merchant', component: MerchantView },
|
||||
{ path: '/customer', redirect: '/products' }
|
||||
{ path: '/customer', redirect: '/products' },
|
||||
|
||||
// Admin Routes
|
||||
{
|
||||
path: '/admin',
|
||||
component: AdminLayout,
|
||||
redirect: '/admin/overview',
|
||||
children: [
|
||||
{ path: 'overview', name: 'admin-overview', component: AdminOverview },
|
||||
{ path: 'orders', name: 'admin-orders', component: AdminOrders },
|
||||
{ path: 'risk', name: 'admin-risk', component: AdminRisk },
|
||||
{ path: 'products', name: 'admin-products', component: AdminProducts },
|
||||
{ path: 'audit', name: 'admin-audit', component: AdminAudit },
|
||||
{ path: 'users', name: 'admin-users', component: AdminUsers },
|
||||
{ path: 'banners', name: 'admin-banners', component: AdminBanners },
|
||||
{ path: 'reviews', name: 'admin-reviews', component: AdminReviews },
|
||||
{ path: 'logistics', name: 'admin-logistics', component: AdminLogistics },
|
||||
{ path: 'inventory', name: 'admin-inventory', component: AdminInventory },
|
||||
{ path: 'profile', name: 'admin-profile', component: AdminProfile }
|
||||
]
|
||||
},
|
||||
|
||||
// Merchant Routes
|
||||
{
|
||||
path: '/merchant',
|
||||
component: MerchantLayout,
|
||||
redirect: '/merchant/overview',
|
||||
children: [
|
||||
{ path: 'overview', name: 'merchant-overview', component: MerchantOverview },
|
||||
{ path: 'products', name: 'merchant-products', component: MerchantProducts },
|
||||
{ path: 'orders', name: 'merchant-orders', component: MerchantOrders },
|
||||
{ path: 'reviews', name: 'merchant-reviews', component: MerchantReviews },
|
||||
{ path: 'logistics', name: 'merchant-logistics', component: MerchantLogistics },
|
||||
{ path: 'inventory', name: 'merchant-inventory', component: MerchantInventory },
|
||||
{ path: 'profile', name: 'merchant-profile', component: MerchantProfile }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
@@ -26,4 +82,4 @@ const router = createRouter({
|
||||
routes
|
||||
})
|
||||
|
||||
export default router
|
||||
export default router
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,34 +1,104 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<a-space style="width: 100%; justify-content: space-between; margin-bottom: 12px">
|
||||
<h2>萌贝母婴商城 - 购物车</h2>
|
||||
<a-space>
|
||||
<a-button @click="goProducts">继续购物</a-button>
|
||||
<a-button @click="goOrders">我的订单</a-button>
|
||||
<a-button @click="goFavorites">我的收藏</a-button>
|
||||
<a-button @click="goProfile">个人信息</a-button>
|
||||
<a-button @click="logout">退出</a-button>
|
||||
</a-space>
|
||||
</a-space>
|
||||
<div class="cart-container">
|
||||
<div class="cart-header">
|
||||
<div class="header-left">
|
||||
<a-avatar :size="48" style="background: linear-gradient(135deg, #ff7eb3 0%, #ff758c 100%)">
|
||||
<icon-shopping-cart :size="24" />
|
||||
</a-avatar>
|
||||
<div class="header-title">
|
||||
<h1>购物车</h1>
|
||||
<span class="item-count">共 {{ cart.length }} 件商品</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a-button shape="round" @click="goProducts">
|
||||
<template #icon><icon-arrow-left /></template>
|
||||
继续购物
|
||||
</a-button>
|
||||
<a-button shape="round" @click="goOrders">
|
||||
<template #icon><icon-file /></template>
|
||||
我的订单
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-card title="购物车结算">
|
||||
<a-space style="margin-bottom: 10px">
|
||||
<a-input v-model="address" style="width: 320px" placeholder="收货地址" />
|
||||
<a-button type="primary" @click="checkout">结算购物车</a-button>
|
||||
<a-button @click="loadCart">刷新</a-button>
|
||||
</a-space>
|
||||
|
||||
<a-table :columns="cartColumns" :data="cart" :pagination="false">
|
||||
<template #actions="{ record }">
|
||||
<a-button size="mini" status="danger" @click="removeCart(record.productId)">移除</a-button>
|
||||
<a-card :bordered="false" class="cart-content">
|
||||
<a-empty v-if="cart.length === 0" description="购物车空空如也">
|
||||
<template #image>
|
||||
<icon-shopping-cart :size="80" style="color: #e0e0e0" />
|
||||
</template>
|
||||
</a-table>
|
||||
<a-button type="primary" shape="round" @click="goProducts">去逛逛</a-button>
|
||||
</a-empty>
|
||||
|
||||
<template v-else>
|
||||
<div class="address-section">
|
||||
<div class="section-header">
|
||||
<icon-location :size="20" style="color: #ff758c" />
|
||||
<span>收货地址</span>
|
||||
</div>
|
||||
<a-input v-model="address" placeholder="请输入详细收货地址" allow-clear>
|
||||
<template #prefix>
|
||||
<icon-home />
|
||||
</template>
|
||||
</a-input>
|
||||
</div>
|
||||
|
||||
<div class="cart-items">
|
||||
<div class="cart-item" v-for="item in cart" :key="item.productId">
|
||||
<div class="item-image">
|
||||
<img :src="item.imageUrl || 'https://picsum.photos/100/100?baby=' + item.productId" />
|
||||
</div>
|
||||
<div class="item-info">
|
||||
<div class="item-name">{{ item.productName }}</div>
|
||||
<div class="item-category">
|
||||
<a-tag size="small" color="pink">{{ item.category }}</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-price">
|
||||
<span class="price-label">单价</span>
|
||||
<span class="price-value">¥{{ item.unitPrice }}</span>
|
||||
</div>
|
||||
<div class="item-quantity">
|
||||
<span class="quantity-label">数量</span>
|
||||
<a-input-number :model-value="item.quantity" :min="1" :max="item.stock" size="small" disabled />
|
||||
</div>
|
||||
<div class="item-subtotal">
|
||||
<span class="subtotal-label">小计</span>
|
||||
<span class="subtotal-value">¥{{ item.subtotal }}</span>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<a-popconfirm content="确定要从购物车移除此商品吗?" @ok="removeCart(item.productId)">
|
||||
<a-button type="text" status="danger" size="small">
|
||||
<template #icon><icon-delete /></template>
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cart-footer">
|
||||
<div class="total-info">
|
||||
<span class="total-label">商品总额</span>
|
||||
<span class="total-value">¥{{ totalAmount }}</span>
|
||||
</div>
|
||||
<a-space size="large">
|
||||
<a-button shape="round" @click="loadCart">
|
||||
<template #icon><icon-refresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button type="primary" shape="round" size="large" @click="checkout">
|
||||
<template #icon><icon-check /></template>
|
||||
结算购物车 ({{ cart.length }}件)
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</template>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { api } from '../api'
|
||||
@@ -37,24 +107,14 @@ import { useUserStore } from '../stores/user'
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const cart = ref([])
|
||||
const address = ref(localStorage.getItem('customer_address') || '辽宁省大连市高新区')
|
||||
const address = ref(localStorage.getItem('customer_address') || '')
|
||||
|
||||
const cartColumns = [
|
||||
{ title: '商品名称', dataIndex: 'productName' },
|
||||
{ title: '单价', dataIndex: 'unitPrice' },
|
||||
{ title: '数量', dataIndex: 'quantity' },
|
||||
{ title: '小计', dataIndex: 'subtotal' },
|
||||
{ title: '操作', slotName: 'actions' }
|
||||
]
|
||||
const totalAmount = computed(() => {
|
||||
return cart.value.reduce((sum, item) => sum + item.subtotal, 0).toFixed(2)
|
||||
})
|
||||
|
||||
const goProducts = () => router.push('/products')
|
||||
const goOrders = () => router.push('/orders')
|
||||
const goFavorites = () => router.push('/favorites')
|
||||
const goProfile = () => router.push('/profile')
|
||||
const logout = async () => {
|
||||
await userStore.logout()
|
||||
router.replace('/login')
|
||||
}
|
||||
|
||||
const loadCart = async () => {
|
||||
cart.value = await api.customerCartViews()
|
||||
@@ -74,6 +134,8 @@ const checkout = async () => {
|
||||
router.push('/orders')
|
||||
}
|
||||
|
||||
import { IconApps, IconArrowLeft, IconFile, IconLocation, IconHome, IconDelete, IconRefresh, IconCheck } from '@arco-design/web-vue/es/icon'
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await userStore.fetchMe()
|
||||
@@ -84,3 +146,169 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cart-container {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #fff5f8 0%, #f0f5ff 100%);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.cart-header {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-title h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #ff7eb3 0%, #ff758c 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.item-count {
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cart-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.address-section {
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.cart-items {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.cart-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background: #fafafa;
|
||||
margin-bottom: 12px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.cart-item:hover {
|
||||
background: #fff0f5;
|
||||
}
|
||||
|
||||
.item-image img {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
flex: 1;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.item-category {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.item-price,
|
||||
.item-quantity,
|
||||
.item-subtotal {
|
||||
text-align: center;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.price-label,
|
||||
.quantity-label,
|
||||
.subtotal-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.price-value {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.subtotal-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.cart-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.total-info {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.total-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.total-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
:deep(.arco-btn-primary) {
|
||||
background: linear-gradient(135deg, #ff7eb3 0%, #ff758c 100%);
|
||||
border-color: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,59 +1,361 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<a-space style="width: 100%; justify-content: space-between; margin-bottom: 12px">
|
||||
<h2>萌贝母婴商城</h2>
|
||||
<a-space>
|
||||
<a-button v-if="!userStore.loggedIn" type="primary" @click="goLogin">登录</a-button>
|
||||
<template v-else>
|
||||
<span>{{ userStore.profile?.nickname }} ({{ userStore.role }})</span>
|
||||
<a-button v-if="userStore.role === 'CUSTOMER'" @click="goCart">购物车</a-button>
|
||||
<a-button v-if="userStore.role === 'CUSTOMER'" @click="goOrders">我的订单</a-button>
|
||||
<a-button v-if="userStore.role === 'CUSTOMER'" @click="goFavorites">我的收藏</a-button>
|
||||
<a-button v-if="userStore.role === 'CUSTOMER'" @click="goProfile">个人信息</a-button>
|
||||
<a-button v-if="userStore.role === 'ADMIN' || userStore.role === 'MERCHANT'" type="primary" @click="goConsole">进入工作台</a-button>
|
||||
<a-button @click="logout">退出</a-button>
|
||||
<div class="home-container">
|
||||
<a-space class="header-bar" wrap>
|
||||
<div class="logo">
|
||||
<a-avatar :size="40" style="background: linear-gradient(135deg, #ff7eb3 0%, #ff758c 100%)">
|
||||
<icon-heart />
|
||||
</a-avatar>
|
||||
<span class="logo-text">萌贝母婴</span>
|
||||
</div>
|
||||
<a-input-search v-model="keyword" placeholder="搜索母婴好物..." search-button @search="loadProducts" class="search-box" />
|
||||
<div class="header-actions">
|
||||
<a-badge :count="cartCount" :max-count="99">
|
||||
<a-button v-if="userStore.role === 'CUSTOMER'" shape="round" @click="goCart">
|
||||
<template #icon><icon-apps /></template>
|
||||
购物车
|
||||
</a-button>
|
||||
</a-badge>
|
||||
<template v-if="userStore.loggedIn">
|
||||
<a-dropdown>
|
||||
<a-button shape="round">
|
||||
<template #icon><icon-user /></template>
|
||||
{{ userStore.profile?.nickname || userStore.profile?.username }}
|
||||
</a-button>
|
||||
<template #content>
|
||||
<a-doption @click="goOrders" v-if="userStore.role === 'CUSTOMER'">
|
||||
<template #icon><icon-file /></template>
|
||||
我的订单
|
||||
</a-doption>
|
||||
<a-doption @click="goFavorites" v-if="userStore.role === 'CUSTOMER'">
|
||||
<template #icon><icon-heart /></template>
|
||||
我的收藏
|
||||
</a-doption>
|
||||
<a-doption @click="goProfile">
|
||||
<template #icon><icon-settings /></template>
|
||||
个人信息
|
||||
</a-doption>
|
||||
<a-doption @click="goConsole" v-if="userStore.role === 'ADMIN' || userStore.role === 'MERCHANT'">
|
||||
<template #icon><icon-dashboard /></template>
|
||||
管理后台
|
||||
</a-doption>
|
||||
<a-doption @click="logout">
|
||||
<template #icon><icon-export /></template>
|
||||
退出登录
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
</a-space>
|
||||
<a-button v-else type="primary" shape="round" @click="goLogin">
|
||||
<template #icon><icon-export /></template>
|
||||
登录
|
||||
</a-button>
|
||||
</div>
|
||||
</a-space>
|
||||
|
||||
<a-carousel auto-play style="height: 220px; margin-bottom: 16px">
|
||||
<a-carousel-item v-for="b in banners" :key="b.id">
|
||||
<img :src="b.imageUrl" style="width: 100%; height: 220px; object-fit: cover" />
|
||||
</a-carousel-item>
|
||||
</a-carousel>
|
||||
|
||||
<a-input-search v-model="keyword" placeholder="搜索商品" search-button @search="loadProducts" />
|
||||
<a-grid :cols="{ xs: 1, md: 3 }" :col-gap="12" :row-gap="12" style="margin-top: 16px">
|
||||
<a-grid-item v-for="p in products" :key="p.id">
|
||||
<a-card>
|
||||
<template #title>{{ p.name }}</template>
|
||||
<div>分类:{{ p.category || '未分类' }}</div>
|
||||
<div>价格:¥{{ p.price }}</div>
|
||||
<div>库存:{{ p.stock }}</div>
|
||||
<div style="margin-top: 8px">
|
||||
<a-space>
|
||||
<a-button size="small" @click="addCart(p.id)" :disabled="userStore.role !== 'CUSTOMER'">加入购物车</a-button>
|
||||
<a-button size="small" type="primary" @click="buyNow(p.id)" :disabled="userStore.role !== 'CUSTOMER'">立即购买</a-button>
|
||||
<a-button size="small" @click="addFavorite(p.id)" :disabled="userStore.role !== 'CUSTOMER'">收藏</a-button>
|
||||
</a-space>
|
||||
<div class="banner-section">
|
||||
<a-carousel auto-play :interval="4000" style="height: 280px; border-radius: 16px; overflow: hidden; box-shadow: 0 8px 24px rgba(0,0,0,0.12)">
|
||||
<a-carousel-item v-for="b in banners" :key="b.id">
|
||||
<img :src="b.imageUrl" style="width: 100%; height: 280px; object-fit: cover" />
|
||||
</a-carousel-item>
|
||||
<a-carousel-item v-if="banners.length === 0">
|
||||
<div class="banner-placeholder">
|
||||
<icon-safe :size="80" style="color: #ff758c" />
|
||||
<p>萌贝母婴商城 · 呵护每一次成长</p>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-grid-item>
|
||||
</a-grid>
|
||||
</a-carousel-item>
|
||||
</a-carousel>
|
||||
</div>
|
||||
|
||||
<a-card v-if="userStore.role === 'CUSTOMER'" title="前台购买操作" style="margin-top: 16px">
|
||||
<a-space>
|
||||
<a-input v-model="address" style="width: 320px" placeholder="收货地址" />
|
||||
<span>你可以在此页立即购买,也可以去购物车结算。</span>
|
||||
<div class="category-section">
|
||||
<a-card :bordered="false" class="category-card">
|
||||
<a-space wrap>
|
||||
<a-tag
|
||||
v-for="cat in categories"
|
||||
:key="cat"
|
||||
:color="selectedCategory === cat ? 'pink' : 'gray'"
|
||||
checkable
|
||||
:checked="selectedCategory === cat"
|
||||
@click="selectCategory(cat)"
|
||||
class="category-tag"
|
||||
>
|
||||
{{ cat }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<div class="products-section">
|
||||
<a-empty v-if="filteredProducts.length === 0 && !loading" description="暂无相关商品" />
|
||||
<a-grid :cols="{ xs: 1, sm: 2, md: 3, lg: 4 }" :col-gap="16" :row-gap="16">
|
||||
<a-grid-item v-for="p in filteredProducts" :key="p.id">
|
||||
<a-card class="product-card" :bordered="false" hoverable @click="showProductDetail(p)">
|
||||
<template #cover>
|
||||
<div class="product-image">
|
||||
<img :src="p.imageUrl || 'https://picsum.photos/300/200?baby=' + p.id" style="width: 100%; height: 180px; object-fit: cover" />
|
||||
<div class="product-badge" v-if="p.stock < 10">库存紧张</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="product-info">
|
||||
<div class="product-category">
|
||||
<a-tag size="small" color="pink">{{ p.category || '未分类' }}</a-tag>
|
||||
</div>
|
||||
<div class="product-name">{{ p.name }}</div>
|
||||
<div class="product-price">
|
||||
<span class="price-symbol">¥</span>
|
||||
<span class="price-value">{{ p.price }}</span>
|
||||
</div>
|
||||
<div class="product-actions">
|
||||
<a-space>
|
||||
<a-button size="small" shape="round" @click.stop="addCart(p.id)" :disabled="userStore.role !== 'CUSTOMER'">
|
||||
<template #icon><icon-plus /></template>
|
||||
加购
|
||||
</a-button>
|
||||
<a-button size="small" type="primary" shape="round" @click.stop="buyNow(p.id)" :disabled="userStore.role !== 'CUSTOMER'">
|
||||
立即购买
|
||||
</a-button>
|
||||
<a-button size="small" shape="round" @click.stop="addFavorite(p.id)" :disabled="userStore.role !== 'CUSTOMER'">
|
||||
<icon-heart :style="{ color: isFavorite(p.id) ? '#ff4d4f' : '#b37feb' }" />
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-grid-item>
|
||||
</a-grid>
|
||||
</div>
|
||||
|
||||
<a-drawer v-model:visible="detailVisible" title="商品详情" :width="480" unmountOnClose>
|
||||
<template v-if="currentProduct">
|
||||
<img :src="currentProduct.imageUrl || 'https://picsum.photos/400/300?baby=' + currentProduct.id" style="width: 100%; border-radius: 8px; margin-bottom: 16px" />
|
||||
<a-descriptions :column="1" bordered>
|
||||
<a-descriptions-item label="商品名称">{{ currentProduct.name }}</a-descriptions-item>
|
||||
<a-descriptions-item label="分类">{{ currentProduct.category || '未分类' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="价格">¥{{ currentProduct.price }}</a-descriptions-item>
|
||||
<a-descriptions-item label="库存">
|
||||
<a-tag :color="currentProduct.stock > 20 ? 'green' : currentProduct.stock > 0 ? 'orange' : 'red'">
|
||||
{{ currentProduct.stock }}件
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="描述">{{ currentProduct.description || '暂无描述' }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<a-space style="width: 100%; justify-content: center; margin-top: 20px">
|
||||
<a-button type="primary" size="large" shape="round" @click="addCart(currentProduct.id)" :disabled="userStore.role !== 'CUSTOMER'">
|
||||
<template #icon><icon-apps /></template>
|
||||
加入购物车
|
||||
</a-button>
|
||||
<a-button type="primary" size="large" shape="round" status="success" @click="buyNow(currentProduct.id)" :disabled="userStore.role !== 'CUSTOMER'">
|
||||
立即购买
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-drawer>
|
||||
|
||||
<a-affix :bottom="20" style="right: 20px">
|
||||
<a-space direction="vertical">
|
||||
<a-button shape="circle" size="large" @click="scrollToTop">
|
||||
<template #icon><icon-arrow-up /></template>
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-card>
|
||||
</a-affix>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-container {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #fff5f8 0%, #f0f5ff 100%);
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
background: white;
|
||||
padding: 12px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #ff7eb3 0%, #ff758c 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
width: 360px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.banner-section {
|
||||
max-width: 1200px;
|
||||
margin: 20px auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.banner-placeholder {
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
background: linear-gradient(135deg, #fff0f5 0%, #f0f5ff 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.banner-placeholder p {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #ff758c;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.category-section {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.category-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.category-tag {
|
||||
cursor: pointer;
|
||||
padding: 6px 16px;
|
||||
border-radius: 20px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.category-tag:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.products-section {
|
||||
max-width: 1200px;
|
||||
margin: 20px auto;
|
||||
padding: 0 16px 40px;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.product-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 24px rgba(255, 117, 140, 0.2);
|
||||
}
|
||||
|
||||
.product-image {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-image img {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.product-card:hover .product-image img {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.product-badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: linear-gradient(135deg, #ff9a44 0%, #ff6b6b 100%);
|
||||
color: white;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.product-category {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.price-symbol {
|
||||
font-size: 14px;
|
||||
color: #ff4d4f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.price-value {
|
||||
font-size: 24px;
|
||||
color: #ff4d4f;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.product-actions {
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
:deep(.arco-card-body) {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
:deep(.arco-input-search .arco-btn) {
|
||||
background: linear-gradient(135deg, #ff7eb3 0%, #ff758c 100%);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
:deep(.arco-tag-checkable:checked) {
|
||||
background: linear-gradient(135deg, #ff7eb3 0%, #ff758c 100%);
|
||||
color: white;
|
||||
border-color: transparent;
|
||||
}
|
||||
</style>
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { IconHeart, IconApps, IconUser, IconSettings, IconDashboard, IconExport, IconPlus, IconArrowUp, IconCheckCircle, IconFile, IconSafe } from '@arco-design/web-vue/es/icon'
|
||||
import { api } from '../api'
|
||||
import { useUserStore } from '../stores/user'
|
||||
|
||||
@@ -61,56 +363,116 @@ const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const keyword = ref('')
|
||||
const products = ref([])
|
||||
const cartCount = ref(0)
|
||||
const banners = ref([])
|
||||
const address = ref('辽宁省大连市高新区')
|
||||
const products = ref([])
|
||||
const categories = ref(['全部', '奶粉', '尿裤', '玩具', '辅食', '服饰', '用品'])
|
||||
const selectedCategory = ref('全部')
|
||||
const currentProduct = ref(null)
|
||||
const detailVisible = ref(false)
|
||||
const loading = ref(false)
|
||||
const favorites = ref([])
|
||||
|
||||
const filteredProducts = computed(() => {
|
||||
if (selectedCategory.value === '全部') return products.value
|
||||
return products.value.filter(p => p.category === selectedCategory.value)
|
||||
})
|
||||
|
||||
const goLogin = () => router.push('/login')
|
||||
const goCart = () => router.push('/cart')
|
||||
const goOrders = () => router.push('/orders')
|
||||
const goFavorites = () => router.push('/favorites')
|
||||
const goProfile = () => router.push('/profile')
|
||||
const goConsole = () => {
|
||||
if (userStore.role === 'ADMIN') return router.push('/admin')
|
||||
if (userStore.role === 'MERCHANT') return router.push('/merchant')
|
||||
return router.push('/products')
|
||||
if (userStore.role === 'ADMIN') router.push('/admin')
|
||||
else if (userStore.role === 'MERCHANT') router.push('/merchant')
|
||||
}
|
||||
|
||||
const goLogin = () => router.push('/login')
|
||||
const logout = async () => {
|
||||
await userStore.logout()
|
||||
Message.success('已退出')
|
||||
}
|
||||
|
||||
const loadProducts = async () => {
|
||||
products.value = await api.products(keyword.value)
|
||||
router.replace('/login')
|
||||
}
|
||||
|
||||
const loadBanners = async () => {
|
||||
banners.value = await api.banners()
|
||||
if (banners.value.length === 0) {
|
||||
banners.value = [{ id: 0, imageUrl: 'https://picsum.photos/1200/220?baby=1' }]
|
||||
try {
|
||||
banners.value = await api.banners()
|
||||
} catch (e) {
|
||||
console.error('加载轮播图失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadProducts = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
products.value = await api.products(keyword.value)
|
||||
} catch (e) {
|
||||
console.error('加载商品失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadFavorites = async () => {
|
||||
try {
|
||||
favorites.value = await api.customerFavorites()
|
||||
} catch (e) {
|
||||
console.error('加载收藏失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadCartCount = async () => {
|
||||
try {
|
||||
const cart = await api.customerCart()
|
||||
cartCount.value = cart.length
|
||||
} catch (e) {
|
||||
console.error('加载购物车失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
const selectCategory = (cat) => {
|
||||
selectedCategory.value = cat
|
||||
}
|
||||
|
||||
const showProductDetail = (product) => {
|
||||
currentProduct.value = product
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
const addCart = async (productId) => {
|
||||
if (userStore.role !== 'CUSTOMER') return Message.warning('请使用顾客账号操作')
|
||||
await api.addCart({ productId, quantity: 1 })
|
||||
Message.success('已加入购物车')
|
||||
try {
|
||||
await api.addCart({ productId, quantity: 1 })
|
||||
Message.success('已加入购物车')
|
||||
await loadCartCount()
|
||||
} catch (e) {
|
||||
Message.error('添加失败')
|
||||
}
|
||||
}
|
||||
|
||||
const buyNow = async (productId) => {
|
||||
if (userStore.role !== 'CUSTOMER') return Message.warning('请使用顾客账号操作')
|
||||
if (!address.value) return Message.warning('请先填写收货地址')
|
||||
localStorage.setItem('customer_address', address.value)
|
||||
await api.customerBuyNow({ productId, quantity: 1, address: address.value })
|
||||
Message.success('购买成功')
|
||||
router.push('/orders')
|
||||
try {
|
||||
await api.customerBuyNow({ productId, quantity: 1, address: '' })
|
||||
Message.success('购买成功')
|
||||
router.push('/orders')
|
||||
} catch (e) {
|
||||
Message.error('购买失败')
|
||||
}
|
||||
}
|
||||
|
||||
const addFavorite = async (productId) => {
|
||||
if (userStore.role !== 'CUSTOMER') return Message.warning('请使用顾客账号操作')
|
||||
await api.addFavorite({ productId })
|
||||
Message.success('已收藏')
|
||||
try {
|
||||
await api.addFavorite({ productId })
|
||||
Message.success('已收藏')
|
||||
await loadFavorites()
|
||||
} catch (e) {
|
||||
Message.error('收藏失败')
|
||||
}
|
||||
}
|
||||
|
||||
const isFavorite = (productId) => {
|
||||
return favorites.value.some(f => f.productId === productId)
|
||||
}
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -121,7 +483,7 @@ onMounted(async () => {
|
||||
try {
|
||||
await userStore.fetchMe()
|
||||
if (userStore.role === 'CUSTOMER') {
|
||||
address.value = localStorage.getItem('customer_address') || address.value
|
||||
await Promise.all([loadFavorites(), loadCartCount()])
|
||||
}
|
||||
} catch (e) {
|
||||
userStore.profile = null
|
||||
|
||||
@@ -1,48 +1,135 @@
|
||||
<template>
|
||||
<a-layout style="min-height: 100vh">
|
||||
<a-layout-sider :width="220" theme="dark" collapsible>
|
||||
<div style="height: 56px; display:flex; align-items:center; justify-content:center; color:#fff; font-weight:600;">商家后台</div>
|
||||
<a-menu :selected-keys="[active]" @menu-item-click="(k)=>active=k">
|
||||
<a-menu-item key="overview">数据概览</a-menu-item>
|
||||
<a-menu-item key="products">商品管理</a-menu-item>
|
||||
<a-menu-item key="orders">订单管理</a-menu-item>
|
||||
<a-menu-item key="reviews">评价管理</a-menu-item>
|
||||
<a-menu-item key="logistics">物流管理</a-menu-item>
|
||||
<a-menu-item key="inventory">库存管理</a-menu-item>
|
||||
<a-menu-item key="profile">个人信息</a-menu-item>
|
||||
<a-layout style="min-height: 100vh" class="merchant-layout">
|
||||
<a-layout-sider :width="240" theme="dark" collapsible :collapsed-width="64" class="merchant-sider">
|
||||
<div class="sider-header">
|
||||
<a-avatar :size="36" style="background: linear-gradient(135deg, #52c41a 0%, #73d13d 100%)">
|
||||
<icon-home />
|
||||
</a-avatar>
|
||||
<span class="sider-title" v-if="!$attrs.collapsed">商家后台</span>
|
||||
</div>
|
||||
<a-menu :selected-keys="[active]" @menu-item-click="(k)=>active=k" theme="dark">
|
||||
<a-menu-item key="overview">
|
||||
<template #icon><icon-dashboard /></template>
|
||||
<span v-if="!$attrs.collapsed">数据概览</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="products">
|
||||
<template #icon><icon-apps /></template>
|
||||
<span v-if="!$attrs.collapsed">商品管理</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="orders">
|
||||
<template #icon><icon-file /></template>
|
||||
<span v-if="!$attrs.collapsed">订单管理</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="reviews">
|
||||
<template #icon><icon-message /></template>
|
||||
<span v-if="!$attrs.collapsed">评价管理</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="logistics">
|
||||
<template #icon><icon-send /></template>
|
||||
<span v-if="!$attrs.collapsed">物流管理</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="inventory">
|
||||
<template #icon><icon-database /></template>
|
||||
<span v-if="!$attrs.collapsed">库存管理</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="profile">
|
||||
<template #icon><icon-user /></template>
|
||||
<span v-if="!$attrs.collapsed">个人信息</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</a-layout-sider>
|
||||
<a-layout>
|
||||
<a-layout-header style="background:#fff; display:flex; justify-content:space-between; align-items:center; padding:0 16px;">
|
||||
<strong>{{ titleMap[active] }}</strong>
|
||||
<a-space>
|
||||
<a-button @click="goHome">去商城</a-button>
|
||||
<a-button status="danger" @click="logout">退出</a-button>
|
||||
</a-space>
|
||||
</a-layout-header>
|
||||
<a-layout-content style="padding:16px;">
|
||||
<a-card v-if="active==='overview'" title="经营概览">
|
||||
<a-space style="margin-bottom: 16px">
|
||||
<a-tag color="arcoblue" size="large">订单量: {{ overview.orderCount || 0 }}</a-tag>
|
||||
<a-tag color="green" size="large">销售额: ¥{{ (overview.salesAmount || 0).toLocaleString() }}</a-tag>
|
||||
<a-button @click="loadOverview">刷新</a-button>
|
||||
<a-layout class="merchant-main">
|
||||
<a-layout-header class="merchant-header">
|
||||
<div class="header-left">
|
||||
<span class="page-title">{{ titleMap[active] }}</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<a-space>
|
||||
<a-badge :count="pendingRefundCount">
|
||||
<a-button shape="circle" @click="active = 'orders'">
|
||||
<template #icon><icon-notification /></template>
|
||||
</a-button>
|
||||
</a-badge>
|
||||
<a-button @click="goHome">
|
||||
<template #icon><icon-home /></template>
|
||||
去商城
|
||||
</a-button>
|
||||
<a-dropdown>
|
||||
<a-button shape="circle">
|
||||
<template #icon><icon-user /></template>
|
||||
</a-button>
|
||||
<template #content>
|
||||
<a-doption @click="active = 'profile'">
|
||||
<template #icon><icon-settings /></template>
|
||||
个人信息
|
||||
</a-doption>
|
||||
<a-doption @click="logout">
|
||||
<template #icon><icon-export /></template>
|
||||
退出登录
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
</div>
|
||||
</a-layout-header>
|
||||
<a-layout-content class="merchant-content">
|
||||
<a-card v-if="active==='overview'" :bordered="false" class="overview-card">
|
||||
<div class="stats-row">
|
||||
<a-card class="stat-card" :bordered="false">
|
||||
<div class="stat-icon orders">
|
||||
<icon-file :size="32" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ overview.orderCount || 0 }}</div>
|
||||
<div class="stat-label">订单总量</div>
|
||||
</div>
|
||||
</a-card>
|
||||
<a-card class="stat-card" :bordered="false">
|
||||
<div class="stat-icon sales">
|
||||
<icon-gift :size="32" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">¥{{ (overview.salesAmount || 0).toLocaleString() }}</div>
|
||||
<div class="stat-label">销售额</div>
|
||||
</div>
|
||||
</a-card>
|
||||
<a-card class="stat-card" :bordered="false">
|
||||
<div class="stat-icon products">
|
||||
<icon-apps :size="32" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ products.length }}</div>
|
||||
<div class="stat-label">在售商品</div>
|
||||
</div>
|
||||
</a-card>
|
||||
<a-card class="stat-card" :bordered="false">
|
||||
<div class="stat-icon reviews">
|
||||
<icon-message :size="32" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ reviews.length }}</div>
|
||||
<div class="stat-label">累计评价</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<a-row :gutter="16" style="margin-top: 20px">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-card title="品类占比" size="small">
|
||||
<div ref="categoryChartRef" style="height: 250px;"></div>
|
||||
<div ref="categoryChartRef" style="height: 280px;"></div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-card title="热销排行 Top10" size="small">
|
||||
<div ref="hotChartRef" style="height: 250px;"></div>
|
||||
<div ref="hotChartRef" style="height: 280px;"></div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-divider />
|
||||
<a-card title="通知公告" size="small">
|
||||
<a-timeline v-if="overview.notifications && overview.notifications.length">
|
||||
<a-timeline-item v-for="(note, idx) in overview.notifications" :key="idx" :color="idx === 0 ? 'red' : 'blue'">
|
||||
<a-timeline-item v-for="(note, idx) in overview.notifications" :key="idx" :color="idx === 0 ? 'red' : 'green'">
|
||||
{{ note }}
|
||||
</a-timeline-item>
|
||||
</a-timeline>
|
||||
@@ -50,81 +137,210 @@
|
||||
</a-card>
|
||||
</a-card>
|
||||
|
||||
<a-card v-if="active==='products'" title="商品管理">
|
||||
<a-space style="margin-bottom: 10px">
|
||||
<a-button type="primary" @click="openProductModal()">新增商品</a-button>
|
||||
<a-button @click="loadProducts">刷新</a-button>
|
||||
</a-space>
|
||||
<a-table :columns="productColumns" :data="products" :pagination="{ pageSize: 8 }">
|
||||
<a-card v-if="active==='products'" :bordered="false" class="content-card">
|
||||
<template #title>
|
||||
<a-space>
|
||||
<span>商品管理</span>
|
||||
<a-tag color="green">共 {{ products.length }} 件商品</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="openProductModal()">
|
||||
<template #icon><icon-plus /></template>
|
||||
新增商品
|
||||
</a-button>
|
||||
<a-button @click="loadProducts">
|
||||
<template #icon><icon-refresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-table :columns="productColumns" :data="products" :pagination="{ pageSize: 8 }" :bordered="false">
|
||||
<template #cover="{ record }">
|
||||
<img :src="record.imageUrl || 'https://picsum.photos/60/60?baby=' + record.id" style="width: 48px; height: 48px; border-radius: 8px; object-fit: cover" />
|
||||
</template>
|
||||
<template #actions="{ record }">
|
||||
<a-space>
|
||||
<a-button size="mini" type="primary" @click="openProductModal(record)">编辑</a-button>
|
||||
<a-button size="mini" status="danger" @click="delProduct(record.id)">删除</a-button>
|
||||
<a-button size="mini" type="primary" shape="round" @click="openProductModal(record)">编辑</a-button>
|
||||
<a-popconfirm content="确定删除此商品吗?" @ok="delProduct(record.id)">
|
||||
<a-button size="mini" status="danger" shape="round">删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<a-card v-if="active==='orders'" title="订单管理">
|
||||
<a-table :columns="orderColumns" :data="orders" :pagination="{ pageSize: 8 }">
|
||||
<a-card v-if="active==='orders'" :bordered="false" class="content-card">
|
||||
<template #title>
|
||||
<a-space>
|
||||
<span>订单管理</span>
|
||||
<a-tag color="arcoblue">共 {{ orders.length }} 个订单</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-button @click="loadOrders">
|
||||
<template #icon><icon-refresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</template>
|
||||
<a-table :columns="orderColumns" :data="orders" :pagination="{ pageSize: 8 }" :bordered="false">
|
||||
<template #status="{ record }">
|
||||
<a-tag :color="getOrderStatusColor(record.status)">{{ getOrderStatusText(record.status) }}</a-tag>
|
||||
</template>
|
||||
<template #actions="{ record }">
|
||||
<a-space>
|
||||
<a-button size="mini" type="primary" @click="ship(record.id)">发货</a-button>
|
||||
<a-button size="mini" @click="refund(record.id, true)">同意退款</a-button>
|
||||
<a-button size="mini" @click="openOrderLogistics(record.id)">查看物流</a-button>
|
||||
<a-button size="mini" type="primary" shape="round" @click="ship(record.id)" v-if="record.status === 'PAID'">
|
||||
<template #icon><icon-send /></template>
|
||||
发货
|
||||
</a-button>
|
||||
<a-button size="mini" type="primary" shape="round" @click="refund(record.id, true)" v-if="record.status === 'REFUND_REQUESTED'">
|
||||
<template #icon><icon-check-circle /></template>
|
||||
同意退款
|
||||
</a-button>
|
||||
<a-button size="mini" shape="round" @click="openOrderLogistics(record.id)">
|
||||
<template #icon><icon-send /></template>
|
||||
物流
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<a-card v-if="active==='reviews'" title="评价管理">
|
||||
<a-table :columns="reviewColumns" :data="reviews" :pagination="{ pageSize: 8 }" />
|
||||
</a-card>
|
||||
|
||||
<a-card v-if="active==='logistics'" title="物流管理">
|
||||
<a-table :columns="logisticsColumns" :data="logistics" :pagination="{ pageSize: 8 }" />
|
||||
</a-card>
|
||||
|
||||
<a-card v-if="active==='inventory'" title="库存管理">
|
||||
<a-table :columns="inventoryColumns" :data="inventory" :pagination="{ pageSize: 8 }">
|
||||
<template #actions="{ record }">
|
||||
<a-button size="mini" status="danger" @click="deleteInventory(record.id)">删除</a-button>
|
||||
<a-card v-if="active==='reviews'" :bordered="false" class="content-card">
|
||||
<template #title>
|
||||
<a-space>
|
||||
<span>评价管理</span>
|
||||
<a-tag color="orange">共 {{ reviews.length }} 条评价</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-table :columns="reviewColumns" :data="reviews" :pagination="{ pageSize: 8 }" :bordered="false">
|
||||
<template #rating="{ record }">
|
||||
<a-rate :model-value="record.rating" readonly />
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<a-card v-if="active==='profile'" title="个人信息">
|
||||
<a-form :model="profile" layout="vertical" style="max-width: 480px">
|
||||
<a-form-item label="账号"><a-input v-model="profile.username" disabled /></a-form-item>
|
||||
<a-form-item label="角色"><a-input v-model="profile.role" disabled /></a-form-item>
|
||||
<a-form-item label="昵称"><a-input v-model="profile.nickname" /></a-form-item>
|
||||
<a-form-item label="手机号"><a-input v-model="profile.phone" /></a-form-item>
|
||||
<a-form-item label="地址"><a-input v-model="profile.address" /></a-form-item>
|
||||
<a-button type="primary" @click="saveProfile">保存修改</a-button>
|
||||
<a-card v-if="active==='logistics'" :bordered="false" class="content-card">
|
||||
<template #title>物流管理</template>
|
||||
<a-table :columns="logisticsColumns" :data="logistics" :pagination="{ pageSize: 8 }" :bordered="false" />
|
||||
</a-card>
|
||||
|
||||
<a-card v-if="active==='inventory'" :bordered="false" class="content-card">
|
||||
<template #title>库存管理</template>
|
||||
<template #extra>
|
||||
<a-button @click="loadInventory">
|
||||
<template #icon><icon-refresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</template>
|
||||
<a-table :columns="inventoryColumns" :data="inventory" :pagination="{ pageSize: 8 }" :bordered="false">
|
||||
<template #changeQty="{ record }">
|
||||
<a-tag :color="record.changeQty > 0 ? 'green' : 'red'">
|
||||
{{ record.changeQty > 0 ? '+' : '' }}{{ record.changeQty }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template #actions="{ record }">
|
||||
<a-popconfirm content="确定删除此库存记录吗?" @ok="deleteInventory(record.id)">
|
||||
<a-button size="mini" status="danger" shape="round">删除</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<a-card v-if="active==='profile'" :bordered="false" class="content-card">
|
||||
<template #title>个人信息</template>
|
||||
<a-form :model="profile" layout="vertical" style="max-width: 500px">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="账号">
|
||||
<a-input v-model="profile.username" disabled>
|
||||
<template #prefix><icon-user /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="角色">
|
||||
<a-input v-model="profile.role" disabled>
|
||||
<template #prefix><icon-safe /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="昵称">
|
||||
<a-input v-model="profile.nickname">
|
||||
<template #prefix><icon-relation /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="手机号">
|
||||
<a-input v-model="profile.phone">
|
||||
<template #prefix><icon-phone /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="地址">
|
||||
<a-input v-model="profile.address">
|
||||
<template #prefix><icon-location /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
<a-button type="primary" @click="saveProfile">
|
||||
<template #icon><icon-check /></template>
|
||||
保存修改
|
||||
</a-button>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
|
||||
<a-modal v-model:visible="productModalVisible" :title="productForm.id ? '编辑商品' : '新增商品'" @ok="submitProduct">
|
||||
<a-modal v-model:visible="productModalVisible" :title="productForm.id ? '编辑商品' : '新增商品'" :ok-text="productForm.id ? '保存' : '添加'" @ok="submitProduct" width="600">
|
||||
<a-form :model="productForm" layout="vertical">
|
||||
<a-form-item label="商品名称"><a-input v-model="productForm.name" /></a-form-item>
|
||||
<a-form-item label="分类"><a-input v-model="productForm.category" /></a-form-item>
|
||||
<a-form-item label="描述"><a-input v-model="productForm.description" /></a-form-item>
|
||||
<a-form-item label="价格"><a-input-number v-model="productForm.price" style="width: 100%" /></a-form-item>
|
||||
<a-form-item label="库存"><a-input-number v-model="productForm.stock" style="width: 100%" /></a-form-item>
|
||||
<a-form-item label="图片URL"><a-input v-model="productForm.imageUrl" /></a-form-item>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="16">
|
||||
<a-form-item label="商品名称" required>
|
||||
<a-input v-model="productForm.name" placeholder="请输入商品名称" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="分类">
|
||||
<a-select v-model="productForm.category" placeholder="选择分类">
|
||||
<a-option value="奶粉">奶粉</a-option>
|
||||
<a-option value="尿裤">尿裤</a-option>
|
||||
<a-option value="辅食">辅食</a-option>
|
||||
<a-option value="玩具">玩具</a-option>
|
||||
<a-option value="服饰">服饰</a-option>
|
||||
<a-option value="用品">用品</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="商品描述">
|
||||
<a-textarea v-model="productForm.description" placeholder="请输入商品描述" :rows="3" />
|
||||
</a-form-item>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="价格" required>
|
||||
<a-input-number v-model="productForm.price" :min="0" :precision="2" :step="0.01" style="width: 100%" prefix="¥" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="库存" required>
|
||||
<a-input-number v-model="productForm.stock" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="商品图片">
|
||||
<a-input v-model="productForm.imageUrl" placeholder="请输入图片URL" allow-clear />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<a-modal v-model:visible="logisticsModalVisible" title="订单物流">
|
||||
<a-table :columns="logisticsColumns" :data="filteredLogistics" :pagination="false" />
|
||||
<a-modal v-model:visible="logisticsModalVisible" title="订单物流详情" :footer="null" width="600">
|
||||
<a-table :columns="logisticsColumns" :data="filteredLogistics" :pagination="false" :bordered="false" />
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref, watch, nextTick, onUnmounted } from 'vue'
|
||||
import { onMounted, reactive, ref, watch, nextTick, onUnmounted, computed } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import * as echarts from 'echarts'
|
||||
@@ -158,46 +374,70 @@ const productForm = reactive({ id: null, name: '', category: '', description: ''
|
||||
const profile = reactive({ username: '', role: '', nickname: '', phone: '', address: '' })
|
||||
const filteredLogistics = ref([])
|
||||
|
||||
const pendingRefundCount = computed(() => orders.value.filter(o => o.status === 'REFUND_REQUESTED').length)
|
||||
|
||||
const productColumns = [
|
||||
{ title: 'ID', dataIndex: 'id' },
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '封面', slotName: 'cover', width: 70 },
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '分类', dataIndex: 'category' },
|
||||
{ title: '价格', dataIndex: 'price' },
|
||||
{ title: '库存', dataIndex: 'stock' },
|
||||
{ title: '审核', dataIndex: 'approved' },
|
||||
{ title: '操作', slotName: 'actions' }
|
||||
{ title: '分类', dataIndex: 'category', width: 100 },
|
||||
{ title: '价格', dataIndex: 'price', width: 100 },
|
||||
{ title: '库存', dataIndex: 'stock', width: 80 },
|
||||
{ title: '审核', dataIndex: 'approved', width: 80, render: ({ record }) => h(Tag, { color: record.approved ? 'green' : 'orange' }, () => record.approved ? '已上架' : '待审核') },
|
||||
{ title: '操作', slotName: 'actions', width: 150 }
|
||||
]
|
||||
|
||||
const orderColumns = [
|
||||
{ title: '订单号', dataIndex: 'orderNo' },
|
||||
{ title: '金额', dataIndex: 'totalAmount' },
|
||||
{ title: '状态', dataIndex: 'status' },
|
||||
{ title: '操作', slotName: 'actions' }
|
||||
{ title: '金额', dataIndex: 'totalAmount', render: ({ record }) => `¥${record.totalAmount}` },
|
||||
{ title: '状态', slotName: 'status' },
|
||||
{ title: '下单时间', dataIndex: 'createdAt', render: ({ record }) => formatDate(record.createdAt) },
|
||||
{ title: '操作', slotName: 'actions', width: 200 }
|
||||
]
|
||||
|
||||
const reviewColumns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '订单号', dataIndex: 'orderNo' },
|
||||
{ title: '商品名称', dataIndex: 'productName' },
|
||||
{ title: '评分', dataIndex: 'rating' },
|
||||
{ title: '内容', dataIndex: 'content' },
|
||||
{ title: '时间', dataIndex: 'createdAt' }
|
||||
{ title: '评分', slotName: 'rating', width: 180 },
|
||||
{ title: '内容', dataIndex: 'content', ellipsis: true },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: ({ record }) => formatDate(record.createdAt) }
|
||||
]
|
||||
|
||||
const logisticsColumns = [
|
||||
{ title: '订单号', dataIndex: 'orderNo' },
|
||||
{ title: '状态', dataIndex: 'status' },
|
||||
{ title: '备注', dataIndex: 'note' },
|
||||
{ title: '时间', dataIndex: 'createdAt' }
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160 }
|
||||
]
|
||||
|
||||
const inventoryColumns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '商品名称', dataIndex: 'productName' },
|
||||
{ title: '变动', dataIndex: 'changeQty' },
|
||||
{ title: '变动', slotName: 'changeQty', width: 100 },
|
||||
{ title: '备注', dataIndex: 'note' },
|
||||
{ title: '时间', dataIndex: 'createdAt' },
|
||||
{ title: '操作', slotName: 'actions' }
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160 },
|
||||
{ title: '操作', slotName: 'actions', width: 80 }
|
||||
]
|
||||
|
||||
import { h } from 'vue'
|
||||
import { Tag } from '@arco-design/web-vue'
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return '-'
|
||||
return new Date(date).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const getOrderStatusColor = (status) => {
|
||||
const colors = { 'PENDING_PAYMENT': 'orange', 'PAID': 'blue', 'SHIPPED': 'cyan', 'COMPLETED': 'green', 'REFUND_REQUESTED': 'orange', 'REFUNDED': 'purple', 'CANCELLED': 'gray' }
|
||||
return colors[status] || 'gray'
|
||||
}
|
||||
|
||||
const getOrderStatusText = (status) => {
|
||||
const texts = { 'PENDING_PAYMENT': '待支付', 'PAID': '已支付', 'SHIPPED': '已发货', 'COMPLETED': '已完成', 'REFUND_REQUESTED': '退款中', 'REFUNDED': '已退款', 'CANCELLED': '已取消' }
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
const goHome = () => router.push('/products')
|
||||
const logout = async () => { await userStore.logout(); router.replace('/login') }
|
||||
|
||||
@@ -216,27 +456,25 @@ const initCharts = () => {
|
||||
categoryChart.setOption({
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: { orient: 'vertical', left: 'left' },
|
||||
series: [{ type: 'pie', radius: ['40%', '70%'], data: categoryData }]
|
||||
series: [{ type: 'pie', radius: ['40%', '70%'], data: categoryData, itemStyle: { borderRadius: 8, borderColor: '#fff', borderWidth: 2 } }]
|
||||
})
|
||||
} else if (categoryChartRef.value) {
|
||||
categoryChart = echarts.init(categoryChartRef.value)
|
||||
categoryChart.setOption({
|
||||
categoryChart(categoryChartRef.value)
|
||||
categoryChart = echarts.init.setOption({
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: { orient: 'vertical', left: 'left' },
|
||||
series: [{ type: 'pie', radius: ['40%', '70%'], data: categoryData }]
|
||||
series: [{ type: 'pie', radius: ['40%', '70%'], data: categoryData, itemStyle: { borderRadius: 8, borderColor: '#fff', borderWidth: 2 } }]
|
||||
})
|
||||
}
|
||||
|
||||
const hotEntries = Object.entries(overview.hotProducts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
const hotData = hotEntries.map(([name, value]) => ({ name: name.length > 10 ? name.substring(0, 10) + '...' : name, value }))
|
||||
const hotEntries = Object.entries(overview.hotProducts).sort((a, b) => b[1] - a[1]).slice(0, 10)
|
||||
const hotData = hotEntries.map(([name, value]) => ({ name: name.length > 8 ? name.substring(0, 8) + '...' : name, value }))
|
||||
if (hotChart) {
|
||||
hotChart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'value' },
|
||||
yAxis: { type: 'category', data: hotData.map(d => d.name).reverse() },
|
||||
series: [{ type: 'bar', data: hotData.map(d => d.value).reverse() }]
|
||||
series: [{ type: 'bar', data: hotData.map(d => d.value).reverse(), itemStyle: { borderRadius: [0, 4, 4, 0] } }]
|
||||
})
|
||||
} else if (hotChartRef.value) {
|
||||
hotChart = echarts.init(hotChartRef.value)
|
||||
@@ -244,22 +482,15 @@ const initCharts = () => {
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'value' },
|
||||
yAxis: { type: 'category', data: hotData.map(d => d.name).reverse() },
|
||||
series: [{ type: 'bar', data: hotData.map(d => d.value).reverse() }]
|
||||
series: [{ type: 'bar', data: hotData.map(d => d.value).reverse(), itemStyle: { borderRadius: [0, 4, 4, 0] } }]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleResize = () => {
|
||||
categoryChart?.resize()
|
||||
hotChart?.resize()
|
||||
}
|
||||
const handleResize = () => { categoryChart?.resize(); hotChart?.resize() }
|
||||
|
||||
watch(active, async (v) => {
|
||||
if (v === 'overview') {
|
||||
await loadOverview()
|
||||
await nextTick()
|
||||
initCharts()
|
||||
}
|
||||
if (v === 'overview') { await loadOverview(); await nextTick(); initCharts() }
|
||||
if (v === 'products') return loadProducts()
|
||||
if (v === 'orders') return loadOrders()
|
||||
if (v === 'reviews') return loadReviews()
|
||||
@@ -267,6 +498,12 @@ watch(active, async (v) => {
|
||||
if (v === 'inventory') return loadInventory()
|
||||
})
|
||||
|
||||
const loadProducts = async () => { products.value = await api.merchantProducts() }
|
||||
const loadOrders = async () => { orders.value = await api.merchantOrders() }
|
||||
const loadReviews = async () => { reviews.value = await api.merchantReviews() }
|
||||
const loadLogistics = async () => { logistics.value = await api.merchantLogistics() }
|
||||
const loadInventory = async () => { inventory.value = await api.merchantInventory() }
|
||||
|
||||
const openProductModal = (row = null) => {
|
||||
if (row) {
|
||||
productForm.id = row.id
|
||||
@@ -289,8 +526,9 @@ const openProductModal = (row = null) => {
|
||||
}
|
||||
|
||||
const submitProduct = async () => {
|
||||
if (!productForm.name.trim()) return Message.warning('请输入商品名称')
|
||||
await api.saveMerchantProduct({ ...productForm })
|
||||
Message.success('商品已保存')
|
||||
Message.success(productForm.id ? '商品已更新' : '商品已添加')
|
||||
productModalVisible.value = false
|
||||
await loadProducts()
|
||||
}
|
||||
@@ -302,14 +540,14 @@ const delProduct = async (id) => {
|
||||
}
|
||||
|
||||
const ship = async (id) => {
|
||||
await api.shipOrder(id, { note: '已发货' })
|
||||
await api.shipOrder(id, { note: '已发货,请注意查收' })
|
||||
Message.success('发货成功')
|
||||
await loadOrders()
|
||||
}
|
||||
|
||||
const refund = async (id, agree) => {
|
||||
await api.merchantRefund(id, { agree })
|
||||
Message.success('退款已处理')
|
||||
Message.success(agree ? '已同意退款' : '已拒绝退款')
|
||||
await loadOrders()
|
||||
}
|
||||
|
||||
@@ -339,11 +577,12 @@ const saveProfile = async () => {
|
||||
Message.success('已保存')
|
||||
}
|
||||
|
||||
import { IconHome, IconDashboard, IconApps, IconFile, IconMessage, IconSend, IconStorage, IconUser, IconNotification, IconExport, IconSettings, IconGift, IconPlus, IconRefresh, IconCheckCircle, IconLocation, IconPhone, IconRelation, IconSafe, IconCheck } from '@arco-design/web-vue/es/icon'
|
||||
|
||||
onMounted(async () => {
|
||||
await userStore.fetchMe()
|
||||
if (userStore.role !== 'MERCHANT') return router.replace('/login')
|
||||
await loadOverview()
|
||||
await loadProfile()
|
||||
await Promise.all([loadOverview(), loadProducts(), loadOrders(), loadReviews(), loadLogistics(), loadInventory(), loadProfile()])
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
@@ -353,3 +592,131 @@ onUnmounted(() => {
|
||||
hotChart?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.merchant-layout {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.merchant-sider {
|
||||
background: linear-gradient(180deg, #1f1f1f 0%, #2d2d2d 100%);
|
||||
}
|
||||
|
||||
.sider-header {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.sider-title {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.merchant-main {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.merchant-header {
|
||||
background: #fff;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.merchant-content {
|
||||
padding: 24px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.overview-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #f0f5ff 0%, #fff 100%);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stat-icon.orders {
|
||||
background: linear-gradient(135deg, #1890ff 0%, #69c0ff 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stat-icon.sales {
|
||||
background: linear-gradient(135deg, #52c41a 0%, #95de64 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stat-icon.products {
|
||||
background: linear-gradient(135deg, #722ed1 0%, #b37feb 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stat-icon.reviews {
|
||||
background: linear-gradient(135deg, #fa8c16 0%, #ffc069 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
:deep(.arco-btn-primary) {
|
||||
background: linear-gradient(135deg, #52c41a 0%, #73d13d 100%);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
:deep(.arco-card-header) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-row {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,58 +1,143 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<a-space style="width: 100%; justify-content: space-between; margin-bottom: 12px">
|
||||
<h2>萌贝母婴商城 - 我的订单</h2>
|
||||
<a-space>
|
||||
<a-button @click="goProducts">继续购物</a-button>
|
||||
<a-button @click="goCart">购物车</a-button>
|
||||
<a-button @click="goFavorites">我的收藏</a-button>
|
||||
<a-button @click="goProfile">个人信息</a-button>
|
||||
<a-button @click="loadOrders">刷新</a-button>
|
||||
</a-space>
|
||||
</a-space>
|
||||
<div class="orders-container">
|
||||
<div class="orders-header">
|
||||
<div class="header-left">
|
||||
<a-avatar :size="48" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%)">
|
||||
<icon-file :size="24" />
|
||||
</a-avatar>
|
||||
<div class="header-title">
|
||||
<h1>我的订单</h1>
|
||||
<span class="order-count">共 {{ orders.length }} 个订单</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a-button shape="round" @click="goProducts">
|
||||
<template #icon><icon-apps /></template>
|
||||
继续购物
|
||||
</a-button>
|
||||
<a-button shape="round" @click="goCart">
|
||||
<template #icon><icon-apps /></template>
|
||||
购物车
|
||||
</a-button>
|
||||
<a-button shape="round" @click="loadOrders">
|
||||
<template #icon><icon-refresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-card title="订单列表">
|
||||
<a-table :columns="orderColumns" :data="orders" :pagination="{ pageSize: 8 }">
|
||||
<template #actions="{ record }">
|
||||
<a-space>
|
||||
<a-button size="mini" @click="openLogistics(record.id)">查看物流</a-button>
|
||||
<a-button size="mini" @click="openAddress(record)">修改地址</a-button>
|
||||
<a-button size="mini" @click="openReview(record.id)">评价</a-button>
|
||||
<a-button size="mini" @click="refund(record.id)">退款</a-button>
|
||||
<a-button size="mini" @click="deleteOrder(record.id)">删除</a-button>
|
||||
</a-space>
|
||||
<a-card :bordered="false" class="orders-content">
|
||||
<a-empty v-if="orders.length === 0" description="暂无订单记录">
|
||||
<template #image>
|
||||
<icon-file :size="80" style="color: #e0e0e0" />
|
||||
</template>
|
||||
</a-table>
|
||||
<a-button type="primary" shape="round" @click="goProducts">去下单</a-button>
|
||||
</a-empty>
|
||||
|
||||
<div v-else class="orders-list">
|
||||
<div v-for="order in orders" :key="order.id" class="order-card">
|
||||
<div class="order-header">
|
||||
<div class="order-info">
|
||||
<span class="order-no">订单号:{{ order.orderNo }}</span>
|
||||
<span class="order-date">{{ formatDate(order.createdAt) }}</span>
|
||||
</div>
|
||||
<a-tag :color="getStatusColor(order.status)" size="large">{{ getStatusText(order.status) }}</a-tag>
|
||||
</div>
|
||||
|
||||
<div class="order-items">
|
||||
<div class="order-item-preview">
|
||||
<span class="item-label">商品信息</span>
|
||||
<span class="item-value">查看详情</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="order-footer">
|
||||
<div class="order-address">
|
||||
<icon-location :size="16" />
|
||||
<span>{{ order.address }}</span>
|
||||
</div>
|
||||
<div class="order-total">
|
||||
<span class="total-label">实付金额</span>
|
||||
<span class="total-value">¥{{ order.totalAmount }}</span>
|
||||
</div>
|
||||
<div class="order-actions">
|
||||
<a-space>
|
||||
<a-button size="small" shape="round" @click="openLogistics(order.id)">
|
||||
<template #icon><icon-send /></template>
|
||||
物流
|
||||
</a-button>
|
||||
<a-button size="small" shape="round" @click="openAddress(order)" v-if="canModifyAddress(order.status)">
|
||||
<template #icon><icon-edit /></template>
|
||||
修改地址
|
||||
</a-button>
|
||||
<a-button size="small" shape="round" type="primary" @click="openReview(order.id)" v-if="canReview(order.status)">
|
||||
<template #icon><icon-message /></template>
|
||||
评价
|
||||
</a-button>
|
||||
<a-button size="small" shape="round" status="warning" @click="refund(order.id)" v-if="canRefund(order.status)">
|
||||
<template #icon><icon-history /></template>
|
||||
退款
|
||||
</a-button>
|
||||
<a-popconfirm content="确定删除此订单吗?" @ok="deleteOrder(order.id)">
|
||||
<a-button size="small" shape="round" status="danger">
|
||||
<template #icon><icon-delete /></template>
|
||||
删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-modal v-model:visible="addressModalVisible" title="修改收货地址" :ok-text="'保存'" cancel-text="取消" @ok="submitAddress">
|
||||
<a-form :model="addressForm" layout="vertical">
|
||||
<a-form-item label="收货地址">
|
||||
<a-input v-model="addressForm.address" placeholder="请输入详细的收货地址" allow-clear />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<a-modal v-model:visible="logisticsModalVisible" title="物流跟踪" :footer="null" width="600">
|
||||
<a-timeline v-if="logistics.length > 0">
|
||||
<a-timeline-item v-for="(log, index) in logistics" :key="index" :color="index === 0 ? 'green' : 'gray'">
|
||||
<div class="log-item">
|
||||
<div class="log-status">{{ log.status }}</div>
|
||||
<div class="log-note">{{ log.note }}</div>
|
||||
<div class="log-time">{{ log.createdAt }}</div>
|
||||
</div>
|
||||
</a-timeline-item>
|
||||
</a-timeline>
|
||||
<a-empty v-else description="暂无物流信息" />
|
||||
</a-modal>
|
||||
|
||||
<a-modal v-model:visible="reviewModalVisible" title="发表评价" :ok-text="'提交评价'" cancel-text="取消" @ok="submitReview">
|
||||
<a-form :model="reviewForm" layout="vertical">
|
||||
<a-form-item label="选择商品">
|
||||
<a-select v-model="reviewForm.productId" placeholder="请选择要评价的商品">
|
||||
<a-option v-for="item in orderItems" :key="item.productId" :value="item.productId">
|
||||
<template #icon><icon-thumb-up /></template>
|
||||
{{ item.productName }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="评分">
|
||||
<div class="rating-section">
|
||||
<a-rate v-model="reviewForm.rating" :count="5" />
|
||||
<span class="rating-text">{{ ratingText }}</span>
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="评价内容">
|
||||
<a-textarea v-model="reviewForm.content" placeholder="请分享您的使用体验..." :rows="4" show-word-limit :max-length="500" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
|
||||
<a-modal v-model:visible="addressModalVisible" title="修改收货地址" @ok="submitAddress">
|
||||
<a-input v-model="addressForm.address" placeholder="请输入新的收货地址" />
|
||||
</a-modal>
|
||||
|
||||
<a-modal v-model:visible="logisticsModalVisible" title="物流信息">
|
||||
<a-table :columns="logisticsColumns" :data="logistics" :pagination="false" />
|
||||
</a-modal>
|
||||
|
||||
<a-modal v-model:visible="reviewModalVisible" title="提交评价" @ok="submitReview">
|
||||
<a-form :model="reviewForm" layout="vertical">
|
||||
<a-form-item label="商品">
|
||||
<a-select v-model="reviewForm.productId">
|
||||
<a-option v-for="item in orderItems" :key="item.productId" :value="item.productId">{{ item.productName }}</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="评分">
|
||||
<a-rate v-model="reviewForm.rating" :count="5" />
|
||||
</a-form-item>
|
||||
<a-form-item label="内容">
|
||||
<a-textarea v-model="reviewForm.content" :rows="3" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { onMounted, reactive, ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { api } from '../api'
|
||||
@@ -62,38 +147,69 @@ const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const orders = ref([])
|
||||
|
||||
const orderColumns = [
|
||||
{ title: '订单号', dataIndex: 'orderNo' },
|
||||
{ title: '金额', dataIndex: 'totalAmount' },
|
||||
{ title: '状态', dataIndex: 'status' },
|
||||
{ title: '地址', dataIndex: 'address' },
|
||||
{ title: '操作', slotName: 'actions' }
|
||||
]
|
||||
|
||||
const goProducts = () => router.push('/products')
|
||||
const goCart = () => router.push('/cart')
|
||||
const goFavorites = () => router.push('/favorites')
|
||||
const goProfile = () => router.push('/profile')
|
||||
|
||||
const loadOrders = async () => {
|
||||
orders.value = await api.customerOrders()
|
||||
}
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return '-'
|
||||
return new Date(date).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
const colors = {
|
||||
'PENDING_PAYMENT': 'orange',
|
||||
'PAID': 'blue',
|
||||
'SHIPPED': 'cyan',
|
||||
'COMPLETED': 'green',
|
||||
'REFUND_REQUESTED': 'orange',
|
||||
'REFUNDED': 'purple',
|
||||
'CANCELLED': 'gray'
|
||||
}
|
||||
return colors[status] || 'gray'
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const texts = {
|
||||
'PENDING_PAYMENT': '待支付',
|
||||
'PAID': '已支付',
|
||||
'SHIPPED': '已发货',
|
||||
'COMPLETED': '已完成',
|
||||
'REFUND_REQUESTED': '退款中',
|
||||
'REFUNDED': '已退款',
|
||||
'CANCELLED': '已取消'
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
const canModifyAddress = (status) => {
|
||||
return ['PENDING_PAYMENT', 'PAID'].includes(status)
|
||||
}
|
||||
|
||||
const canReview = (status) => {
|
||||
return ['PAID', 'SHIPPED', 'COMPLETED', 'REFUNDED'].includes(status)
|
||||
}
|
||||
|
||||
const canRefund = (status) => {
|
||||
return ['PAID', 'SHIPPED'].includes(status)
|
||||
}
|
||||
|
||||
const addressModalVisible = ref(false)
|
||||
const logisticsModalVisible = ref(false)
|
||||
const reviewModalVisible = ref(false)
|
||||
|
||||
const addressForm = reactive({ id: null, address: '' })
|
||||
const logistics = ref([])
|
||||
const logisticsColumns = [
|
||||
{ title: '状态', dataIndex: 'status' },
|
||||
{ title: '备注', dataIndex: 'note' },
|
||||
{ title: '时间', dataIndex: 'createdAt' }
|
||||
]
|
||||
|
||||
const orderItems = ref([])
|
||||
const reviewForm = reactive({ orderId: null, productId: null, rating: 5, content: '' })
|
||||
|
||||
const ratingText = computed(() => {
|
||||
const texts = ['', '非常差', '较差', '一般', '满意', '非常满意']
|
||||
return texts[reviewForm.rating] || ''
|
||||
})
|
||||
|
||||
const refund = async (id) => {
|
||||
await api.refundOrder(id, { reason: '不想要了' })
|
||||
Message.success('已提交退款申请')
|
||||
@@ -113,6 +229,7 @@ const openAddress = (order) => {
|
||||
}
|
||||
|
||||
const submitAddress = async () => {
|
||||
if (!addressForm.address.trim()) return Message.warning('请输入收货地址')
|
||||
await api.updateOrderAddress(addressForm.id, { address: addressForm.address })
|
||||
Message.success('地址已更新')
|
||||
addressModalVisible.value = false
|
||||
@@ -138,10 +255,13 @@ const openReview = async (orderId) => {
|
||||
const submitReview = async () => {
|
||||
if (!reviewForm.productId) return Message.warning('请选择商品')
|
||||
await api.addReview({ orderId: reviewForm.orderId, productId: reviewForm.productId, rating: reviewForm.rating, content: reviewForm.content })
|
||||
Message.success('评价已提交')
|
||||
Message.success({ content: '评价已提交,感谢您的反馈!', icon: () => h(IconCheckCircle, { status: 'success' }) })
|
||||
reviewModalVisible.value = false
|
||||
}
|
||||
|
||||
import { h } from 'vue'
|
||||
import { IconFile, IconApps, IconRefresh, IconLocation, IconSend, IconEdit, IconMessage, IconHistory, IconDelete, IconThumbUp, IconCheckCircle } from '@arco-design/web-vue/es/icon'
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await userStore.fetchMe()
|
||||
@@ -152,3 +272,196 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.orders-container {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #f0f5ff 0%, #fff5f8 100%);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.orders-header {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-title h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.order-count {
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.orders-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.orders-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.order-card {
|
||||
background: linear-gradient(135deg, #fafafa 0%, #fff 100%);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid #f0f0f0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.order-card:hover {
|
||||
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.15);
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.order-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.order-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.order-no {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.order-date {
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.order-items {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.order-item-preview {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.item-label {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.item-value {
|
||||
color: #667eea;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.order-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.order-address {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.order-total {
|
||||
text-align: right;
|
||||
margin: 0 24px;
|
||||
}
|
||||
|
||||
.total-label {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.total-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.order-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.log-item {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.log-status {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.log-note {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.rating-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.rating-text {
|
||||
color: #ff758c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.arco-btn-primary) {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
:deep(.arco-rate) {
|
||||
font-size: 28px;
|
||||
}
|
||||
</style>
|
||||
|
||||
106
frontend/src/views/admin/AdminAudit.vue
Normal file
106
frontend/src/views/admin/AdminAudit.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<a-card :bordered="false" class="content-card">
|
||||
<template #title>
|
||||
<a-space>
|
||||
<span>商家审核管理</span>
|
||||
<a-tag color="orange">共 {{ applications.length }} 个申请</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-button @click="loadApplications">
|
||||
<template #icon><icon-refresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</template>
|
||||
<a-table :columns="applyColumns" :data="applications" :pagination="{ pageSize: 8 }" :bordered="false">
|
||||
<template #status="{ record }">
|
||||
<a-tag :color="getAuditStatusColor(record.status)">{{ getAuditStatusText(record.status) }}</a-tag>
|
||||
</template>
|
||||
<template #actions="{ record }">
|
||||
<a-button size="mini" type="primary" shape="round" @click="openAuditModal(record)">审核</a-button>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<a-modal v-model:visible="auditModalVisible" title="审核商家入驻申请" :ok-text="'提交审核'" cancel-text="取消" @ok="submitAuditModal">
|
||||
<a-form :model="auditForm" layout="vertical">
|
||||
<a-form-item label="审核结果">
|
||||
<a-radio-group v-model="auditForm.status">
|
||||
<a-radio value="APPROVED">通过</a-radio>
|
||||
<a-radio value="REJECTED">拒绝</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="审核备注">
|
||||
<a-textarea v-model="auditForm.remark" placeholder="请输入审核备注(可选)" :rows="3" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { api } from '../../api'
|
||||
import { IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||
|
||||
const applications = ref([])
|
||||
const auditModalVisible = ref(false)
|
||||
const auditForm = reactive({ id: null, status: 'APPROVED', remark: '' })
|
||||
|
||||
const applyColumns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '申请人', dataIndex: 'applicantNickname' },
|
||||
{ title: '账号', dataIndex: 'applicantUsername', width: 120 },
|
||||
{ title: '资质说明', dataIndex: 'qualification', ellipsis: true },
|
||||
{ title: '状态', slotName: 'status', width: 90 },
|
||||
{ title: '审核备注', dataIndex: 'remark', ellipsis: true },
|
||||
{ title: '操作', slotName: 'actions', width: 80 }
|
||||
]
|
||||
|
||||
const getAuditStatusColor = (status) => {
|
||||
const colors = { 'PENDING': 'orange', 'APPROVED': 'green', 'REJECTED': 'red' }
|
||||
return colors[status] || 'gray'
|
||||
}
|
||||
|
||||
const getAuditStatusText = (status) => {
|
||||
const texts = { 'PENDING': '待审核', 'APPROVED': '已通过', 'REJECTED': '已拒绝' }
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
const loadApplications = async () => {
|
||||
applications.value = await api.adminMerchantApplications()
|
||||
}
|
||||
|
||||
const openAuditModal = (row) => {
|
||||
auditForm.id = row.id
|
||||
auditForm.status = row.status === 'APPROVED' ? 'APPROVED' : 'REJECTED'
|
||||
auditForm.remark = row.remark || ''
|
||||
auditModalVisible.value = true
|
||||
}
|
||||
|
||||
const submitAuditModal = async () => {
|
||||
await api.adminAuditMerchantApplication(auditForm.id, { status: auditForm.status, remark: auditForm.remark })
|
||||
Message.success('审核完成')
|
||||
auditModalVisible.value = false
|
||||
await loadApplications()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadApplications()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
:deep(.arco-btn-primary) {
|
||||
background: linear-gradient(135deg, #722ed1 0%, #9254de 100%);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
:deep(.arco-card-header) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
231
frontend/src/views/admin/AdminBanners.vue
Normal file
231
frontend/src/views/admin/AdminBanners.vue
Normal file
@@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<a-card :bordered="false" class="content-card">
|
||||
<template #title>轮播图设置</template>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="openBannerModal()">
|
||||
<template #icon><icon-plus /></template>
|
||||
新增轮播图
|
||||
</a-button>
|
||||
<a-button @click="loadBanners">
|
||||
<template #icon><icon-refresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-table :columns="bannerColumns" :data="banners" :pagination="false" :bordered="false">
|
||||
<template #imageUrl="{ record }">
|
||||
<img :src="record.imageUrl" style="width: 80px; height: 40px; object-fit: cover; border-radius: 4px" />
|
||||
</template>
|
||||
<template #enabled="{ record }">
|
||||
<a-switch :model-value="record.enabled" @change="(v) => { record.enabled = v; openBannerModal(record) }" />
|
||||
</template>
|
||||
<template #actions="{ record }">
|
||||
<a-space>
|
||||
<a-button size="mini" type="primary" shape="round" @click="openBannerModal(record)">编辑</a-button>
|
||||
<a-popconfirm content="确定删除此轮播图吗?" @ok="deleteBanner(record.id)">
|
||||
<a-button size="mini" status="danger" shape="round">删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<a-modal v-model:visible="bannerModalVisible" :title="bannerForm.id ? '编辑轮播图' : '新增轮播图'" :ok-text="bannerForm.id ? '保存' : '添加'" cancel-text="取消" @ok="submitBannerModal" width="500">
|
||||
<a-form :model="bannerForm" layout="vertical">
|
||||
<a-form-item label="轮播图片" required>
|
||||
<a-upload
|
||||
:action="uploadAction"
|
||||
:show-file-list="false"
|
||||
:headers="uploadHeaders"
|
||||
accept="image/*"
|
||||
@success="onUploadSuccess"
|
||||
@error="onUploadError"
|
||||
@before-upload="beforeUpload"
|
||||
>
|
||||
<template #upload-button>
|
||||
<div class="upload-trigger" :class="{ 'has-image': bannerForm.imageUrl }">
|
||||
<img v-if="bannerForm.imageUrl" :src="bannerForm.imageUrl" class="preview-image" />
|
||||
<div v-else class="upload-placeholder">
|
||||
<icon-plus :size="24" />
|
||||
<span>点击上传图片</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-upload>
|
||||
<div v-if="bannerForm.imageUrl" class="upload-actions">
|
||||
<a-button type="text" size="small" @click="clearImage">
|
||||
<template #icon><icon-delete /></template>
|
||||
清除图片
|
||||
</a-button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="跳转链接">
|
||||
<a-input v-model="bannerForm.linkUrl" placeholder="点击轮播图跳转的URL(可选)" allow-clear />
|
||||
</a-form-item>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="排序">
|
||||
<a-input-number v-model="bannerForm.sortNo" :min="1" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="启用状态">
|
||||
<a-switch v-model="bannerForm.enabled" />
|
||||
<span style="margin-left: 8px">{{ bannerForm.enabled ? '启用' : '禁用' }}</span>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { api } from '../../api'
|
||||
import { IconPlus, IconRefresh, IconDelete } from '@arco-design/web-vue/es/icon'
|
||||
|
||||
const banners = ref([])
|
||||
const bannerModalVisible = ref(false)
|
||||
const bannerForm = reactive({ id: null, imageUrl: '', linkUrl: '', sortNo: 1, enabled: true })
|
||||
|
||||
const uploadAction = 'http://localhost:8080/api/upload/image'
|
||||
const uploadHeaders = computed(() => ({
|
||||
'X-Token': localStorage.getItem('token') || ''
|
||||
}))
|
||||
|
||||
const beforeUpload = (file) => {
|
||||
const isImage = file.type.startsWith('image/')
|
||||
if (!isImage) {
|
||||
Message.error('只能上传图片文件!')
|
||||
return false
|
||||
}
|
||||
const isLt5M = file.size / 1024 / 1024 < 5
|
||||
if (!isLt5M) {
|
||||
Message.error('图片大小不能超过 5MB!')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const onUploadSuccess = (response) => {
|
||||
if (response && response.data) {
|
||||
bannerForm.imageUrl = response.data
|
||||
Message.success('图片上传成功')
|
||||
} else if (typeof response === 'string') {
|
||||
bannerForm.imageUrl = response
|
||||
Message.success('图片上传成功')
|
||||
}
|
||||
}
|
||||
|
||||
const onUploadError = (error) => {
|
||||
Message.error('图片上传失败:' + (error.message || '未知错误'))
|
||||
}
|
||||
|
||||
const clearImage = () => {
|
||||
bannerForm.imageUrl = ''
|
||||
}
|
||||
|
||||
const bannerColumns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '图片', slotName: 'imageUrl', width: 100 },
|
||||
{ title: '跳转链接', dataIndex: 'linkUrl', ellipsis: true },
|
||||
{ title: '排序', dataIndex: 'sortNo', width: 70 },
|
||||
{ title: '状态', slotName: 'enabled', width: 70 },
|
||||
{ title: '操作', slotName: 'actions', width: 140 }
|
||||
]
|
||||
|
||||
const loadBanners = async () => {
|
||||
banners.value = await api.adminBanners()
|
||||
}
|
||||
|
||||
const openBannerModal = (row = null) => {
|
||||
if (row) {
|
||||
bannerForm.id = row.id
|
||||
bannerForm.imageUrl = row.imageUrl || ''
|
||||
bannerForm.linkUrl = row.linkUrl || ''
|
||||
bannerForm.sortNo = row.sortNo || 1
|
||||
bannerForm.enabled = !!row.enabled
|
||||
} else {
|
||||
bannerForm.id = null
|
||||
bannerForm.imageUrl = ''
|
||||
bannerForm.linkUrl = ''
|
||||
bannerForm.sortNo = 1
|
||||
bannerForm.enabled = true
|
||||
}
|
||||
bannerModalVisible.value = true
|
||||
}
|
||||
|
||||
const submitBannerModal = async () => {
|
||||
await api.adminSaveBanner({ ...bannerForm })
|
||||
Message.success(bannerForm.id ? '轮播图已更新' : '轮播图已新增')
|
||||
bannerModalVisible.value = false
|
||||
await loadBanners()
|
||||
}
|
||||
|
||||
const deleteBanner = async (id) => {
|
||||
await api.adminDeleteBanner(id)
|
||||
Message.success('轮播图已删除')
|
||||
await loadBanners()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadBanners()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
:deep(.arco-btn-primary) {
|
||||
background: linear-gradient(135deg, #722ed1 0%, #9254de 100%);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
:deep(.arco-card-header) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.upload-trigger {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
border: 2px dashed #d9d9d9;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upload-trigger:hover {
|
||||
border-color: #722ed1;
|
||||
}
|
||||
|
||||
.upload-trigger.has-image {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: #999;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.upload-actions {
|
||||
margin-top: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
52
frontend/src/views/admin/AdminInventory.vue
Normal file
52
frontend/src/views/admin/AdminInventory.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<a-card :bordered="false" class="content-card">
|
||||
<template #title>库存总览</template>
|
||||
<template #extra>
|
||||
<a-button @click="loadInventory">
|
||||
<template #icon><icon-refresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</template>
|
||||
<a-table :columns="inventoryColumns" :data="inventory" :pagination="{ pageSize: 8 }" :bordered="false" />
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '../../api'
|
||||
import { IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||
|
||||
const inventory = ref([])
|
||||
|
||||
const inventoryColumns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '商品', dataIndex: 'productName' },
|
||||
{ title: '商家', dataIndex: 'merchantUsername', width: 100 },
|
||||
{ title: '变动', dataIndex: 'changeQty', width: 80 },
|
||||
{ title: '备注', dataIndex: 'note', ellipsis: true },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160 }
|
||||
]
|
||||
|
||||
const loadInventory = async () => {
|
||||
inventory.value = await api.adminInventory()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadInventory()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
:deep(.arco-btn-primary) {
|
||||
background: linear-gradient(135deg, #722ed1 0%, #9254de 100%);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
:deep(.arco-card-header) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
150
frontend/src/views/admin/AdminLayout.vue
Normal file
150
frontend/src/views/admin/AdminLayout.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<a-layout style="min-height: 100vh" class="admin-layout">
|
||||
<a-layout-sider :width="250" theme="dark" collapsible v-model:collapsed="collapsed" :collapsed-width="64" class="admin-sider">
|
||||
<div class="sider-header">
|
||||
<a-avatar :size="36" style="background: linear-gradient(135deg, #722ed1 0%, #b37feb 100%)">
|
||||
<icon-safe />
|
||||
</a-avatar>
|
||||
<span class="sider-title" v-if="!collapsed">超级管理员</span>
|
||||
</div>
|
||||
<a-menu :selected-keys="[$route.name]" @menu-item-click="onMenuClick" theme="dark">
|
||||
<a-menu-item key="admin-overview">
|
||||
<template #icon><icon-dashboard /></template>
|
||||
<span v-if="!collapsed">数据概览</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="admin-orders">
|
||||
<template #icon><icon-file /></template>
|
||||
<span v-if="!collapsed">订单管理</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="admin-risk">
|
||||
<template #icon><icon-exclamation /></template>
|
||||
<span v-if="!collapsed">风控审核</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="admin-products">
|
||||
<template #icon><icon-apps /></template>
|
||||
<span v-if="!collapsed">商品管理</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="admin-audit">
|
||||
<template #icon><icon-check-circle /></template>
|
||||
<span v-if="!collapsed">审核管理</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="admin-users">
|
||||
<template #icon><icon-user-group /></template>
|
||||
<span v-if="!collapsed">用户管理</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="admin-banners">
|
||||
<template #icon><icon-apps /></template>
|
||||
<span v-if="!collapsed">轮播图设置</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="admin-reviews">
|
||||
<template #icon><icon-message /></template>
|
||||
<span v-if="!collapsed">评价管理</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="admin-logistics">
|
||||
<template #icon><icon-send /></template>
|
||||
<span v-if="!collapsed">物流总览</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="admin-inventory">
|
||||
<template #icon><icon-storage /></template>
|
||||
<span v-if="!collapsed">库存总览</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="admin-profile">
|
||||
<template #icon><icon-user /></template>
|
||||
<span v-if="!collapsed">个人信息</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</a-layout-sider>
|
||||
<a-layout class="admin-main">
|
||||
<a-layout-header class="admin-header">
|
||||
<div class="header-left">
|
||||
<span class="page-title">{{ titleMap[$route.name] || '管理后台' }}</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<a-space>
|
||||
<a-tag color="purple" size="small">
|
||||
<template #icon><icon-trophy /></template>
|
||||
管理员
|
||||
</a-tag>
|
||||
<a-button @click="goHome">
|
||||
<template #icon><icon-home /></template>
|
||||
前台首页
|
||||
</a-button>
|
||||
<a-dropdown>
|
||||
<a-button shape="circle">
|
||||
<template #icon><icon-user /></template>
|
||||
</a-button>
|
||||
<template #content>
|
||||
<a-doption @click="goProfile">
|
||||
<template #icon><icon-settings /></template>
|
||||
个人信息
|
||||
</a-doption>
|
||||
<a-doption @click="logout">
|
||||
<template #icon><icon-export /></template>
|
||||
退出登录
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</div>
|
||||
</a-layout-header>
|
||||
<a-layout-content class="admin-content">
|
||||
<router-view />
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { IconSafe, IconDashboard, IconFile, IconExclamation, IconApps, IconCheckCircle, IconUserGroup, IconMessage, IconSend, IconStorage, IconUser, IconTrophy, IconHome, IconExport, IconSettings } from '@arco-design/web-vue/es/icon'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const collapsed = ref(false)
|
||||
|
||||
const titleMap = {
|
||||
'admin-overview': '数据概览',
|
||||
'admin-orders': '订单管理',
|
||||
'admin-risk': '风控审核',
|
||||
'admin-products': '商品管理',
|
||||
'admin-audit': '审核管理',
|
||||
'admin-users': '用户管理',
|
||||
'admin-banners': '轮播图设置',
|
||||
'admin-reviews': '评价管理',
|
||||
'admin-logistics': '物流总览',
|
||||
'admin-inventory': '库存总览',
|
||||
'admin-profile': '个人信息'
|
||||
}
|
||||
|
||||
const onMenuClick = (key) => {
|
||||
router.push({ name: key })
|
||||
}
|
||||
|
||||
const goHome = () => router.push('/')
|
||||
const goProfile = () => router.push({ name: 'admin-profile' })
|
||||
const logout = async () => {
|
||||
await userStore.logout()
|
||||
router.replace('/login')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await userStore.fetchMe()
|
||||
if (userStore.role !== 'ADMIN') {
|
||||
router.replace('/login')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-layout { background: #f0f2f5; }
|
||||
.admin-sider { background: #001529; }
|
||||
.sider-header { height: 64px; display: flex; align-items: center; justify-content: center; gap: 10px; padding: 0 16px; border-bottom: 1px solid rgba(255,255,255,0.1); }
|
||||
.sider-title { font-size: 16px; font-weight: 600; color: #fff; }
|
||||
.admin-main { background: #f0f2f5; }
|
||||
.admin-header { background: #fff; padding: 0 24px; display: flex; align-items: center; justify-content: space-between; box-shadow: 0 1px 4px rgba(0,0,0,0.1); }
|
||||
.page-title { font-size: 18px; font-weight: 600; color: #1f2937; }
|
||||
.admin-content { padding: 20px; overflow: auto; }
|
||||
</style>
|
||||
52
frontend/src/views/admin/AdminLogistics.vue
Normal file
52
frontend/src/views/admin/AdminLogistics.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<a-card :bordered="false" class="content-card">
|
||||
<template #title>物流总览</template>
|
||||
<template #extra>
|
||||
<a-button @click="loadLogistics">
|
||||
<template #icon><icon-refresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</template>
|
||||
<a-table :columns="logisticsColumns" :data="logistics" :pagination="{ pageSize: 8 }" :bordered="false" />
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '../../api'
|
||||
import { IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||
|
||||
const logistics = ref([])
|
||||
|
||||
const logisticsColumns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '订单号', dataIndex: 'orderNo', width: 160 },
|
||||
{ title: '商家', dataIndex: 'merchantUsername', width: 100 },
|
||||
{ title: '状态', dataIndex: 'status', width: 100 },
|
||||
{ title: '备注', dataIndex: 'note', ellipsis: true },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160 }
|
||||
]
|
||||
|
||||
const loadLogistics = async () => {
|
||||
logistics.value = await api.adminLogistics()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadLogistics()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
:deep(.arco-btn-primary) {
|
||||
background: linear-gradient(135deg, #722ed1 0%, #9254de 100%);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
:deep(.arco-card-header) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
134
frontend/src/views/admin/AdminOrders.vue
Normal file
134
frontend/src/views/admin/AdminOrders.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<a-card :bordered="false" class="content-card">
|
||||
<template #title>
|
||||
<a-space>
|
||||
<span>订单管理</span>
|
||||
<a-tag color="blue">共 {{ orders.length }} 个订单</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-button @click="loadOrders">
|
||||
<template #icon><icon-refresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</template>
|
||||
<a-table :columns="orderColumns" :data="orders" :pagination="{ pageSize: 8 }" :bordered="false">
|
||||
<template #status="{ record }">
|
||||
<a-tag :color="getStatusColor(record.status)">{{ getStatusText(record.status) }}</a-tag>
|
||||
</template>
|
||||
<template #actions="{ record }">
|
||||
<a-space>
|
||||
<a-button size="mini" type="primary" shape="round" @click="openOrderModal(record)">修改</a-button>
|
||||
<a-button size="mini" v-if="record.status === 'REFUND_REQUESTED'" @click="auditRefund(record, true)">通过退款</a-button>
|
||||
<a-button size="mini" status="danger" v-if="record.status === 'REFUND_REQUESTED'" @click="auditRefund(record, false)">驳回退款</a-button>
|
||||
<a-button size="mini" v-if="record.status === 'SHIPPED' || record.status === 'PAID'" @click="auditShipment(record, true)">发货审核</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<a-modal v-model:visible="orderModalVisible" title="修改订单" :ok-text="'保存'" cancel-text="取消" @ok="submitOrderModal">
|
||||
<a-form :model="orderForm" layout="vertical">
|
||||
<a-form-item label="订单状态">
|
||||
<a-select v-model="orderForm.status">
|
||||
<a-option value="PENDING_PAYMENT">待支付</a-option>
|
||||
<a-option value="PAID">已支付</a-option>
|
||||
<a-option value="SHIPPED">已发货</a-option>
|
||||
<a-option value="COMPLETED">已完成</a-option>
|
||||
<a-option value="REFUND_REQUESTED">退款中</a-option>
|
||||
<a-option value="REFUNDED">已退款</a-option>
|
||||
<a-option value="CANCELLED">已取消</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="物流信息">
|
||||
<a-input v-model="orderForm.logisticsInfo" placeholder="请输入物流信息" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { api } from '../../api'
|
||||
import { IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||
|
||||
const orders = ref([])
|
||||
const orderModalVisible = ref(false)
|
||||
const orderForm = reactive({ id: null, status: 'PAID', logisticsInfo: '' })
|
||||
|
||||
const orderColumns = [
|
||||
{ title: '订单号', dataIndex: 'orderNo', width: 180 },
|
||||
{ title: '金额', dataIndex: 'totalAmount', render: ({ record }) => `¥${record.totalAmount}`, width: 100 },
|
||||
{ title: '状态', slotName: 'status', width: 100 },
|
||||
{ title: '物流信息', dataIndex: 'logisticsInfo', ellipsis: true },
|
||||
{ title: '下单时间', dataIndex: 'createdAt', render: ({ record }) => formatDate(record.createdAt), width: 160 },
|
||||
{ title: '操作', slotName: 'actions', width: 280 }
|
||||
]
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return '-'
|
||||
return new Date(date).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
const colors = { 'PENDING_PAYMENT': 'orange', 'PAID': 'blue', 'SHIPPED': 'cyan', 'COMPLETED': 'green', 'REFUND_REQUESTED': 'orange', 'REFUNDED': 'purple', 'CANCELLED': 'gray' }
|
||||
return colors[status] || 'gray'
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const texts = { 'PENDING_PAYMENT': '待支付', 'PAID': '已支付', 'SHIPPED': '已发货', 'COMPLETED': '已完成', 'REFUND_REQUESTED': '退款中', 'REFUNDED': '已退款', 'CANCELLED': '已取消' }
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
const loadOrders = async () => {
|
||||
orders.value = await api.adminOrders()
|
||||
}
|
||||
|
||||
const openOrderModal = (row) => {
|
||||
orderForm.id = row.id
|
||||
orderForm.status = row.status
|
||||
orderForm.logisticsInfo = row.logisticsInfo || ''
|
||||
orderModalVisible.value = true
|
||||
}
|
||||
|
||||
const submitOrderModal = async () => {
|
||||
await api.adminUpdateOrder(orderForm.id, { status: orderForm.status, logisticsInfo: orderForm.logisticsInfo })
|
||||
Message.success('订单已更新')
|
||||
orderModalVisible.value = false
|
||||
await loadOrders()
|
||||
}
|
||||
|
||||
const auditRefund = async (row, approve) => {
|
||||
const remark = window.prompt(approve ? '请输入退款通过备注(可为空)' : '请输入驳回原因(可为空)', '') || ''
|
||||
await api.adminAuditRefund(row.id, { approve, remark })
|
||||
Message.success(approve ? '退款审核已通过' : '退款审核已驳回')
|
||||
await loadOrders()
|
||||
}
|
||||
|
||||
const auditShipment = async (row, approve) => {
|
||||
const remark = window.prompt(approve ? '请输入发货审核备注(可为空)' : '请输入驳回原因(可为空)', '') || ''
|
||||
await api.adminAuditShipment(row.id, { approve, remark })
|
||||
Message.success(approve ? '发货审核已通过' : '发货审核已驳回')
|
||||
await loadOrders()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadOrders()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
:deep(.arco-btn-primary) {
|
||||
background: linear-gradient(135deg, #722ed1 0%, #9254de 100%);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
:deep(.arco-card-header) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
153
frontend/src/views/admin/AdminOverview.vue
Normal file
153
frontend/src/views/admin/AdminOverview.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<a-card :bordered="false" class="overview-card">
|
||||
<div class="stats-row">
|
||||
<a-card class="stat-card" :bordered="false">
|
||||
<div class="stat-icon orders">
|
||||
<icon-file :size="32" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ overview.orderCount || 0 }}</div>
|
||||
<div class="stat-label">订单总量</div>
|
||||
</div>
|
||||
</a-card>
|
||||
<a-card class="stat-card" :bordered="false">
|
||||
<div class="stat-icon sales">
|
||||
<icon-gift :size="32" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">¥{{ (overview.salesAmount || 0).toLocaleString() }}</div>
|
||||
<div class="stat-label">平台销售额</div>
|
||||
</div>
|
||||
</a-card>
|
||||
<a-card class="stat-card" :bordered="false">
|
||||
<div class="stat-icon users">
|
||||
<icon-user-group :size="32" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ userCount }}</div>
|
||||
<div class="stat-label">注册用户</div>
|
||||
</div>
|
||||
</a-card>
|
||||
<a-card class="stat-card" :bordered="false">
|
||||
<div class="stat-icon risks">
|
||||
<icon-exclamation :size="32" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ riskCount }}</div>
|
||||
<div class="stat-label">待处理风控</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<a-row :gutter="16" style="margin-top: 20px">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-card title="品类占比" size="small">
|
||||
<div ref="categoryChartRef" style="height: 280px;"></div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-card title="热销排行 Top10" size="small">
|
||||
<div ref="hotChartRef" style="height: 280px;"></div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-divider />
|
||||
<a-card title="通知公告" size="small">
|
||||
<a-timeline v-if="overview.notifications && overview.notifications.length">
|
||||
<a-timeline-item v-for="(note, idx) in overview.notifications" :key="idx" :color="idx === 0 ? 'red' : 'purple'">
|
||||
{{ note }}
|
||||
</a-timeline-item>
|
||||
</a-timeline>
|
||||
<a-empty v-else description="暂无公告" />
|
||||
</a-card>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref, onUnmounted, nextTick } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { IconFile, IconGift, IconUserGroup, IconExclamation } from '@arco-design/web-vue/es/icon'
|
||||
import { api } from '../../api'
|
||||
|
||||
const overview = reactive({})
|
||||
const userCount = ref(0)
|
||||
const riskCount = ref(0)
|
||||
const categoryChartRef = ref(null)
|
||||
const hotChartRef = ref(null)
|
||||
let categoryChart = null
|
||||
let hotChart = null
|
||||
|
||||
const initCharts = () => {
|
||||
if (!overview.categoryRatio) overview.categoryRatio = {}
|
||||
if (!overview.hotProducts) overview.hotProducts = {}
|
||||
|
||||
const categoryData = Object.entries(overview.categoryRatio).map(([name, value]) => ({ name, value }))
|
||||
if (categoryChart) {
|
||||
categoryChart.setOption({
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: { orient: 'vertical', left: 'left' },
|
||||
series: [{ type: 'pie', radius: ['40%', '70%'], data: categoryData, itemStyle: { borderRadius: 8, borderColor: '#fff', borderWidth: 2 } }]
|
||||
})
|
||||
} else if (categoryChartRef.value) {
|
||||
categoryChart = echarts.init(categoryChartRef.value)
|
||||
categoryChart.setOption({
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: { orient: 'vertical', left: 'left' },
|
||||
series: [{ type: 'pie', radius: ['40%', '70%'], data: categoryData, itemStyle: { borderRadius: 8, borderColor: '#fff', borderWidth: 2 } }]
|
||||
})
|
||||
}
|
||||
|
||||
const hotEntries = Object.entries(overview.hotProducts).sort((a, b) => b[1] - a[1]).slice(0, 10)
|
||||
const hotData = hotEntries.map(([name, value]) => ({ name: name.length > 8 ? name.substring(0, 8) + '...' : name, value }))
|
||||
if (hotChart) {
|
||||
hotChart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'value' },
|
||||
yAxis: { type: 'category', data: hotData.map(d => d.name).reverse() },
|
||||
series: [{ type: 'bar', data: hotData.map(d => d.value).reverse(), itemStyle: { borderRadius: [0, 4, 4, 0] } }]
|
||||
})
|
||||
} else if (hotChartRef.value) {
|
||||
hotChart = echarts.init(hotChartRef.value)
|
||||
hotChart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'value' },
|
||||
yAxis: { type: 'category', data: hotData.map(d => d.name).reverse() },
|
||||
series: [{ type: 'bar', data: hotData.map(d => d.value).reverse(), itemStyle: { borderRadius: [0, 4, 4, 0] } }]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleResize = () => { categoryChart?.resize(); hotChart?.resize() }
|
||||
|
||||
onMounted(async () => {
|
||||
Object.assign(overview, await api.adminOverview())
|
||||
const users = await api.adminUsers()
|
||||
userCount.value = users.length
|
||||
const risks = await api.adminOrderRisks()
|
||||
riskCount.value = risks.length
|
||||
await nextTick()
|
||||
initCharts()
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
categoryChart?.dispose()
|
||||
hotChart?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stats-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
|
||||
.stat-card { display: flex; align-items: center; gap: 16px; padding: 20px; }
|
||||
.stat-icon { width: 56px; height: 56px; border-radius: 12px; display: flex; align-items: center; justify-content: center; }
|
||||
.stat-icon.orders { background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%); color: white; }
|
||||
.stat-icon.sales { background: linear-gradient(135deg, #52c41a 0%, #389e0d 100%); color: white; }
|
||||
.stat-icon.users { background: linear-gradient(135deg, #722ed1 0%, #b37feb 100%); color: white; }
|
||||
.stat-icon.risks { background: linear-gradient(135deg, #f5222d 0%, #cf1322 100%); color: white; }
|
||||
.stat-value { font-size: 28px; font-weight: 700; color: #1f2937; }
|
||||
.stat-label { font-size: 14px; color: #6b7280; margin-top: 4px; }
|
||||
.overview-card { border-radius: 12px; }
|
||||
@media (max-width: 768px) { .stats-row { grid-template-columns: repeat(2, 1fr); } }
|
||||
</style>
|
||||
291
frontend/src/views/admin/AdminProducts.vue
Normal file
291
frontend/src/views/admin/AdminProducts.vue
Normal file
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<a-card :bordered="false" class="content-card">
|
||||
<template #title>
|
||||
<a-space>
|
||||
<span>商品管理</span>
|
||||
<a-tag color="green">共 {{ products.length }} 件商品</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="openProductModal()">
|
||||
<template #icon><icon-plus /></template>
|
||||
新增商品
|
||||
</a-button>
|
||||
<a-button @click="loadProducts">
|
||||
<template #icon><icon-refresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-table :columns="productColumns" :data="products" :pagination="{ pageSize: 8 }" :bordered="false">
|
||||
<template #approved="{ record }">
|
||||
<a-switch :model-value="record.approved" @change="() => toggleApprove(record)" />
|
||||
</template>
|
||||
<template #actions="{ record }">
|
||||
<a-space>
|
||||
<a-button size="mini" type="primary" shape="round" @click="openProductModal(record)">编辑</a-button>
|
||||
<a-popconfirm content="确定删除此商品吗?" @ok="deleteProduct(record.id)">
|
||||
<a-button size="mini" status="danger" shape="round">删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<a-modal v-model:visible="productModalVisible" :title="productForm.id ? '编辑商品' : '新增商品'" :ok-text="productForm.id ? '保存' : '添加'" cancel-text="取消" @ok="submitProductModal" width="600">
|
||||
<a-form :model="productForm" layout="vertical">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="16">
|
||||
<a-form-item label="商品名称" required>
|
||||
<a-input v-model="productForm.name" placeholder="请输入商品名称" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="分类">
|
||||
<a-select v-model="productForm.category" placeholder="选择分类">
|
||||
<a-option value="奶粉">奶粉</a-option>
|
||||
<a-option value="尿裤">尿裤</a-option>
|
||||
<a-option value="辅食">辅食</a-option>
|
||||
<a-option value="玩具">玩具</a-option>
|
||||
<a-option value="服饰">服饰</a-option>
|
||||
<a-option value="用品">用品</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="商品描述">
|
||||
<a-textarea v-model="productForm.description" placeholder="请输入商品描述" :rows="3" />
|
||||
</a-form-item>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="价格" required>
|
||||
<a-input-number v-model="productForm.price" :min="0" :precision="2" :step="0.01" style="width: 100%" prefix="¥" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="库存" required>
|
||||
<a-input-number v-model="productForm.stock" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="商品图片">
|
||||
<a-upload
|
||||
:action="uploadAction"
|
||||
:show-file-list="false"
|
||||
:headers="uploadHeaders"
|
||||
accept="image/*"
|
||||
@success="onUploadSuccess"
|
||||
@error="onUploadError"
|
||||
@before-upload="beforeUpload"
|
||||
>
|
||||
<template #upload-button>
|
||||
<div class="upload-trigger" :class="{ 'has-image': productForm.imageUrl }">
|
||||
<img v-if="productForm.imageUrl" :src="productForm.imageUrl" class="preview-image" />
|
||||
<div v-else class="upload-placeholder">
|
||||
<icon-plus :size="24" />
|
||||
<span>点击上传图片</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-upload>
|
||||
<div v-if="productForm.imageUrl" class="upload-actions">
|
||||
<a-button type="text" size="small" @click="clearImage">
|
||||
<template #icon><icon-delete /></template>
|
||||
清除图片
|
||||
</a-button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="所属商家">
|
||||
<a-select v-model="productForm.merchantId" placeholder="选择商家" allow-search>
|
||||
<a-option v-for="m in merchantOptions" :key="m.value" :value="m.value">{{ m.label }}</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="审核状态">
|
||||
<a-switch v-model="productForm.approved" />
|
||||
<span style="margin-left: 8px">{{ productForm.approved ? '已上架' : '待审核' }}</span>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { api } from '../../api'
|
||||
import { IconPlus, IconRefresh, IconDelete } from '@arco-design/web-vue/es/icon'
|
||||
|
||||
const products = ref([])
|
||||
const merchantOptions = ref([])
|
||||
const productModalVisible = ref(false)
|
||||
const productForm = reactive({ id: null, name: '', category: '', description: '', price: 0, stock: 0, imageUrl: '', merchantId: null, approved: false })
|
||||
|
||||
const uploadAction = 'http://localhost:8080/api/upload/image'
|
||||
const uploadHeaders = computed(() => ({
|
||||
'X-Token': localStorage.getItem('token') || ''
|
||||
}))
|
||||
|
||||
const beforeUpload = (file) => {
|
||||
const isImage = file.type.startsWith('image/')
|
||||
if (!isImage) {
|
||||
Message.error('只能上传图片文件!')
|
||||
return false
|
||||
}
|
||||
const isLt5M = file.size / 1024 / 1024 < 5
|
||||
if (!isLt5M) {
|
||||
Message.error('图片大小不能超过 5MB!')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const onUploadSuccess = (response) => {
|
||||
if (response && response.data) {
|
||||
productForm.imageUrl = response.data
|
||||
Message.success('图片上传成功')
|
||||
} else if (typeof response === 'string') {
|
||||
productForm.imageUrl = response
|
||||
Message.success('图片上传成功')
|
||||
}
|
||||
}
|
||||
|
||||
const onUploadError = (error) => {
|
||||
Message.error('图片上传失败:' + (error.message || '未知错误'))
|
||||
}
|
||||
|
||||
const clearImage = () => {
|
||||
productForm.imageUrl = ''
|
||||
}
|
||||
|
||||
const productColumns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '商品名', dataIndex: 'name' },
|
||||
{ title: '分类', dataIndex: 'category', width: 80 },
|
||||
{ title: '价格', dataIndex: 'price', render: ({ record }) => `¥${record.price}`, width: 80 },
|
||||
{ title: '库存', dataIndex: 'stock', width: 70 },
|
||||
{ title: '商家', dataIndex: 'merchantUsername', width: 100 },
|
||||
{ title: '审核', slotName: 'approved', width: 80 },
|
||||
{ title: '操作', slotName: 'actions', width: 140 }
|
||||
]
|
||||
|
||||
const loadProducts = async () => {
|
||||
products.value = await api.adminProductViews()
|
||||
}
|
||||
|
||||
const loadMerchantOptions = async () => {
|
||||
const list = await api.adminUsers()
|
||||
merchantOptions.value = list.filter((u) => u.role === 'MERCHANT').map((u) => ({ label: `${u.nickname || u.username} (${u.username})`, value: u.id }))
|
||||
}
|
||||
|
||||
const openProductModal = (row = null) => {
|
||||
if (row) {
|
||||
productForm.id = row.id
|
||||
productForm.name = row.name || ''
|
||||
productForm.category = row.category || ''
|
||||
productForm.description = row.description || ''
|
||||
productForm.price = row.price || 0
|
||||
productForm.stock = row.stock || 0
|
||||
productForm.imageUrl = row.imageUrl || ''
|
||||
productForm.merchantId = row.merchantId || null
|
||||
productForm.approved = !!row.approved
|
||||
} else {
|
||||
productForm.id = null
|
||||
productForm.name = ''
|
||||
productForm.category = ''
|
||||
productForm.description = ''
|
||||
productForm.price = 0
|
||||
productForm.stock = 0
|
||||
productForm.imageUrl = ''
|
||||
productForm.merchantId = null
|
||||
productForm.approved = false
|
||||
}
|
||||
if (merchantOptions.value.length === 0) loadMerchantOptions()
|
||||
productModalVisible.value = true
|
||||
}
|
||||
|
||||
const submitProductModal = async () => {
|
||||
if (!productForm.merchantId) return Message.warning('请选择所属商家')
|
||||
await api.adminSaveProduct({ ...productForm })
|
||||
Message.success(productForm.id ? '商品已更新' : '商品已新增')
|
||||
productModalVisible.value = false
|
||||
await loadProducts()
|
||||
}
|
||||
|
||||
const toggleApprove = async (row) => {
|
||||
await api.adminApproveProduct(row.id, { approved: !row.approved })
|
||||
Message.success(!row.approved ? '已上架' : '已下架')
|
||||
await loadProducts()
|
||||
}
|
||||
|
||||
const deleteProduct = async (id) => {
|
||||
await api.adminDeleteProduct(id)
|
||||
Message.success('商品已删除')
|
||||
await loadProducts()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProducts()
|
||||
loadMerchantOptions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
:deep(.arco-btn-primary) {
|
||||
background: linear-gradient(135deg, #722ed1 0%, #9254de 100%);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
:deep(.arco-card-header) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.upload-trigger {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
border: 2px dashed #d9d9d9;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upload-trigger:hover {
|
||||
border-color: #722ed1;
|
||||
}
|
||||
|
||||
.upload-trigger.has-image {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: #999;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.upload-actions {
|
||||
margin-top: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
84
frontend/src/views/admin/AdminProfile.vue
Normal file
84
frontend/src/views/admin/AdminProfile.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<a-card :bordered="false" class="content-card">
|
||||
<template #title>个人信息</template>
|
||||
<a-form :model="profile" layout="vertical" style="max-width: 500px">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="账号">
|
||||
<a-input v-model="profile.username" disabled>
|
||||
<template #prefix><icon-user /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="角色">
|
||||
<a-input v-model="profile.role" disabled>
|
||||
<template #prefix><icon-trophy /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="昵称">
|
||||
<a-input v-model="profile.nickname">
|
||||
<template #prefix><icon-relation /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="手机号">
|
||||
<a-input v-model="profile.phone">
|
||||
<template #prefix><icon-phone /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="地址">
|
||||
<a-input v-model="profile.address">
|
||||
<template #prefix><icon-location /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
<a-button type="primary" @click="saveProfile">
|
||||
<template #icon><icon-check /></template>
|
||||
保存修改
|
||||
</a-button>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, onMounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { api } from '../../api'
|
||||
import { IconUser, IconTrophy, IconRelation, IconPhone, IconLocation, IconCheck } from '@arco-design/web-vue/es/icon'
|
||||
|
||||
const profile = reactive({ username: '', role: '', nickname: '', phone: '', address: '' })
|
||||
|
||||
const loadProfile = async () => {
|
||||
const me = await api.me()
|
||||
profile.username = me.username
|
||||
profile.role = me.role
|
||||
profile.nickname = me.nickname || ''
|
||||
profile.phone = me.phone || ''
|
||||
profile.address = me.address || ''
|
||||
}
|
||||
|
||||
const saveProfile = async () => {
|
||||
await api.updateMe({ nickname: profile.nickname, phone: profile.phone, address: profile.address })
|
||||
Message.success('已保存')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProfile()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
:deep(.arco-btn-primary) {
|
||||
background: linear-gradient(135deg, #722ed1 0%, #9254de 100%);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
:deep(.arco-card-header) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
62
frontend/src/views/admin/AdminReviews.vue
Normal file
62
frontend/src/views/admin/AdminReviews.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<a-card :bordered="false" class="content-card">
|
||||
<template #title>
|
||||
<a-space>
|
||||
<span>评价管理</span>
|
||||
<a-tag color="pink">共 {{ reviews.length }} 条评价</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-button @click="loadReviews">
|
||||
<template #icon><icon-refresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</template>
|
||||
<a-table :columns="reviewColumns" :data="reviews" :pagination="{ pageSize: 8 }" :bordered="false">
|
||||
<template #rating="{ record }">
|
||||
<a-rate :model-value="record.rating" readonly />
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '../../api'
|
||||
import { IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||
|
||||
const reviews = ref([])
|
||||
|
||||
const reviewColumns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '订单号', dataIndex: 'orderNo', width: 160 },
|
||||
{ title: '商品', dataIndex: 'productName' },
|
||||
{ title: '顾客', dataIndex: 'customerUsername', width: 100 },
|
||||
{ title: '评分', slotName: 'rating', width: 180 },
|
||||
{ title: '内容', dataIndex: 'content', ellipsis: true },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160 }
|
||||
]
|
||||
|
||||
const loadReviews = async () => {
|
||||
reviews.value = await api.adminReviews()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadReviews()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
:deep(.arco-btn-primary) {
|
||||
background: linear-gradient(135deg, #722ed1 0%, #9254de 100%);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
:deep(.arco-card-header) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
65
frontend/src/views/admin/AdminRisk.vue
Normal file
65
frontend/src/views/admin/AdminRisk.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<a-card :bordered="false" class="content-card">
|
||||
<template #title>
|
||||
<a-space>
|
||||
<span>风控审核</span>
|
||||
<a-badge :count="risks.length" :show-zero="true" />
|
||||
</a-space>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-button @click="loadRisks">
|
||||
<template #icon><icon-refresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</template>
|
||||
<a-table :columns="riskColumns" :data="risks" :pagination="{ pageSize: 8 }" :bordered="false">
|
||||
<template #riskType="{ record }">
|
||||
<a-tag :color="getRiskColor(record.riskType)">{{ record.riskType }}</a-tag>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '../../api'
|
||||
import { IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||
|
||||
const risks = ref([])
|
||||
|
||||
const riskColumns = [
|
||||
{ title: '订单号', dataIndex: 'orderNo', width: 180 },
|
||||
{ title: '状态', dataIndex: 'status', width: 100 },
|
||||
{ title: '金额', dataIndex: 'totalAmount', render: ({ record }) => `¥${record.totalAmount}`, width: 100 },
|
||||
{ title: '风险类型', slotName: 'riskType', width: 120 },
|
||||
{ title: '风险说明', dataIndex: 'riskReason' }
|
||||
]
|
||||
|
||||
const getRiskColor = (type) => {
|
||||
const colors = { 'HIGH_AMOUNT': 'red', 'SHIP_DELAY': 'orange', 'NO_LOGISTICS': 'purple', 'REFUND_PENDING': 'orange' }
|
||||
return colors[type] || 'gray'
|
||||
}
|
||||
|
||||
const loadRisks = async () => {
|
||||
risks.value = await api.adminOrderRisks()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadRisks()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
:deep(.arco-btn-primary) {
|
||||
background: linear-gradient(135deg, #722ed1 0%, #9254de 100%);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
:deep(.arco-card-header) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
153
frontend/src/views/admin/AdminUsers.vue
Normal file
153
frontend/src/views/admin/AdminUsers.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<a-card :bordered="false" class="content-card">
|
||||
<template #title>
|
||||
<a-space>
|
||||
<span>用户管理</span>
|
||||
<a-tag color="cyan">共 {{ users.length }} 个用户</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="openUserModal()">
|
||||
<template #icon><icon-user-add /></template>
|
||||
新增用户
|
||||
</a-button>
|
||||
<a-button @click="loadUsers">
|
||||
<template #icon><icon-refresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-table :columns="userColumns" :data="users" :pagination="{ pageSize: 8 }" :bordered="false">
|
||||
<template #role="{ record }">
|
||||
<a-tag :color="getRoleColor(record.role)">{{ record.role }}</a-tag>
|
||||
</template>
|
||||
<template #enabled="{ record }">
|
||||
<a-switch :model-value="record.enabled" disabled />
|
||||
</template>
|
||||
<template #actions="{ record }">
|
||||
<a-space>
|
||||
<a-button size="mini" type="primary" shape="round" @click="openUserModal(record)">编辑</a-button>
|
||||
<a-popconfirm content="确定删除此用户吗?" @ok="deleteUser(record.id)">
|
||||
<a-button size="mini" status="danger" shape="round">删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<a-modal v-model:visible="userModalVisible" :title="userForm.id ? '编辑用户' : '新增用户'" :ok-text="userForm.id ? '保存' : '添加'" cancel-text="取消" @ok="submitUserModal" width="520">
|
||||
<a-form :model="userForm" layout="vertical">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="账号" required>
|
||||
<a-input v-model="userForm.username" placeholder="请输入账号" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="密码" :required="!userForm.id">
|
||||
<a-input-password v-model="userForm.password" :placeholder="userForm.id ? '留空则不修改' : '请输入密码'" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="角色" required>
|
||||
<a-select v-model="userForm.role" placeholder="选择用户角色">
|
||||
<a-option value="CUSTOMER">顾客</a-option>
|
||||
<a-option value="MERCHANT">商家</a-option>
|
||||
<a-option value="ADMIN">管理员</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="昵称">
|
||||
<a-input v-model="userForm.nickname" placeholder="请输入昵称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="账号状态">
|
||||
<a-switch v-model="userForm.enabled" />
|
||||
<span style="margin-left: 8px">{{ userForm.enabled ? '启用' : '禁用' }}</span>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { api } from '../../api'
|
||||
import { IconUserAdd, IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||
|
||||
const users = ref([])
|
||||
const userModalVisible = ref(false)
|
||||
const userForm = reactive({ id: null, username: '', password: '', role: 'CUSTOMER', nickname: '', enabled: true })
|
||||
|
||||
const userColumns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '账号', dataIndex: 'username' },
|
||||
{ title: '昵称', dataIndex: 'nickname', width: 100 },
|
||||
{ title: '角色', slotName: 'role', width: 90 },
|
||||
{ title: '状态', slotName: 'enabled', width: 70 },
|
||||
{ title: '操作', slotName: 'actions', width: 140 }
|
||||
]
|
||||
|
||||
const getRoleColor = (role) => {
|
||||
const colors = { 'ADMIN': 'purple', 'MERCHANT': 'blue', 'CUSTOMER': 'green' }
|
||||
return colors[role] || 'gray'
|
||||
}
|
||||
|
||||
const loadUsers = async () => {
|
||||
users.value = await api.adminUsers()
|
||||
}
|
||||
|
||||
const openUserModal = (row = null) => {
|
||||
if (row) {
|
||||
userForm.id = row.id
|
||||
userForm.username = row.username
|
||||
userForm.password = ''
|
||||
userForm.role = row.role
|
||||
userForm.nickname = row.nickname || ''
|
||||
userForm.enabled = row.enabled !== false
|
||||
} else {
|
||||
userForm.id = null
|
||||
userForm.username = ''
|
||||
userForm.password = ''
|
||||
userForm.role = 'CUSTOMER'
|
||||
userForm.nickname = ''
|
||||
userForm.enabled = true
|
||||
}
|
||||
userModalVisible.value = true
|
||||
}
|
||||
|
||||
const submitUserModal = async () => {
|
||||
const username = (userForm.username || '').trim()
|
||||
const password = (userForm.password || '').trim()
|
||||
if (!username) return Message.warning('请输入账号')
|
||||
if (!userForm.id && !password) return Message.warning('请输入密码')
|
||||
await api.adminSaveUser({ ...userForm, username, password })
|
||||
Message.success(userForm.id ? '用户已更新' : '用户已新增')
|
||||
userModalVisible.value = false
|
||||
await loadUsers()
|
||||
}
|
||||
|
||||
const deleteUser = async (id) => {
|
||||
await api.adminDeleteUser(id)
|
||||
Message.success('用户已删除')
|
||||
await loadUsers()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
:deep(.arco-btn-primary) {
|
||||
background: linear-gradient(135deg, #722ed1 0%, #9254de 100%);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
:deep(.arco-card-header) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
63
frontend/src/views/merchant/MerchantInventory.vue
Normal file
63
frontend/src/views/merchant/MerchantInventory.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<a-card :bordered="false" class="content-card">
|
||||
<template #title>库存管理</template>
|
||||
<template #extra>
|
||||
<a-button @click="loadInventory">
|
||||
<template #icon><icon-refresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</template>
|
||||
<a-table :columns="inventoryColumns" :data="inventory" :pagination="{ pageSize: 8 }" :bordered="false">
|
||||
<template #changeQty="{ record }">
|
||||
<a-tag :color="record.changeQty > 0 ? 'green' : 'red'">
|
||||
{{ record.changeQty > 0 ? '+' : '' }}{{ record.changeQty }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template #actions="{ record }">
|
||||
<a-popconfirm content="确定删除此库存记录吗?" @ok="deleteInventory(record.id)">
|
||||
<a-button size="mini" status="danger" shape="round">删除</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { api } from '../../api'
|
||||
import { IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||
|
||||
const inventory = ref([])
|
||||
|
||||
const inventoryColumns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '商品名称', dataIndex: 'productName' },
|
||||
{ title: '变动', slotName: 'changeQty', width: 100 },
|
||||
{ title: '备注', dataIndex: 'note' },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160 },
|
||||
{ title: '操作', slotName: 'actions', width: 80 }
|
||||
]
|
||||
|
||||
const loadInventory = async () => { inventory.value = await api.merchantInventory() }
|
||||
|
||||
const deleteInventory = async (id) => {
|
||||
await api.deleteMerchantInventory(id)
|
||||
Message.success('库存记录已删除')
|
||||
await loadInventory()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadInventory()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
:deep(.arco-card-header) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
126
frontend/src/views/merchant/MerchantLayout.vue
Normal file
126
frontend/src/views/merchant/MerchantLayout.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<a-layout style="min-height: 100vh" class="merchant-layout">
|
||||
<a-layout-sider :width="240" theme="dark" collapsible v-model:collapsed="collapsed" :collapsed-width="64" class="merchant-sider">
|
||||
<div class="sider-header">
|
||||
<a-avatar :size="36" style="background: linear-gradient(135deg, #52c41a 0%, #73d13d 100%)">
|
||||
<icon-home />
|
||||
</a-avatar>
|
||||
<span class="sider-title" v-if="!collapsed">商家后台</span>
|
||||
</div>
|
||||
<a-menu :selected-keys="[$route.name]" @menu-item-click="onMenuClick" theme="dark">
|
||||
<a-menu-item key="merchant-overview">
|
||||
<template #icon><icon-dashboard /></template>
|
||||
<span v-if="!collapsed">数据概览</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="merchant-products">
|
||||
<template #icon><icon-apps /></template>
|
||||
<span v-if="!collapsed">商品管理</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="merchant-orders">
|
||||
<template #icon><icon-file /></template>
|
||||
<span v-if="!collapsed">订单管理</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="merchant-reviews">
|
||||
<template #icon><icon-message /></template>
|
||||
<span v-if="!collapsed">评价管理</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="merchant-logistics">
|
||||
<template #icon><icon-send /></template>
|
||||
<span v-if="!collapsed">物流管理</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="merchant-inventory">
|
||||
<template #icon><icon-storage /></template>
|
||||
<span v-if="!collapsed">库存管理</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="merchant-profile">
|
||||
<template #icon><icon-user /></template>
|
||||
<span v-if="!collapsed">个人信息</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</a-layout-sider>
|
||||
<a-layout class="merchant-main">
|
||||
<a-layout-header class="merchant-header">
|
||||
<div class="header-left">
|
||||
<span class="page-title">{{ titleMap[$route.name] || '商家后台' }}</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<a-space>
|
||||
<a-button @click="goHome">
|
||||
<template #icon><icon-home /></template>
|
||||
去商城
|
||||
</a-button>
|
||||
<a-dropdown>
|
||||
<a-button shape="circle">
|
||||
<template #icon><icon-user /></template>
|
||||
</a-button>
|
||||
<template #content>
|
||||
<a-doption @click="goProfile">
|
||||
<template #icon><icon-settings /></template>
|
||||
个人信息
|
||||
</a-doption>
|
||||
<a-doption @click="logout">
|
||||
<template #icon><icon-export /></template>
|
||||
退出登录
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</div>
|
||||
</a-layout-header>
|
||||
<a-layout-content class="merchant-content">
|
||||
<router-view />
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { IconHome, IconDashboard, IconApps, IconFile, IconMessage, IconSend, IconStorage, IconUser, IconExport, IconSettings } from '@arco-design/web-vue/es/icon'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const collapsed = ref(false)
|
||||
|
||||
const titleMap = {
|
||||
'merchant-overview': '数据概览',
|
||||
'merchant-products': '商品管理',
|
||||
'merchant-orders': '订单管理',
|
||||
'merchant-reviews': '评价管理',
|
||||
'merchant-logistics': '物流管理',
|
||||
'merchant-inventory': '库存管理',
|
||||
'merchant-profile': '个人信息'
|
||||
}
|
||||
|
||||
const onMenuClick = (key) => {
|
||||
router.push({ name: key })
|
||||
}
|
||||
|
||||
const goHome = () => router.push('/')
|
||||
const goProfile = () => router.push({ name: 'merchant-profile' })
|
||||
const logout = async () => {
|
||||
await userStore.logout()
|
||||
router.replace('/login')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await userStore.fetchMe()
|
||||
if (userStore.role !== 'MERCHANT') {
|
||||
router.replace('/login')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.merchant-layout { background: #f0f2f5; }
|
||||
.merchant-sider { background: #001529; }
|
||||
.sider-header { height: 64px; display: flex; align-items: center; justify-content: center; gap: 10px; padding: 0 16px; border-bottom: 1px solid rgba(255,255,255,0.1); }
|
||||
.sider-title { font-size: 16px; font-weight: 600; color: #fff; }
|
||||
.merchant-main { background: #f0f2f5; }
|
||||
.merchant-header { background: #fff; padding: 0 24px; display: flex; align-items: center; justify-content: space-between; box-shadow: 0 1px 4px rgba(0,0,0,0.1); }
|
||||
.page-title { font-size: 18px; font-weight: 600; color: #1f2937; }
|
||||
.merchant-content { padding: 20px; overflow: auto; }
|
||||
</style>
|
||||
36
frontend/src/views/merchant/MerchantLogistics.vue
Normal file
36
frontend/src/views/merchant/MerchantLogistics.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<a-card :bordered="false" class="content-card">
|
||||
<template #title>物流管理</template>
|
||||
<a-table :columns="logisticsColumns" :data="logistics" :pagination="{ pageSize: 8 }" :bordered="false" />
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '../../api'
|
||||
|
||||
const logistics = ref([])
|
||||
|
||||
const logisticsColumns = [
|
||||
{ title: '订单号', dataIndex: 'orderNo' },
|
||||
{ title: '状态', dataIndex: 'status' },
|
||||
{ title: '备注', dataIndex: 'note' },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160 }
|
||||
]
|
||||
|
||||
const loadLogistics = async () => { logistics.value = await api.merchantLogistics() }
|
||||
|
||||
onMounted(() => {
|
||||
loadLogistics()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
:deep(.arco-card-header) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
123
frontend/src/views/merchant/MerchantOrders.vue
Normal file
123
frontend/src/views/merchant/MerchantOrders.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<a-card :bordered="false" class="content-card">
|
||||
<template #title>
|
||||
<a-space>
|
||||
<span>订单管理</span>
|
||||
<a-tag color="arcoblue">共 {{ orders.length }} 个订单</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-button @click="loadOrders">
|
||||
<template #icon><icon-refresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</template>
|
||||
<a-table :columns="orderColumns" :data="orders" :pagination="{ pageSize: 8 }" :bordered="false">
|
||||
<template #status="{ record }">
|
||||
<a-tag :color="getOrderStatusColor(record.status)">{{ getOrderStatusText(record.status) }}</a-tag>
|
||||
</template>
|
||||
<template #actions="{ record }">
|
||||
<a-space>
|
||||
<a-button size="mini" type="primary" shape="round" @click="ship(record.id)" v-if="record.status === 'PAID'">
|
||||
<template #icon><icon-send /></template>
|
||||
发货
|
||||
</a-button>
|
||||
<a-button size="mini" type="primary" shape="round" @click="refund(record.id, true)" v-if="record.status === 'REFUND_REQUESTED'">
|
||||
<template #icon><icon-check-circle /></template>
|
||||
同意退款
|
||||
</a-button>
|
||||
<a-button size="mini" shape="round" @click="openOrderLogistics(record.id)">
|
||||
<template #icon><icon-send /></template>
|
||||
物流
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<a-modal v-model:visible="logisticsModalVisible" title="订单物流详情" :footer="null" width="600">
|
||||
<a-table :columns="logisticsColumns" :data="filteredLogistics" :pagination="false" :bordered="false" />
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { api } from '../../api'
|
||||
import { IconRefresh, IconSend, IconCheckCircle } from '@arco-design/web-vue/es/icon'
|
||||
|
||||
const orders = ref([])
|
||||
const logistics = ref([])
|
||||
const logisticsModalVisible = ref(false)
|
||||
const filteredLogistics = ref([])
|
||||
|
||||
const orderColumns = [
|
||||
{ title: '订单号', dataIndex: 'orderNo' },
|
||||
{ title: '金额', dataIndex: 'totalAmount', render: ({ record }) => `¥${record.totalAmount}` },
|
||||
{ title: '状态', slotName: 'status' },
|
||||
{ title: '下单时间', dataIndex: 'createdAt', render: ({ record }) => formatDate(record.createdAt) },
|
||||
{ title: '操作', slotName: 'actions', width: 200 }
|
||||
]
|
||||
|
||||
const logisticsColumns = [
|
||||
{ title: '订单号', dataIndex: 'orderNo' },
|
||||
{ title: '状态', dataIndex: 'status' },
|
||||
{ title: '备注', dataIndex: 'note' },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160 }
|
||||
]
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return '-'
|
||||
return new Date(date).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const getOrderStatusColor = (status) => {
|
||||
const colors = { 'PENDING_PAYMENT': 'orange', 'PAID': 'blue', 'SHIPPED': 'cyan', 'COMPLETED': 'green', 'REFUND_REQUESTED': 'orange', 'REFUNDED': 'purple', 'CANCELLED': 'gray' }
|
||||
return colors[status] || 'gray'
|
||||
}
|
||||
|
||||
const getOrderStatusText = (status) => {
|
||||
const texts = { 'PENDING_PAYMENT': '待支付', 'PAID': '已支付', 'SHIPPED': '已发货', 'COMPLETED': '已完成', 'REFUND_REQUESTED': '退款中', 'REFUNDED': '已退款', 'CANCELLED': '已取消' }
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
const loadOrders = async () => { orders.value = await api.merchantOrders() }
|
||||
const loadLogistics = async () => { logistics.value = await api.merchantLogistics() }
|
||||
|
||||
const ship = async (id) => {
|
||||
await api.shipOrder(id, { note: '已发货,请注意查收' })
|
||||
Message.success('发货成功')
|
||||
await loadOrders()
|
||||
}
|
||||
|
||||
const refund = async (id, agree) => {
|
||||
await api.merchantRefund(id, { agree })
|
||||
Message.success(agree ? '已同意退款' : '已拒绝退款')
|
||||
await loadOrders()
|
||||
}
|
||||
|
||||
const openOrderLogistics = async (orderId) => {
|
||||
await loadLogistics()
|
||||
filteredLogistics.value = logistics.value.filter((l) => l.orderId === orderId)
|
||||
logisticsModalVisible.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadOrders()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
:deep(.arco-btn-primary) {
|
||||
background: linear-gradient(135deg, #52c41a 0%, #73d13d 100%);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
:deep(.arco-card-header) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
209
frontend/src/views/merchant/MerchantOverview.vue
Normal file
209
frontend/src/views/merchant/MerchantOverview.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<a-card :bordered="false" class="overview-card">
|
||||
<div class="stats-row">
|
||||
<a-card class="stat-card" :bordered="false">
|
||||
<div class="stat-icon orders">
|
||||
<icon-file :size="32" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ overview.orderCount || 0 }}</div>
|
||||
<div class="stat-label">订单总量</div>
|
||||
</div>
|
||||
</a-card>
|
||||
<a-card class="stat-card" :bordered="false">
|
||||
<div class="stat-icon sales">
|
||||
<icon-gift :size="32" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">¥{{ (overview.salesAmount || 0).toLocaleString() }}</div>
|
||||
<div class="stat-label">销售额</div>
|
||||
</div>
|
||||
</a-card>
|
||||
<a-card class="stat-card" :bordered="false">
|
||||
<div class="stat-icon products">
|
||||
<icon-apps :size="32" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ products.length }}</div>
|
||||
<div class="stat-label">在售商品</div>
|
||||
</div>
|
||||
</a-card>
|
||||
<a-card class="stat-card" :bordered="false">
|
||||
<div class="stat-icon reviews">
|
||||
<icon-message :size="32" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ reviews.length }}</div>
|
||||
<div class="stat-label">累计评价</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<a-row :gutter="16" style="margin-top: 20px">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-card title="品类占比" size="small">
|
||||
<div ref="categoryChartRef" style="height: 280px;"></div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-card title="热销排行 Top10" size="small">
|
||||
<div ref="hotChartRef" style="height: 280px;"></div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-divider />
|
||||
<a-card title="通知公告" size="small">
|
||||
<a-timeline v-if="overview.notifications && overview.notifications.length">
|
||||
<a-timeline-item v-for="(note, idx) in overview.notifications" :key="idx" :color="idx === 0 ? 'red' : 'green'">
|
||||
{{ note }}
|
||||
</a-timeline-item>
|
||||
</a-timeline>
|
||||
<a-empty v-else description="暂无公告" />
|
||||
</a-card>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, nextTick, onUnmounted } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { api } from '../../api'
|
||||
import { IconFile, IconGift, IconApps, IconMessage } from '@arco-design/web-vue/es/icon'
|
||||
|
||||
const categoryChartRef = ref(null)
|
||||
const hotChartRef = ref(null)
|
||||
let categoryChart = null
|
||||
let hotChart = null
|
||||
|
||||
const overview = reactive({})
|
||||
const products = ref([])
|
||||
const reviews = ref([])
|
||||
|
||||
const loadOverview = async () => {
|
||||
Object.assign(overview, await api.merchantOverview())
|
||||
await nextTick()
|
||||
initCharts()
|
||||
}
|
||||
|
||||
const initCharts = () => {
|
||||
if (!overview.categoryRatio) overview.categoryRatio = {}
|
||||
if (!overview.hotProducts) overview.hotProducts = {}
|
||||
|
||||
const categoryData = Object.entries(overview.categoryRatio).map(([name, value]) => ({ name, value }))
|
||||
if (categoryChart) {
|
||||
categoryChart.setOption({
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: { orient: 'vertical', left: 'left' },
|
||||
series: [{ type: 'pie', radius: ['40%', '70%'], data: categoryData, itemStyle: { borderRadius: 8, borderColor: '#fff', borderWidth: 2 } }]
|
||||
})
|
||||
} else if (categoryChartRef.value) {
|
||||
categoryChart = echarts.init(categoryChartRef.value)
|
||||
categoryChart.setOption({
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: { orient: 'vertical', left: 'left' },
|
||||
series: [{ type: 'pie', radius: ['40%', '70%'], data: categoryData, itemStyle: { borderRadius: 8, borderColor: '#fff', borderWidth: 2 } }]
|
||||
})
|
||||
}
|
||||
|
||||
const hotEntries = Object.entries(overview.hotProducts).sort((a, b) => b[1] - a[1]).slice(0, 10)
|
||||
const hotData = hotEntries.map(([name, value]) => ({ name: name.length > 8 ? name.substring(0, 8) + '...' : name, value }))
|
||||
if (hotChart) {
|
||||
hotChart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'value' },
|
||||
yAxis: { type: 'category', data: hotData.map(d => d.name).reverse() },
|
||||
series: [{ type: 'bar', data: hotData.map(d => d.value).reverse(), itemStyle: { borderRadius: [0, 4, 4, 0] } }]
|
||||
})
|
||||
} else if (hotChartRef.value) {
|
||||
hotChart = echarts.init(hotChartRef.value)
|
||||
hotChart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'value' },
|
||||
yAxis: { type: 'category', data: hotData.map(d => d.name).reverse() },
|
||||
series: [{ type: 'bar', data: hotData.map(d => d.value).reverse(), itemStyle: { borderRadius: [0, 4, 4, 0] } }]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleResize = () => { categoryChart?.resize(); hotChart?.resize() }
|
||||
|
||||
const loadProducts = async () => { products.value = await api.merchantProducts() }
|
||||
const loadReviews = async () => { reviews.value = await api.merchantReviews() }
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadOverview(), loadProducts(), loadReviews()])
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
categoryChart?.dispose()
|
||||
hotChart?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.overview-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #f0f5ff 0%, #fff 100%);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stat-icon.orders {
|
||||
background: linear-gradient(135deg, #1890ff 0%, #69c0ff 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stat-icon.sales {
|
||||
background: linear-gradient(135deg, #52c41a 0%, #95de64 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stat-icon.products {
|
||||
background: linear-gradient(135deg, #722ed1 0%, #b37feb 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stat-icon.reviews {
|
||||
background: linear-gradient(135deg, #fa8c16 0%, #ffc069 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-row {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
256
frontend/src/views/merchant/MerchantProducts.vue
Normal file
256
frontend/src/views/merchant/MerchantProducts.vue
Normal file
@@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<a-card :bordered="false" class="content-card">
|
||||
<template #title>
|
||||
<a-space>
|
||||
<span>商品管理</span>
|
||||
<a-tag color="green">共 {{ products.length }} 件商品</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="openProductModal()">
|
||||
<template #icon><icon-plus /></template>
|
||||
新增商品
|
||||
</a-button>
|
||||
<a-button @click="loadProducts">
|
||||
<template #icon><icon-refresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-table :columns="productColumns" :data="products" :pagination="{ pageSize: 8 }" :bordered="false">
|
||||
<template #cover="{ record }">
|
||||
<img :src="record.imageUrl || 'https://picsum.photos/60/60?baby=' + record.id" style="width: 48px; height: 48px; border-radius: 8px; object-fit: cover" />
|
||||
</template>
|
||||
<template #actions="{ record }">
|
||||
<a-space>
|
||||
<a-button size="mini" type="primary" shape="round" @click="openProductModal(record)">编辑</a-button>
|
||||
<a-popconfirm content="确定删除此商品吗?" @ok="delProduct(record.id)">
|
||||
<a-button size="mini" status="danger" shape="round">删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<a-modal v-model:visible="productModalVisible" :title="productForm.id ? '编辑商品' : '新增商品'" :ok-text="productForm.id ? '保存' : '添加'" @ok="submitProduct" width="600">
|
||||
<a-form :model="productForm" layout="vertical">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="16">
|
||||
<a-form-item label="商品名称" required>
|
||||
<a-input v-model="productForm.name" placeholder="请输入商品名称" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="分类">
|
||||
<a-select v-model="productForm.category" placeholder="选择分类">
|
||||
<a-option value="奶粉">奶粉</a-option>
|
||||
<a-option value="尿裤">尿裤</a-option>
|
||||
<a-option value="辅食">辅食</a-option>
|
||||
<a-option value="玩具">玩具</a-option>
|
||||
<a-option value="服饰">服饰</a-option>
|
||||
<a-option value="用品">用品</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="商品描述">
|
||||
<a-textarea v-model="productForm.description" placeholder="请输入商品描述" :rows="3" />
|
||||
</a-form-item>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="价格" required>
|
||||
<a-input-number v-model="productForm.price" :min="0" :precision="2" :step="0.01" style="width: 100%" prefix="¥" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="库存" required>
|
||||
<a-input-number v-model="productForm.stock" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="商品图片">
|
||||
<a-upload
|
||||
:action="uploadAction"
|
||||
:show-file-list="false"
|
||||
:headers="uploadHeaders"
|
||||
accept="image/*"
|
||||
@success="onUploadSuccess"
|
||||
@error="onUploadError"
|
||||
@before-upload="beforeUpload"
|
||||
>
|
||||
<template #upload-button>
|
||||
<div class="upload-trigger" :class="{ 'has-image': productForm.imageUrl }">
|
||||
<img v-if="productForm.imageUrl" :src="productForm.imageUrl" class="preview-image" />
|
||||
<div v-else class="upload-placeholder">
|
||||
<icon-plus :size="24" />
|
||||
<span>点击上传图片</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-upload>
|
||||
<div v-if="productForm.imageUrl" class="upload-actions">
|
||||
<a-button type="text" size="small" @click="clearImage">
|
||||
<template #icon><icon-delete /></template>
|
||||
清除图片
|
||||
</a-button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, h, onMounted, computed } from 'vue'
|
||||
import { Message, Tag } from '@arco-design/web-vue'
|
||||
import { api } from '../../api'
|
||||
import { IconPlus, IconRefresh, IconDelete } from '@arco-design/web-vue/es/icon'
|
||||
|
||||
const products = ref([])
|
||||
const productModalVisible = ref(false)
|
||||
const productForm = reactive({ id: null, name: '', category: '', description: '', price: 0, stock: 0, imageUrl: '', approved: false })
|
||||
|
||||
const uploadAction = 'http://localhost:8080/api/upload/image'
|
||||
const uploadHeaders = computed(() => ({
|
||||
'X-Token': localStorage.getItem('token') || ''
|
||||
}))
|
||||
|
||||
const beforeUpload = (file) => {
|
||||
const isImage = file.type.startsWith('image/')
|
||||
if (!isImage) {
|
||||
Message.error('只能上传图片文件!')
|
||||
return false
|
||||
}
|
||||
const isLt5M = file.size / 1024 / 1024 < 5
|
||||
if (!isLt5M) {
|
||||
Message.error('图片大小不能超过 5MB!')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const onUploadSuccess = (response) => {
|
||||
if (response && response.data) {
|
||||
productForm.imageUrl = response.data
|
||||
Message.success('图片上传成功')
|
||||
} else if (typeof response === 'string') {
|
||||
productForm.imageUrl = response
|
||||
Message.success('图片上传成功')
|
||||
}
|
||||
}
|
||||
|
||||
const onUploadError = (error) => {
|
||||
Message.error('图片上传失败:' + (error.message || '未知错误'))
|
||||
}
|
||||
|
||||
const clearImage = () => {
|
||||
productForm.imageUrl = ''
|
||||
}
|
||||
|
||||
const productColumns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '封面', slotName: 'cover', width: 70 },
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '分类', dataIndex: 'category', width: 100 },
|
||||
{ title: '价格', dataIndex: 'price', width: 100 },
|
||||
{ title: '库存', dataIndex: 'stock', width: 80 },
|
||||
{ title: '审核', dataIndex: 'approved', width: 80, render: ({ record }) => h(Tag, { color: record.approved ? 'green' : 'orange' }, () => record.approved ? '已上架' : '待审核') },
|
||||
{ title: '操作', slotName: 'actions', width: 150 }
|
||||
]
|
||||
|
||||
const loadProducts = async () => { products.value = await api.merchantProducts() }
|
||||
|
||||
const openProductModal = (row = null) => {
|
||||
if (row) {
|
||||
productForm.id = row.id
|
||||
productForm.name = row.name
|
||||
productForm.category = row.category
|
||||
productForm.description = row.description
|
||||
productForm.price = row.price
|
||||
productForm.stock = row.stock
|
||||
productForm.imageUrl = row.imageUrl
|
||||
} else {
|
||||
productForm.id = null
|
||||
productForm.name = ''
|
||||
productForm.category = ''
|
||||
productForm.description = ''
|
||||
productForm.price = 0
|
||||
productForm.stock = 0
|
||||
productForm.imageUrl = ''
|
||||
}
|
||||
productModalVisible.value = true
|
||||
}
|
||||
|
||||
const submitProduct = async () => {
|
||||
if (!productForm.name.trim()) return Message.warning('请输入商品名称')
|
||||
await api.saveMerchantProduct({ ...productForm })
|
||||
Message.success(productForm.id ? '商品已更新' : '商品已添加')
|
||||
productModalVisible.value = false
|
||||
await loadProducts()
|
||||
}
|
||||
|
||||
const delProduct = async (id) => {
|
||||
await api.deleteMerchantProduct(id)
|
||||
Message.success('商品已删除')
|
||||
await loadProducts()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProducts()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
:deep(.arco-btn-primary) {
|
||||
background: linear-gradient(135deg, #52c41a 0%, #73d13d 100%);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
:deep(.arco-card-header) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.upload-trigger {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
border: 2px dashed #d9d9d9;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upload-trigger:hover {
|
||||
border-color: #52c41a;
|
||||
}
|
||||
|
||||
.upload-trigger.has-image {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: #999;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.upload-actions {
|
||||
margin-top: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
84
frontend/src/views/merchant/MerchantProfile.vue
Normal file
84
frontend/src/views/merchant/MerchantProfile.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<a-card :bordered="false" class="content-card">
|
||||
<template #title>个人信息</template>
|
||||
<a-form :model="profile" layout="vertical" style="max-width: 500px">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="账号">
|
||||
<a-input v-model="profile.username" disabled>
|
||||
<template #prefix><icon-user /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="角色">
|
||||
<a-input v-model="profile.role" disabled>
|
||||
<template #prefix><icon-safe /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="昵称">
|
||||
<a-input v-model="profile.nickname">
|
||||
<template #prefix><icon-relation /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="手机号">
|
||||
<a-input v-model="profile.phone">
|
||||
<template #prefix><icon-phone /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="地址">
|
||||
<a-input v-model="profile.address">
|
||||
<template #prefix><icon-location /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
<a-button type="primary" @click="saveProfile">
|
||||
<template #icon><icon-check /></template>
|
||||
保存修改
|
||||
</a-button>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, onMounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { api } from '../../api'
|
||||
import { IconUser, IconSafe, IconRelation, IconPhone, IconLocation, IconCheck } from '@arco-design/web-vue/es/icon'
|
||||
|
||||
const profile = reactive({ username: '', role: '', nickname: '', phone: '', address: '' })
|
||||
|
||||
const loadProfile = async () => {
|
||||
const me = await api.me()
|
||||
profile.username = me.username
|
||||
profile.role = me.role
|
||||
profile.nickname = me.nickname || ''
|
||||
profile.phone = me.phone || ''
|
||||
profile.address = me.address || ''
|
||||
}
|
||||
|
||||
const saveProfile = async () => {
|
||||
await api.updateMe({ nickname: profile.nickname, phone: profile.phone, address: profile.address })
|
||||
Message.success('已保存')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProfile()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
:deep(.arco-btn-primary) {
|
||||
background: linear-gradient(135deg, #52c41a 0%, #73d13d 100%);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
:deep(.arco-card-header) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
52
frontend/src/views/merchant/MerchantReviews.vue
Normal file
52
frontend/src/views/merchant/MerchantReviews.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<a-card :bordered="false" class="content-card">
|
||||
<template #title>
|
||||
<a-space>
|
||||
<span>评价管理</span>
|
||||
<a-tag color="orange">共 {{ reviews.length }} 条评价</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-table :columns="reviewColumns" :data="reviews" :pagination="{ pageSize: 8 }" :bordered="false">
|
||||
<template #rating="{ record }">
|
||||
<a-rate :model-value="record.rating" readonly />
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '../../api'
|
||||
|
||||
const reviews = ref([])
|
||||
|
||||
const reviewColumns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '订单号', dataIndex: 'orderNo' },
|
||||
{ title: '商品名称', dataIndex: 'productName' },
|
||||
{ title: '评分', slotName: 'rating', width: 180 },
|
||||
{ title: '内容', dataIndex: 'content', ellipsis: true },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: ({ record }) => formatDate(record.createdAt) }
|
||||
]
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return '-'
|
||||
return new Date(date).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const loadReviews = async () => { reviews.value = await api.merchantReviews() }
|
||||
|
||||
onMounted(() => {
|
||||
loadReviews()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
:deep(.arco-card-header) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user