feat: 重构后台路由并使用嵌套路由;添加图片上传功能

- 拆分 AdminView 为多个子页面组件,使用嵌套路由
- 拆分 MerchantView 为多个子页面组件,使用嵌套路由
- 创建 AdminLayout 和 MerchantLayout 布局组件
- 修复编译错误:IconSend 重复导入、IconDatabase 不存在
- 修复 HomeView 缺失 onMounted 导入
- 添加文件上传后端接口和静态资源映射
- 为商品和轮播图添加图片上传功能(支持预览、清除)
This commit is contained in:
wangziqi
2026-02-10 15:14:23 +08:00
parent a7ce0a089e
commit d6451cf188
29 changed files with 4948 additions and 479 deletions

View File

@@ -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 + "/");
}
}

View File

@@ -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);
}
}

View 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' }
})
}
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>