后端功能增强:全局异常处理、API控制器、JSP视图和单元测试

- 添加 GlobalExceptionHandler 全局异常处理
- 添加 ApiController REST API 控制器
- 更新 WebConfig 跨域配置和 ProductRepository 查询方法
- 新增 monitor/product-detail/profile JSP 视图页面
- 添加 FlashSaleServiceTest 秒杀服务单元测试
- 更新 application.yml 配置
This commit is contained in:
2026-03-05 20:30:48 +08:00
parent 923e877759
commit 989c2741a2
63 changed files with 15508 additions and 1 deletions

View File

@@ -0,0 +1,382 @@
<template>
<div class="admin-dashboard">
<!-- 统计卡片 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="stat-card">
<div class="stat-icon bg-blue-100">
<el-icon :size="24" class="text-blue-500"><User /></el-icon>
</div>
<div class="stat-content">
<div class="stat-value">{{ statistics.totalUsers }}</div>
<div class="stat-label">用户总数</div>
<div class="stat-trend text-green-500">
<el-icon><ArrowUp /></el-icon>
12% 较昨日
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon bg-green-100">
<el-icon :size="24" class="text-green-500"><ShoppingBag /></el-icon>
</div>
<div class="stat-content">
<div class="stat-value">{{ statistics.totalProducts }}</div>
<div class="stat-label">商品总数</div>
<div class="stat-trend text-green-500">
<el-icon><ArrowUp /></el-icon>
5% 较昨日
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon bg-orange-100">
<el-icon :size="24" class="text-orange-500"><List /></el-icon>
</div>
<div class="stat-content">
<div class="stat-value">{{ statistics.totalOrders }}</div>
<div class="stat-label">订单总数</div>
<div class="stat-trend text-green-500">
<el-icon><ArrowUp /></el-icon>
23% 较昨日
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon bg-red-100">
<el-icon :size="24" class="text-red-500"><Coin /></el-icon>
</div>
<div class="stat-content">
<div class="stat-value">¥{{ statistics.totalSales.toFixed(2) }}</div>
<div class="stat-label">销售总额</div>
<div class="stat-trend text-green-500">
<el-icon><ArrowUp /></el-icon>
18% 较昨日
</div>
</div>
</div>
</div>
<!-- 图表区域 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- 销售趋势 -->
<div class="chart-card">
<div class="chart-header">
<h3 class="chart-title">销售趋势</h3>
<el-radio-group v-model="salesPeriod" size="small">
<el-radio-button label="week">本周</el-radio-button>
<el-radio-button label="month">本月</el-radio-button>
<el-radio-button label="year">本年</el-radio-button>
</el-radio-group>
</div>
<div ref="salesChartRef" class="chart-container"></div>
</div>
<!-- 商品分类分布 -->
<div class="chart-card">
<div class="chart-header">
<h3 class="chart-title">商品分类分布</h3>
</div>
<div ref="categoryChartRef" class="chart-container"></div>
</div>
</div>
<!-- 数据表格 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 最新订单 -->
<div class="table-card">
<div class="table-header">
<h3 class="table-title">最新订单</h3>
<el-button text type="primary" @click="router.push('/admin/orders')">
查看全部 <el-icon><ArrowRight /></el-icon>
</el-button>
</div>
<el-table :data="recentOrders" stripe>
<el-table-column prop="orderNo" label="订单号" width="120" />
<el-table-column prop="username" label="用户" />
<el-table-column prop="totalAmount" label="金额">
<template #default="{ row }">
¥{{ row.totalAmount }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态">
<template #default="{ row }">
<el-tag :type="getOrderStatusType(row.status)" size="small">
{{ getOrderStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<!-- 热门商品 -->
<div class="table-card">
<div class="table-header">
<h3 class="table-title">热门商品</h3>
<el-button text type="primary" @click="router.push('/admin/products')">
查看全部 <el-icon><ArrowRight /></el-icon>
</el-button>
</div>
<el-table :data="hotProducts" stripe>
<el-table-column prop="name" label="商品名称" />
<el-table-column prop="price" label="价格">
<template #default="{ row }">
¥{{ row.price }}
</template>
</el-table-column>
<el-table-column prop="sales" label="销量" />
<el-table-column prop="stock" label="库存">
<template #default="{ row }">
<el-tag :type="row.stock > 10 ? 'success' : 'warning'" size="small">
{{ row.stock }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import * as echarts from 'echarts'
import type { EChartsOption } from 'echarts'
const router = useRouter()
// 统计数据
const statistics = reactive({
totalUsers: 5234,
totalProducts: 128,
totalOrders: 1893,
totalSales: 285670.50,
todayOrders: 67,
todaySales: 12450.00,
activeFlashSales: 5,
onlineUsers: 342
})
// 图表相关
const salesPeriod = ref('week')
const salesChartRef = ref<HTMLElement>()
const categoryChartRef = ref<HTMLElement>()
let salesChart: echarts.ECharts | null = null
let categoryChart: echarts.ECharts | null = null
// 最新订单
const recentOrders = ref([
{ id: 1, orderNo: 'ORD2024001', username: 'user1', totalAmount: 299.00, status: 'PAID' },
{ id: 2, orderNo: 'ORD2024002', username: 'user2', totalAmount: 599.00, status: 'SHIPPED' },
{ id: 3, orderNo: 'ORD2024003', username: 'user3', totalAmount: 199.00, status: 'PENDING' },
{ id: 4, orderNo: 'ORD2024004', username: 'user4', totalAmount: 899.00, status: 'COMPLETED' },
{ id: 5, orderNo: 'ORD2024005', username: 'user5', totalAmount: 399.00, status: 'PAID' },
])
// 热门商品
const hotProducts = ref([
{ id: 1, name: 'iPhone 15 Pro', price: 8999, sales: 234, stock: 45 },
{ id: 2, name: 'MacBook Pro', price: 12999, sales: 156, stock: 23 },
{ id: 3, name: 'AirPods Pro', price: 1999, sales: 567, stock: 89 },
{ id: 4, name: 'iPad Pro', price: 6999, sales: 123, stock: 8 },
{ id: 5, name: 'Apple Watch', price: 2999, sales: 345, stock: 67 },
])
// 获取订单状态类型
const getOrderStatusType = (status: string) => {
const map: Record<string, string> = {
'PENDING': 'warning',
'PAID': 'primary',
'SHIPPED': 'primary',
'COMPLETED': 'success',
'CANCELLED': 'info'
}
return map[status] || 'info'
}
// 获取订单状态文本
const getOrderStatusText = (status: string) => {
const map: Record<string, string> = {
'PENDING': '待付款',
'PAID': '待发货',
'SHIPPED': '待收货',
'COMPLETED': '已完成',
'CANCELLED': '已取消'
}
return map[status] || status
}
// 初始化销售趋势图表
const initSalesChart = () => {
if (!salesChartRef.value) return
salesChart = echarts.init(salesChartRef.value)
const option: EChartsOption = {
tooltip: {
trigger: 'axis'
},
xAxis: {
type: 'category',
data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
},
yAxis: {
type: 'value',
axisLabel: {
formatter: '¥{value}'
}
},
series: [
{
name: '销售额',
type: 'line',
smooth: true,
data: [12000, 15000, 13000, 18000, 22000, 25000, 20000],
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(59, 130, 246, 0.5)' },
{ offset: 1, color: 'rgba(59, 130, 246, 0)' }
])
},
itemStyle: {
color: '#3b82f6'
}
}
]
}
salesChart.setOption(option)
}
// 初始化分类分布图表
const initCategoryChart = () => {
if (!categoryChartRef.value) return
categoryChart = echarts.init(categoryChartRef.value)
const option: EChartsOption = {
tooltip: {
trigger: 'item'
},
legend: {
orient: 'vertical',
right: 10,
top: 'center'
},
series: [
{
name: '商品分类',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: false,
position: 'center'
},
emphasis: {
label: {
show: true,
fontSize: 20,
fontWeight: 'bold'
}
},
labelLine: {
show: false
},
data: [
{ value: 35, name: '电子产品' },
{ value: 28, name: '服装鞋包' },
{ value: 20, name: '食品饮料' },
{ value: 10, name: '图书音像' },
{ value: 7, name: '其他' }
]
}
]
}
categoryChart.setOption(option)
}
// 窗口大小变化处理
const handleResize = () => {
salesChart?.resize()
categoryChart?.resize()
}
onMounted(() => {
nextTick(() => {
initSalesChart()
initCategoryChart()
})
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
salesChart?.dispose()
categoryChart?.dispose()
})
</script>
<style scoped lang="scss">
.admin-dashboard {
.stat-card {
@apply bg-white rounded-lg p-6 shadow-sm flex items-center gap-4;
.stat-icon {
@apply w-12 h-12 rounded-lg flex items-center justify-center;
}
.stat-content {
@apply flex-1;
.stat-value {
@apply text-2xl font-bold text-gray-800;
}
.stat-label {
@apply text-sm text-gray-500 mt-1;
}
.stat-trend {
@apply text-sm mt-2 flex items-center gap-1;
}
}
}
.chart-card,
.table-card {
@apply bg-white rounded-lg shadow-sm;
.chart-header,
.table-header {
@apply p-4 border-b flex justify-between items-center;
.chart-title,
.table-title {
@apply text-lg font-semibold;
}
}
.chart-container {
@apply p-4;
height: 300px;
}
}
.table-card {
:deep(.el-table) {
border-radius: 0 0 8px 8px;
}
}
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<div class="admin-flashsales">
<div class="page-header">
<h2 class="page-title">秒杀活动管理</h2>
<el-button type="primary">
<el-icon class="mr-1"><Plus /></el-icon>
创建秒杀
</el-button>
</div>
<div class="content-card">
<el-table :data="[]" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="productName" label="商品名称" />
<el-table-column prop="flashPrice" label="秒杀价" />
<el-table-column prop="flashStock" label="秒杀库存" />
<el-table-column prop="startTime" label="开始时间" />
<el-table-column prop="endTime" label="结束时间" />
<el-table-column prop="status" label="状态" />
<el-table-column label="操作" width="200">
<template #default>
<el-button text type="primary" size="small">编辑</el-button>
<el-button text type="danger" size="small">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped lang="scss">
.admin-flashsales {
.page-header {
@apply flex justify-between items-center mb-6;
.page-title {
@apply text-2xl font-bold;
}
}
.content-card {
@apply bg-white rounded-lg shadow-sm p-6;
}
}
</style>

View File

@@ -0,0 +1,41 @@
<template>
<div class="admin-orders">
<div class="page-header">
<h2 class="page-title">订单管理</h2>
</div>
<div class="content-card">
<el-table :data="[]" stripe>
<el-table-column prop="orderNo" label="订单号" />
<el-table-column prop="username" label="用户" />
<el-table-column prop="totalAmount" label="金额" />
<el-table-column prop="status" label="状态" />
<el-table-column prop="createdAt" label="创建时间" />
<el-table-column label="操作" width="150">
<template #default>
<el-button text type="primary" size="small">详情</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped lang="scss">
.admin-orders {
.page-header {
@apply flex justify-between items-center mb-6;
.page-title {
@apply text-2xl font-bold;
}
}
.content-card {
@apply bg-white rounded-lg shadow-sm p-6;
}
}
</style>

View File

@@ -0,0 +1,46 @@
<template>
<div class="admin-products">
<div class="page-header">
<h2 class="page-title">商品管理</h2>
<el-button type="primary">
<el-icon class="mr-1"><Plus /></el-icon>
添加商品
</el-button>
</div>
<div class="content-card">
<el-table :data="[]" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="商品名称" />
<el-table-column prop="price" label="价格" />
<el-table-column prop="stock" label="库存" />
<el-table-column prop="status" label="状态" />
<el-table-column label="操作" width="200">
<template #default>
<el-button text type="primary" size="small">编辑</el-button>
<el-button text type="danger" size="small">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped lang="scss">
.admin-products {
.page-header {
@apply flex justify-between items-center mb-6;
.page-title {
@apply text-2xl font-bold;
}
}
.content-card {
@apply bg-white rounded-lg shadow-sm p-6;
}
}
</style>

View File

@@ -0,0 +1,42 @@
<template>
<div class="admin-users">
<div class="page-header">
<h2 class="page-title">用户管理</h2>
</div>
<div class="content-card">
<el-table :data="[]" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="用户名" />
<el-table-column prop="email" label="邮箱" />
<el-table-column prop="role" label="角色" />
<el-table-column prop="status" label="状态" />
<el-table-column prop="createdAt" label="注册时间" />
<el-table-column label="操作" width="150">
<template #default>
<el-button text type="primary" size="small">编辑</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped lang="scss">
.admin-users {
.page-header {
@apply flex justify-between items-center mb-6;
.page-title {
@apply text-2xl font-bold;
}
}
.content-card {
@apply bg-white rounded-lg shadow-sm p-6;
}
}
</style>

View File

@@ -0,0 +1,342 @@
<template>
<div class="cart-page">
<div class="container mx-auto px-4 py-8">
<!-- 页面标题 -->
<div class="mb-6">
<h1 class="text-3xl font-bold flex items-center">
<el-icon class="text-blue-500 mr-2"><ShoppingCart /></el-icon>
购物车
</h1>
</div>
<div v-if="cartStore.loading" class="text-center py-12">
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
<p class="mt-2 text-gray-500">加载中...</p>
</div>
<div v-else-if="cartStore.items.length === 0" class="bg-white rounded-lg shadow-sm p-12">
<el-empty description="购物车空空如也">
<el-button type="primary" @click="router.push('/products')">
去购物
</el-button>
</el-empty>
</div>
<div v-else class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 左侧购物车列表 -->
<div class="lg:col-span-2">
<div class="bg-white rounded-lg shadow-sm">
<!-- 全选栏 -->
<div class="border-b px-6 py-4 flex justify-between items-center">
<el-checkbox
v-model="selectAll"
:indeterminate="indeterminate"
@change="handleSelectAll"
>
全选{{ cartStore.selectedCount }}/{{ cartStore.itemCount }}
</el-checkbox>
<el-button
text
type="danger"
:disabled="cartStore.selectedCount === 0"
@click="handleBatchRemove"
>
<el-icon class="mr-1"><Delete /></el-icon>
删除选中
</el-button>
</div>
<!-- 商品列表 -->
<div class="divide-y">
<div
v-for="item in cartStore.items"
:key="item.id"
class="p-6 hover:bg-gray-50 transition-colors"
>
<div class="flex gap-4">
<!-- 选择框 -->
<el-checkbox
v-model="item.selected"
@change="handleSelectItem(item.id)"
/>
<!-- 商品图片 -->
<img
:src="item.productImage || '/default-product.png'"
:alt="item.productName"
class="w-24 h-24 object-cover rounded"
@error="handleImageError"
>
<!-- 商品信息 -->
<div class="flex-1">
<h3 class="font-semibold mb-2">
<router-link
:to="`/product/${item.productId}`"
class="hover:text-primary-500"
>
{{ item.productName }}
</router-link>
</h3>
<div class="flex items-center justify-between">
<div>
<span class="text-red-500 font-bold text-lg">
¥{{ item.price }}
</span>
<span class="ml-2 text-sm text-gray-400">
库存: {{ item.stock }}
</span>
</div>
<!-- 数量选择 -->
<div class="flex items-center gap-2">
<el-input-number
v-model="item.quantity"
:min="1"
:max="item.stock"
size="small"
@change="handleQuantityChange(item.id, item.quantity)"
/>
<el-button
text
type="danger"
size="small"
@click="handleRemoveItem(item.id)"
>
删除
</el-button>
</div>
</div>
<!-- 小计 -->
<div class="mt-2 text-right">
<span class="text-sm text-gray-500">小计</span>
<span class="text-lg font-semibold text-red-500">
¥{{ (item.price * item.quantity).toFixed(2) }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- 底部操作栏 -->
<div class="border-t px-6 py-4 flex justify-between items-center">
<el-button text @click="handleClearCart">
<el-icon class="mr-1"><Delete /></el-icon>
清空购物车
</el-button>
<el-button text type="primary" @click="router.push('/products')">
继续购物
</el-button>
</div>
</div>
</div>
<!-- 右侧结算信息 -->
<div class="lg:col-span-1">
<div class="bg-white rounded-lg shadow-sm p-6 sticky top-20">
<h3 class="text-lg font-semibold mb-4">订单结算</h3>
<!-- 费用明细 -->
<div class="space-y-3 mb-6">
<div class="flex justify-between">
<span class="text-gray-600">商品总额</span>
<span>¥{{ cartStore.totalPrice.toFixed(2) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">运费</span>
<span class="text-green-500">免运费</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">优惠</span>
<span class="text-red-500">-¥0.00</span>
</div>
</div>
<!-- 总计 -->
<div class="border-t pt-4 mb-6">
<div class="flex justify-between items-center">
<span class="text-lg">应付总额</span>
<span class="text-2xl font-bold text-red-500">
¥{{ cartStore.totalPrice.toFixed(2) }}
</span>
</div>
<p class="text-sm text-gray-500 mt-1">
已选 {{ cartStore.selectedCount }} {{ cartStore.totalQuantity }}
</p>
</div>
<!-- 结算按钮 -->
<el-button
type="danger"
size="large"
class="w-full"
:disabled="cartStore.selectedCount === 0"
@click="handleCheckout"
>
去结算{{ cartStore.selectedCount }}
</el-button>
<!-- 推荐商品 -->
<div class="mt-6 pt-6 border-t">
<h4 class="font-semibold mb-3">为你推荐</h4>
<div class="space-y-3">
<div
v-for="item in recommendProducts"
:key="item.id"
class="flex gap-3 cursor-pointer hover:bg-gray-50 p-2 rounded"
@click="router.push(`/product/${item.id}`)"
>
<img
:src="item.imageUrl"
:alt="item.name"
class="w-16 h-16 object-cover rounded"
>
<div class="flex-1">
<p class="text-sm line-clamp-2">{{ item.name }}</p>
<p class="text-red-500 font-semibold">¥{{ item.price }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useCartStore } from '@/stores/cart'
import { productApi } from '@/api/modules/product'
import type { Product } from '@/types/api'
const router = useRouter()
const cartStore = useCartStore()
const recommendProducts = ref<Product[]>([])
// 全选状态
const selectAll = computed({
get: () => cartStore.isAllSelected,
set: (value) => {
if (value !== cartStore.isAllSelected) {
cartStore.toggleSelectAll()
}
}
})
const indeterminate = computed(() => {
return cartStore.selectedCount > 0 && cartStore.selectedCount < cartStore.itemCount
})
// 处理图片错误
const handleImageError = (e: Event) => {
const target = e.target as HTMLImageElement
target.src = '/default-product.png'
}
// 全选/取消全选
const handleSelectAll = () => {
cartStore.toggleSelectAll()
}
// 选择单个商品
const handleSelectItem = (itemId: string) => {
cartStore.toggleSelect(itemId)
}
// 修改数量
const handleQuantityChange = async (itemId: string, quantity: number) => {
await cartStore.updateQuantity(itemId, quantity)
}
// 删除单个商品
const handleRemoveItem = async (itemId: string) => {
await ElMessageBox.confirm('确定要删除该商品吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
await cartStore.removeItem(itemId)
}
// 批量删除
const handleBatchRemove = async () => {
if (cartStore.selectedCount === 0) return
await ElMessageBox.confirm(
`确定要删除选中的 ${cartStore.selectedCount} 件商品吗?`,
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
await cartStore.removeSelected()
}
// 清空购物车
const handleClearCart = async () => {
await ElMessageBox.confirm('确定要清空购物车吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
await cartStore.clearCart()
}
// 去结算
const handleCheckout = async () => {
if (cartStore.selectedCount === 0) {
ElMessage.warning('请选择要结算的商品')
return
}
// TODO: 跳转到订单确认页
ElMessage.info('功能开发中...')
}
// 加载推荐商品
const loadRecommendProducts = async () => {
try {
const res = await productApi.getHot(3)
if (res.success) {
recommendProducts.value = res.data
}
} catch (error) {
console.error('加载推荐商品失败:', error)
}
}
onMounted(() => {
cartStore.fetchCart()
loadRecommendProducts()
})
</script>
<style scoped lang="scss">
.cart-page {
min-height: calc(100vh - 60px);
background-color: #f5f5f5;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,39 @@
<template>
<div class="error-404">
<div class="container mx-auto px-4 py-16 text-center">
<el-icon :size="120" class="text-gray-300 mb-8">
<Warning />
</el-icon>
<h1 class="text-6xl font-bold text-gray-800 mb-4">404</h1>
<p class="text-2xl text-gray-600 mb-8">页面未找到</p>
<p class="text-gray-500 mb-8">抱歉您访问的页面不存在或已被移除</p>
<div class="space-x-4">
<el-button type="primary" size="large" @click="router.push('/')">
<el-icon class="mr-2"><HomeFilled /></el-icon>
返回首页
</el-button>
<el-button size="large" @click="router.back()">
<el-icon class="mr-2"><Back /></el-icon>
返回上一页
</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
</script>
<style scoped lang="scss">
.error-404 {
min-height: calc(100vh - 60px);
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,334 @@
<template>
<div class="flashsale-detail-page">
<div class="container mx-auto px-4 py-8">
<!-- 面包屑 -->
<el-breadcrumb separator="/" class="mb-6">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item :to="{ path: '/flashsale' }">秒杀活动</el-breadcrumb-item>
<el-breadcrumb-item>{{ flashSale?.productName || '详情' }}</el-breadcrumb-item>
</el-breadcrumb>
<div v-if="loading" class="text-center py-12">
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
<p class="mt-2 text-gray-500">加载中...</p>
</div>
<div v-else-if="!flashSale" class="text-center py-12">
<el-empty description="秒杀活动不存在" />
<el-button type="primary" @click="router.push('/flashsale')">
返回秒杀列表
</el-button>
</div>
<div v-else class="bg-white rounded-lg shadow-lg overflow-hidden">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 p-8">
<!-- 左侧商品图片 -->
<div>
<div class="relative">
<img
:src="flashSale.productImageUrl || '/default-product.png'"
:alt="flashSale.productName"
class="w-full rounded-lg"
@error="handleImageError"
>
<!-- 状态标签 -->
<div class="absolute top-4 left-4">
<el-tag :type="statusType" size="large" effect="dark">
<el-icon class="mr-1"><Lightning /></el-icon>
{{ statusText }}
</el-tag>
</div>
</div>
</div>
<!-- 右侧商品信息 -->
<div>
<h1 class="text-3xl font-bold mb-4">{{ flashSale.productName }}</h1>
<!-- 价格信息 -->
<div class="bg-red-50 rounded-lg p-6 mb-6">
<div class="flex items-end mb-2">
<span class="text-sm text-gray-500 mr-2">秒杀价</span>
<span class="text-4xl font-bold text-red-500">¥{{ flashSale.flashPrice }}</span>
<span class="ml-4 text-lg text-gray-400 line-through">¥{{ flashSale.originalPrice }}</span>
<span class="ml-2 px-2 py-1 bg-red-500 text-white text-sm rounded">
{{ discountPercent }}% OFF
</span>
</div>
<!-- 活动时间 -->
<div class="text-sm text-gray-600 mt-4">
<p>开始时间{{ formatTime(flashSale.startTime) }}</p>
<p>结束时间{{ formatTime(flashSale.endTime) }}</p>
</div>
</div>
<!-- 库存信息 -->
<div class="mb-6">
<div class="flex justify-between items-center mb-2">
<span class="text-gray-600">库存情况</span>
<span class="text-sm text-gray-500">
剩余 {{ flashSale.remainingStock }} / {{ flashSale.flashStock }}
</span>
</div>
<el-progress
:percentage="stockPercent"
:stroke-width="10"
:color="progressColor"
/>
</div>
<!-- 倒计时 -->
<div v-if="flashSale.status === 'ACTIVE'" class="mb-6">
<div class="text-center p-4 bg-gray-50 rounded-lg">
<p class="text-sm text-gray-600 mb-2">距离结束还有</p>
<CountDown
:end-time="endTime"
@finish="handleFinish"
/>
</div>
</div>
<!-- 限购信息 -->
<div class="mb-6 p-4 bg-blue-50 rounded-lg">
<div class="flex items-center text-blue-700">
<el-icon class="mr-2"><InfoFilled /></el-icon>
<span>每人限购 {{ flashSale.limitPerUser }} </span>
</div>
</div>
<!-- 操作按钮 -->
<div class="space-y-4">
<el-button
type="danger"
size="large"
class="w-full"
:disabled="!canParticipate"
:loading="participating"
@click="handleParticipate"
>
<el-icon class="mr-2"><Lightning /></el-icon>
{{ buttonText }}
</el-button>
<div class="flex gap-4">
<el-button size="large" class="flex-1" @click="handleViewProduct">
查看商品详情
</el-button>
<el-button size="large" class="flex-1" @click="handleShare">
<el-icon class="mr-1"><Share /></el-icon>
分享
</el-button>
</div>
</div>
<!-- 活动说明 -->
<div class="mt-6 p-4 bg-gray-50 rounded-lg">
<h3 class="font-semibold mb-2">活动说明</h3>
<div class="text-sm text-gray-600 space-y-1">
<p> 秒杀商品数量有限先到先得</p>
<p> 每个用户限购{{ flashSale.limitPerUser }}</p>
<p> 秒杀成功后请在30分钟内完成支付</p>
<p> 商品售出后不支持退换</p>
</div>
</div>
</div>
</div>
<!-- 商品描述 -->
<div v-if="flashSale.description" class="border-t px-8 py-6">
<h3 class="text-xl font-semibold mb-4">活动详情</h3>
<div class="text-gray-600" v-html="flashSale.description"></div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import CountDown from '@/components/business/CountDown.vue'
import { flashsaleApi } from '@/api/modules/flashsale'
import { useUserStore } from '@/stores/user'
import type { FlashSale } from '@/types/api'
import dayjs from 'dayjs'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const loading = ref(false)
const participating = ref(false)
const flashSale = ref<FlashSale | null>(null)
// 计算属性
const statusType = computed(() => {
if (!flashSale.value) return 'info'
switch (flashSale.value.status) {
case 'UPCOMING': return 'warning'
case 'ACTIVE': return 'danger'
case 'ENDED': return 'info'
default: return 'info'
}
})
const statusText = computed(() => {
if (!flashSale.value) return ''
switch (flashSale.value.status) {
case 'UPCOMING': return '即将开始'
case 'ACTIVE': return '秒杀进行中'
case 'ENDED': return '已结束'
default: return '未知'
}
})
const discountPercent = computed(() => {
if (!flashSale.value) return 0
return Math.round((1 - flashSale.value.flashPrice / flashSale.value.originalPrice) * 100)
})
const stockPercent = computed(() => {
if (!flashSale.value || flashSale.value.flashStock === 0) return 0
return Math.round(flashSale.value.remainingStock / flashSale.value.flashStock * 100)
})
const progressColor = computed(() => {
if (stockPercent.value > 50) return '#67c23a'
if (stockPercent.value > 20) return '#e6a23c'
return '#f56c6c'
})
const endTime = computed(() => {
if (!flashSale.value) return 0
return new Date(flashSale.value.endTime).getTime()
})
const canParticipate = computed(() => {
if (!flashSale.value) return false
return flashSale.value.status === 'ACTIVE' && flashSale.value.remainingStock > 0
})
const buttonText = computed(() => {
if (!flashSale.value) return '立即抢购'
if (flashSale.value.status === 'UPCOMING') return '即将开始'
if (flashSale.value.status === 'ENDED') return '已结束'
if (flashSale.value.remainingStock === 0) return '已售罄'
return '立即抢购'
})
// 格式化时间
const formatTime = (time: string) => {
return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
}
// 处理图片错误
const handleImageError = (e: Event) => {
const target = e.target as HTMLImageElement
target.src = '/default-product.png'
}
// 加载秒杀详情
const loadFlashSaleDetail = async () => {
loading.value = true
try {
const id = Number(route.params.id)
const res = await flashsaleApi.getDetail(id)
if (res.success) {
flashSale.value = res.data
}
} catch (error) {
console.error('加载秒杀详情失败:', error)
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
// 参与秒杀
const handleParticipate = async () => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push({
path: '/login',
query: { redirect: route.fullPath }
})
return
}
if (!flashSale.value || !canParticipate.value) return
try {
await ElMessageBox.confirm(
`确定要抢购该商品吗?\n秒杀价¥${flashSale.value.flashPrice}`,
'确认抢购',
{
confirmButtonText: '确定抢购',
cancelButtonText: '再想想',
type: 'warning',
}
)
participating.value = true
const startTime = Date.now()
const res = await flashsaleApi.participate({
flashSaleId: flashSale.value.id,
quantity: 1,
timestamp: startTime
})
if (res.success) {
const duration = Date.now() - startTime
ElMessage.success(`抢购成功!耗时${duration}ms`)
// 跳转到订单页面
setTimeout(() => {
router.push('/orders')
}, 1500)
}
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.message || '抢购失败')
}
} finally {
participating.value = false
// 重新加载详情
loadFlashSaleDetail()
}
}
// 查看商品详情
const handleViewProduct = () => {
if (flashSale.value) {
router.push(`/product/${flashSale.value.productId}`)
}
}
// 分享
const handleShare = () => {
ElMessage.success('分享链接已复制')
// 实际实现复制链接到剪贴板
const url = window.location.href
navigator.clipboard.writeText(url)
}
// 倒计时结束
const handleFinish = () => {
loadFlashSaleDetail()
}
onMounted(() => {
loadFlashSaleDetail()
})
</script>
<style scoped lang="scss">
.flashsale-detail-page {
min-height: calc(100vh - 60px);
background-color: #f5f5f5;
}
</style>

View File

@@ -0,0 +1,273 @@
<template>
<div class="flashsale-page">
<div class="container mx-auto px-4 py-8">
<!-- 页面标题 -->
<div class="mb-8">
<h1 class="text-3xl font-bold mb-2 flex items-center">
<el-icon class="text-red-500 mr-2"><Lightning /></el-icon>
秒杀活动
</h1>
<p class="text-gray-600">限时抢购先到先得</p>
</div>
<!-- 筛选栏 -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
<div class="flex flex-wrap gap-4 items-center">
<!-- 状态筛选 -->
<el-radio-group v-model="filters.status" @change="loadFlashSales">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button label="UPCOMING">即将开始</el-radio-button>
<el-radio-button label="ACTIVE">进行中</el-radio-button>
<el-radio-button label="ENDED">已结束</el-radio-button>
</el-radio-group>
<!-- 排序 -->
<el-select
v-model="filters.sort"
placeholder="排序方式"
style="width: 150px"
@change="loadFlashSales"
>
<el-option label="开始时间" value="startTime" />
<el-option label="结束时间" value="endTime" />
<el-option label="价格从低到高" value="flashPrice" />
<el-option label="折扣力度" value="discount" />
</el-select>
<!-- 搜索 -->
<el-input
v-model="filters.keyword"
placeholder="搜索商品名称"
style="width: 200px"
clearable
@keyup.enter="loadFlashSales"
>
<template #suffix>
<el-icon class="cursor-pointer" @click="loadFlashSales">
<Search />
</el-icon>
</template>
</el-input>
<!-- 刷新按钮 -->
<el-button @click="handleRefresh">
<el-icon class="mr-1"><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<!-- 统计信息 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="stat-card bg-gradient-to-r from-orange-400 to-red-500">
<div class="stat-value">{{ statistics.upcoming }}</div>
<div class="stat-label">即将开始</div>
<el-icon :size="30" class="stat-icon"><Clock /></el-icon>
</div>
<div class="stat-card bg-gradient-to-r from-green-400 to-blue-500">
<div class="stat-value">{{ statistics.active }}</div>
<div class="stat-label">正在进行</div>
<el-icon :size="30" class="stat-icon"><Lightning /></el-icon>
</div>
<div class="stat-card bg-gradient-to-r from-purple-400 to-pink-500">
<div class="stat-value">{{ statistics.participated }}</div>
<div class="stat-label">我的参与</div>
<el-icon :size="30" class="stat-icon"><Trophy /></el-icon>
</div>
<div class="stat-card bg-gradient-to-r from-yellow-400 to-orange-500">
<div class="stat-value">{{ statistics.success }}</div>
<div class="stat-label">抢购成功</div>
<el-icon :size="30" class="stat-icon"><SuccessFilled /></el-icon>
</div>
</div>
<!-- 秒杀活动列表 -->
<div v-if="loading" class="text-center py-12">
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
<p class="mt-2 text-gray-500">加载中...</p>
</div>
<div v-else-if="flashSales.length === 0" class="text-center py-12">
<el-empty description="暂无秒杀活动" />
</div>
<div v-else>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<FlashSaleCard
v-for="item in flashSales"
:key="item.id"
:data="item"
@participate="handleParticipate"
@refresh="loadFlashSales"
/>
</div>
<!-- 分页 -->
<div class="mt-8 flex justify-center">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
:total="pagination.total"
:page-sizes="[12, 24, 36, 48]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadFlashSales"
@current-change="loadFlashSales"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import FlashSaleCard from '@/components/business/FlashSaleCard.vue'
import { flashsaleApi } from '@/api/modules/flashsale'
import { useUserStore } from '@/stores/user'
import type { FlashSale } from '@/types/api'
const router = useRouter()
const userStore = useUserStore()
// 数据状态
const loading = ref(false)
const flashSales = ref<FlashSale[]>([])
// 筛选条件
const filters = reactive({
status: '',
sort: 'startTime',
keyword: ''
})
// 分页
const pagination = reactive({
page: 1,
size: 12,
total: 0
})
// 统计信息
const statistics = reactive({
upcoming: 0,
active: 0,
participated: 0,
success: 0
})
// 加载秒杀活动
const loadFlashSales = async () => {
loading.value = true
try {
const res = await flashsaleApi.getList({
...filters,
page: pagination.page - 1,
size: pagination.size
})
if (res.success) {
flashSales.value = res.data.content
pagination.total = res.data.totalElements
// 更新统计信息
updateStatistics()
}
} catch (error) {
console.error('加载秒杀活动失败:', error)
} finally {
loading.value = false
}
}
// 更新统计信息
const updateStatistics = () => {
statistics.upcoming = flashSales.value.filter(item => item.status === 'UPCOMING').length
statistics.active = flashSales.value.filter(item => item.status === 'ACTIVE').length
// 获取用户参与记录需要后端API支持
if (userStore.isLoggedIn) {
loadUserStatistics()
}
}
// 加载用户统计
const loadUserStatistics = async () => {
try {
const res = await flashsaleApi.getUserRecords()
if (res.success) {
statistics.participated = res.data.length
statistics.success = res.data.filter((item: any) => item.success).length
}
} catch (error) {
console.error('加载用户统计失败:', error)
}
}
// 参与秒杀
const handleParticipate = async (flashSaleId: number) => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push('/login')
return
}
// 先检查资格
try {
const res = await flashsaleApi.checkEligibility(flashSaleId)
if (res.success && res.data.eligible) {
// 确认对话框
await ElMessageBox.confirm(
'确定要参与这个秒杀活动吗?',
'提示',
{
confirmButtonText: '立即抢购',
cancelButtonText: '取消',
type: 'warning',
}
)
// 跳转到详情页参与
router.push(`/flashsale/${flashSaleId}`)
} else {
ElMessage.warning(res.data.reason || '您暂时无法参与此活动')
}
} catch (error) {
// 用户取消或错误
}
}
// 刷新
const handleRefresh = () => {
loadFlashSales()
ElMessage.success('已刷新')
}
onMounted(() => {
loadFlashSales()
})
</script>
<style scoped lang="scss">
.flashsale-page {
min-height: calc(100vh - 60px);
background-color: #f5f5f5;
}
.stat-card {
@apply relative overflow-hidden rounded-lg p-4 text-white;
.stat-value {
@apply text-2xl font-bold;
}
.stat-label {
@apply text-sm opacity-90 mt-1;
}
.stat-icon {
@apply absolute right-4 bottom-4 opacity-30;
}
}
</style>

View File

@@ -0,0 +1,260 @@
<template>
<div class="home-page">
<!-- 轮播图 -->
<el-carousel height="400px" :interval="5000" arrow="hover">
<el-carousel-item v-for="item in banners" :key="item.id">
<div class="banner-content" :style="{ background: item.bgColor }">
<div class="container mx-auto px-4 h-full">
<div class="flex items-center h-full">
<div class="w-1/2">
<h1 class="text-4xl font-bold text-white mb-4">
<el-icon :size="40"><Lightning /></el-icon>
{{ item.title }}
</h1>
<p class="text-xl text-white mb-6">{{ item.subtitle }}</p>
<div class="space-x-4">
<el-button size="large" type="primary" @click="router.push(item.link)">
{{ item.buttonText }}
</el-button>
<el-button size="large" @click="router.push('/products')">
浏览商品
</el-button>
</div>
</div>
<div class="w-1/2 text-center">
<el-icon :size="200" class="text-white opacity-50">
<component :is="item.icon" />
</el-icon>
</div>
</div>
</div>
</div>
</el-carousel-item>
</el-carousel>
<div class="container mx-auto px-4 py-8">
<!-- 正在秒杀 -->
<section class="mb-12">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold flex items-center">
<el-icon class="text-red-500 mr-2"><Lightning /></el-icon>
正在秒杀
</h2>
<el-button text @click="router.push('/flashsale')">
查看全部
<el-icon class="ml-1"><ArrowRight /></el-icon>
</el-button>
</div>
<div v-if="loadingFlashSales" class="text-center py-8">
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
<p class="mt-2 text-gray-500">加载中...</p>
</div>
<div v-else-if="activeFlashSales.length === 0" class="text-center py-8">
<el-empty description="暂无进行中的秒杀活动" />
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<FlashSaleCard
v-for="item in activeFlashSales"
:key="item.id"
:data="item"
@participate="handleParticipate"
/>
</div>
</section>
<!-- 热门商品 -->
<section class="mb-12">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold flex items-center">
<el-icon class="text-orange-500 mr-2"><Star /></el-icon>
热门商品
</h2>
<el-button text @click="router.push('/products')">
查看全部
<el-icon class="ml-1"><ArrowRight /></el-icon>
</el-button>
</div>
<div v-if="loadingProducts" class="text-center py-8">
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
<p class="mt-2 text-gray-500">加载中...</p>
</div>
<div v-else-if="hotProducts.length === 0" class="text-center py-8">
<el-empty description="暂无热门商品" />
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<ProductCard
v-for="item in hotProducts"
:key="item.id"
:data="item"
@add-to-cart="handleAddToCart"
/>
</div>
</section>
<!-- 系统特性 -->
<section class="mb-12">
<h2 class="text-2xl font-bold text-center mb-8">系统特性</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div class="feature-card">
<el-icon :size="40" class="text-red-500 mb-4"><Lightning /></el-icon>
<h3 class="text-lg font-semibold mb-2">秒杀抢购</h3>
<p class="text-gray-600">高并发秒杀系统支持大量用户同时抢购</p>
</div>
<div class="feature-card">
<el-icon :size="40" class="text-green-500 mb-4"><Lock /></el-icon>
<h3 class="text-lg font-semibold mb-2">防超卖</h3>
<p class="text-gray-600">分布式锁机制确保库存数据一致性</p>
</div>
<div class="feature-card">
<el-icon :size="40" class="text-blue-500 mb-4"><Coin /></el-icon>
<h3 class="text-lg font-semibold mb-2">Redis缓存</h3>
<p class="text-gray-600">五种数据类型应用毫秒级响应</p>
</div>
<div class="feature-card">
<el-icon :size="40" class="text-orange-500 mb-4"><Speedometer /></el-icon>
<h3 class="text-lg font-semibold mb-2">接口限流</h3>
<p class="text-gray-600">多种限流策略防止恶意刷单</p>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import FlashSaleCard from '@/components/business/FlashSaleCard.vue'
import ProductCard from '@/components/business/ProductCard.vue'
import { flashsaleApi } from '@/api/modules/flashsale'
import { productApi } from '@/api/modules/product'
import { useCartStore } from '@/stores/cart'
import { useUserStore } from '@/stores/user'
import type { FlashSale, Product } from '@/types/api'
const router = useRouter()
const cartStore = useCartStore()
const userStore = useUserStore()
// 轮播图数据
const banners = [
{
id: 1,
title: '秒杀系统',
subtitle: '基于Redis集群构建的高并发秒杀系统',
buttonText: '立即抢购',
link: '/flashsale',
bgColor: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
icon: 'Lightning'
},
{
id: 2,
title: '防超卖机制',
subtitle: '采用分布式锁和Lua脚本确保数据一致性',
buttonText: '了解更多',
link: '/flashsale',
bgColor: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
icon: 'Lock'
},
{
id: 3,
title: '高性能缓存',
subtitle: 'Redis集群架构毫秒级响应',
buttonText: '查看商品',
link: '/products',
bgColor: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
icon: 'Speedometer'
}
]
// 数据状态
const loadingFlashSales = ref(false)
const loadingProducts = ref(false)
const activeFlashSales = ref<FlashSale[]>([])
const hotProducts = ref<Product[]>([])
// 加载秒杀活动
const loadFlashSales = async () => {
loadingFlashSales.value = true
try {
const res = await flashsaleApi.getActive(4)
if (res.success) {
activeFlashSales.value = res.data
}
} catch (error) {
console.error('加载秒杀活动失败:', error)
} finally {
loadingFlashSales.value = false
}
}
// 加载热门商品
const loadProducts = async () => {
loadingProducts.value = true
try {
const res = await productApi.getHot(8)
if (res.success) {
hotProducts.value = res.data
}
} catch (error) {
console.error('加载热门商品失败:', error)
} finally {
loadingProducts.value = false
}
}
// 参与秒杀
const handleParticipate = async (flashSaleId: number) => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push('/login')
return
}
// 跳转到秒杀详情页
router.push(`/flashsale/${flashSaleId}`)
}
// 添加到购物车
const handleAddToCart = async (productId: number) => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push('/login')
return
}
await cartStore.addToCart(productId)
}
onMounted(() => {
loadFlashSales()
loadProducts()
})
</script>
<style scoped lang="scss">
.home-page {
min-height: 100vh;
}
.banner-content {
height: 100%;
display: flex;
align-items: center;
}
.feature-card {
@apply bg-white p-6 rounded-lg shadow-md text-center hover:shadow-lg transition-shadow;
}
:deep(.el-carousel__item) {
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,357 @@
<template>
<div class="order-detail-page">
<div class="container mx-auto px-4 py-8">
<!-- 面包屑 -->
<el-breadcrumb separator="/" class="mb-6">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item :to="{ path: '/orders' }">我的订单</el-breadcrumb-item>
<el-breadcrumb-item>订单详情</el-breadcrumb-item>
</el-breadcrumb>
<div v-if="loading" class="text-center py-12">
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
<p class="mt-2 text-gray-500">加载中...</p>
</div>
<div v-else-if="!order" class="text-center py-12">
<el-empty description="订单不存在" />
<el-button type="primary" @click="router.push('/orders')">
返回订单列表
</el-button>
</div>
<div v-else>
<!-- 订单状态 -->
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold">订单状态</h2>
<el-tag :type="getStatusType(order.status)" size="large">
{{ getStatusText(order.status) }}
</el-tag>
</div>
<!-- 订单进度 -->
<el-steps :active="getActiveStep()" finish-status="success">
<el-step title="提交订单" :description="formatTime(order.createdAt)" />
<el-step title="支付订单" :description="order.paidAt ? formatTime(order.paidAt) : ''" />
<el-step title="商家发货" :description="order.shippedAt ? formatTime(order.shippedAt) : ''" />
<el-step title="确认收货" :description="order.completedAt ? formatTime(order.completedAt) : ''" />
</el-steps>
<!-- 操作按钮 -->
<div class="mt-6 flex gap-2">
<template v-if="order.status === 'PENDING'">
<el-button type="primary" @click="handlePay">立即付款</el-button>
<el-button @click="handleCancel">取消订单</el-button>
</template>
<template v-else-if="order.status === 'SHIPPED'">
<el-button type="primary" @click="handleConfirm">确认收货</el-button>
</template>
<template v-else-if="order.status === 'COMPLETED'">
<el-button @click="handleReview">评价</el-button>
<el-button @click="handleRebuy">再次购买</el-button>
</template>
</div>
</div>
<!-- 收货信息 -->
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
<h3 class="text-lg font-semibold mb-4">收货信息</h3>
<div v-if="order.address" class="text-sm space-y-2">
<div>
<span class="text-gray-500">收货人</span>
<span>{{ order.address.name }} {{ order.address.phone }}</span>
</div>
<div>
<span class="text-gray-500">收货地址</span>
<span>
{{ order.address.province }}
{{ order.address.city }}
{{ order.address.district }}
{{ order.address.address }}
</span>
</div>
</div>
<div v-else class="text-gray-500">
暂无收货信息
</div>
</div>
<!-- 商品信息 -->
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
<h3 class="text-lg font-semibold mb-4">商品信息</h3>
<div class="space-y-4">
<div
v-for="item in order.items"
:key="item.id"
class="flex gap-4 pb-4 border-b last:border-0"
>
<img
:src="item.productImage || '/default-product.png'"
:alt="item.productName"
class="w-24 h-24 object-cover rounded"
@error="handleImageError"
>
<div class="flex-1">
<h4 class="font-semibold mb-2">{{ item.productName }}</h4>
<div class="text-sm text-gray-500">
单价¥{{ item.price }} × {{ item.quantity }}
</div>
</div>
<div class="text-right">
<div class="font-semibold text-lg">¥{{ item.subtotal }}</div>
</div>
</div>
</div>
<!-- 费用明细 -->
<div class="border-t pt-4 mt-4 space-y-2">
<div class="flex justify-between text-sm">
<span class="text-gray-500">商品总额</span>
<span>¥{{ order.totalAmount }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-500">运费</span>
<span>¥0.00</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-500">优惠</span>
<span class="text-red-500">-¥{{ (order.totalAmount - order.paymentAmount).toFixed(2) }}</span>
</div>
<div class="flex justify-between pt-2 border-t">
<span class="font-semibold">实付金额</span>
<span class="text-xl font-bold text-red-500">¥{{ order.paymentAmount }}</span>
</div>
</div>
</div>
<!-- 订单信息 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<h3 class="text-lg font-semibold mb-4">订单信息</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-500">订单编号</span>
<span>{{ order.orderNo }}</span>
<el-button text type="primary" size="small" @click="copyOrderNo">
复制
</el-button>
</div>
<div>
<span class="text-gray-500">创建时间</span>
<span>{{ formatTime(order.createdAt) }}</span>
</div>
<div v-if="order.paidAt">
<span class="text-gray-500">付款时间</span>
<span>{{ formatTime(order.paidAt) }}</span>
</div>
<div v-if="order.paymentMethod">
<span class="text-gray-500">支付方式</span>
<span>{{ getPaymentMethodText(order.paymentMethod) }}</span>
</div>
<div v-if="order.shippedAt">
<span class="text-gray-500">发货时间</span>
<span>{{ formatTime(order.shippedAt) }}</span>
</div>
<div v-if="order.completedAt">
<span class="text-gray-500">完成时间</span>
<span>{{ formatTime(order.completedAt) }}</span>
</div>
<div v-if="order.remark" class="md:col-span-2">
<span class="text-gray-500">订单备注</span>
<span>{{ order.remark }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { orderApi } from '@/api/modules/order'
import type { Order } from '@/types/api'
import dayjs from 'dayjs'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const order = ref<Order | null>(null)
// 格式化时间
const formatTime = (time: string) => {
return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
}
// 处理图片错误
const handleImageError = (e: Event) => {
const target = e.target as HTMLImageElement
target.src = '/default-product.png'
}
// 获取状态类型
const getStatusType = (status: string) => {
const map: Record<string, string> = {
'PENDING': 'warning',
'PAID': 'primary',
'SHIPPED': 'primary',
'COMPLETED': 'success',
'CANCELLED': 'info',
'REFUNDED': 'danger'
}
return map[status] || 'info'
}
// 获取状态文本
const getStatusText = (status: string) => {
const map: Record<string, string> = {
'PENDING': '待付款',
'PAID': '待发货',
'SHIPPED': '待收货',
'COMPLETED': '已完成',
'CANCELLED': '已取消',
'REFUNDED': '已退款'
}
return map[status] || status
}
// 获取支付方式文本
const getPaymentMethodText = (method: string) => {
const map: Record<string, string> = {
'ONLINE': '在线支付',
'ALIPAY': '支付宝',
'WECHAT': '微信支付',
'CASH': '货到付款'
}
return map[method] || method
}
// 获取当前步骤
const getActiveStep = () => {
if (!order.value) return 0
switch (order.value.status) {
case 'PENDING': return 1
case 'PAID': return 2
case 'SHIPPED': return 3
case 'COMPLETED': return 4
case 'CANCELLED':
case 'REFUNDED': return -1
default: return 0
}
}
// 加载订单详情
const loadOrderDetail = async () => {
loading.value = true
try {
const id = Number(route.params.id)
const res = await orderApi.getDetail(id)
if (res.success) {
order.value = res.data
}
} catch (error) {
console.error('加载订单详情失败:', error)
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
// 复制订单号
const copyOrderNo = () => {
if (order.value) {
navigator.clipboard.writeText(order.value.orderNo)
ElMessage.success('订单号已复制')
}
}
// 付款
const handlePay = async () => {
if (!order.value) return
await ElMessageBox.confirm(
`订单金额:¥${order.value.paymentAmount},确认付款?`,
'付款确认',
{
confirmButtonText: '确认付款',
cancelButtonText: '取消',
type: 'warning',
}
)
try {
await orderApi.pay(order.value.id, 'ONLINE')
ElMessage.success('付款成功')
loadOrderDetail()
} catch (error) {
console.error('付款失败:', error)
}
}
// 取消订单
const handleCancel = async () => {
if (!order.value) return
await ElMessageBox.confirm('确定要取消该订单吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
try {
await orderApi.cancel(order.value.id)
ElMessage.success('订单已取消')
loadOrderDetail()
} catch (error) {
console.error('取消订单失败:', error)
}
}
// 确认收货
const handleConfirm = async () => {
if (!order.value) return
await ElMessageBox.confirm('确定已收到商品?', '确认收货', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
try {
await orderApi.confirm(order.value.id)
ElMessage.success('已确认收货')
loadOrderDetail()
} catch (error) {
console.error('确认收货失败:', error)
}
}
// 评价
const handleReview = () => {
ElMessage.info('评价功能开发中...')
}
// 再次购买
const handleRebuy = () => {
ElMessage.success('商品已加入购物车')
router.push('/cart')
}
onMounted(() => {
loadOrderDetail()
})
</script>
<style scoped lang="scss">
.order-detail-page {
min-height: calc(100vh - 60px);
background-color: #f5f5f5;
}
</style>

View File

@@ -0,0 +1,407 @@
<template>
<div class="orders-page">
<div class="container mx-auto px-4 py-8">
<!-- 页面标题 -->
<div class="mb-6">
<h1 class="text-3xl font-bold flex items-center">
<el-icon class="text-blue-500 mr-2"><List /></el-icon>
我的订单
</h1>
</div>
<!-- 订单统计 -->
<div class="grid grid-cols-2 md:grid-cols-6 gap-4 mb-6">
<div
v-for="stat in orderStats"
:key="stat.key"
class="bg-white rounded-lg p-4 text-center cursor-pointer hover:shadow-md transition-shadow"
@click="handleStatusFilter(stat.key)"
>
<el-icon :size="24" :class="stat.color" class="mb-2">
<component :is="stat.icon" />
</el-icon>
<div class="text-2xl font-bold">{{ stat.count }}</div>
<div class="text-sm text-gray-500">{{ stat.label }}</div>
</div>
</div>
<!-- 筛选栏 -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
<div class="flex flex-wrap gap-4 items-center">
<!-- 状态筛选 -->
<el-radio-group v-model="filters.status" @change="loadOrders">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button label="PENDING">待付款</el-radio-button>
<el-radio-button label="PAID">待发货</el-radio-button>
<el-radio-button label="SHIPPED">待收货</el-radio-button>
<el-radio-button label="COMPLETED">已完成</el-radio-button>
<el-radio-button label="CANCELLED">已取消</el-radio-button>
</el-radio-group>
<!-- 搜索 -->
<el-input
v-model="filters.keyword"
placeholder="搜索订单号或商品名称"
style="width: 250px"
clearable
@keyup.enter="loadOrders"
>
<template #suffix>
<el-icon class="cursor-pointer" @click="loadOrders">
<Search />
</el-icon>
</template>
</el-input>
</div>
</div>
<!-- 订单列表 -->
<div v-if="loading" class="text-center py-12">
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
<p class="mt-2 text-gray-500">加载中...</p>
</div>
<div v-else-if="orders.length === 0" class="bg-white rounded-lg shadow-sm p-12">
<el-empty description="暂无订单">
<el-button type="primary" @click="router.push('/products')">
去购物
</el-button>
</el-empty>
</div>
<div v-else class="space-y-4">
<!-- 订单卡片 -->
<div
v-for="order in orders"
:key="order.id"
class="bg-white rounded-lg shadow-sm overflow-hidden"
>
<!-- 订单头部 -->
<div class="bg-gray-50 px-6 py-3 flex justify-between items-center">
<div class="flex items-center gap-4 text-sm text-gray-600">
<span>订单号{{ order.orderNo }}</span>
<span>{{ formatTime(order.createdAt) }}</span>
</div>
<el-tag :type="getStatusType(order.status)">
{{ getStatusText(order.status) }}
</el-tag>
</div>
<!-- 订单商品 -->
<div class="p-6">
<div
v-for="item in order.items"
:key="item.id"
class="flex gap-4 mb-4 last:mb-0"
>
<img
:src="item.productImage || '/default-product.png'"
:alt="item.productName"
class="w-20 h-20 object-cover rounded"
@error="handleImageError"
>
<div class="flex-1">
<h4 class="font-semibold">{{ item.productName }}</h4>
<div class="text-sm text-gray-500 mt-1">
¥{{ item.price }} × {{ item.quantity }}
</div>
</div>
<div class="text-right">
<div class="font-semibold">¥{{ item.subtotal }}</div>
</div>
</div>
</div>
<!-- 订单底部 -->
<div class="border-t px-6 py-4 flex justify-between items-center">
<div>
<span class="text-sm text-gray-500">实付金额</span>
<span class="text-xl font-bold text-red-500">
¥{{ order.paymentAmount || order.totalAmount }}
</span>
</div>
<div class="space-x-2">
<el-button
text
type="primary"
@click="handleViewDetail(order.id)"
>
查看详情
</el-button>
<!-- 根据状态显示不同操作 -->
<template v-if="order.status === 'PENDING'">
<el-button type="primary" size="small" @click="handlePay(order)">
立即付款
</el-button>
<el-button size="small" @click="handleCancel(order)">
取消订单
</el-button>
</template>
<template v-else-if="order.status === 'SHIPPED'">
<el-button type="primary" size="small" @click="handleConfirm(order)">
确认收货
</el-button>
</template>
<template v-else-if="order.status === 'COMPLETED'">
<el-button size="small" @click="handleReview(order)">
评价
</el-button>
<el-button size="small" @click="handleRebuy(order)">
再次购买
</el-button>
</template>
<template v-else-if="order.status === 'CANCELLED' || order.status === 'REFUNDED'">
<el-button text type="danger" size="small" @click="handleDelete(order)">
删除订单
</el-button>
</template>
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div v-if="orders.length > 0" class="mt-8 flex justify-center">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
:total="pagination.total"
:page-sizes="[10, 20, 30, 50]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadOrders"
@current-change="loadOrders"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { orderApi } from '@/api/modules/order'
import type { Order } from '@/types/api'
import dayjs from 'dayjs'
const router = useRouter()
// 数据状态
const loading = ref(false)
const orders = ref<Order[]>([])
// 筛选条件
const filters = reactive({
status: '',
keyword: ''
})
// 分页
const pagination = reactive({
page: 1,
size: 10,
total: 0
})
// 订单统计
const orderStats = ref([
{ key: '', label: '全部', count: 0, icon: 'List', color: 'text-gray-500' },
{ key: 'PENDING', label: '待付款', count: 0, icon: 'Clock', color: 'text-orange-500' },
{ key: 'PAID', label: '待发货', count: 0, icon: 'Box', color: 'text-blue-500' },
{ key: 'SHIPPED', label: '待收货', count: 0, icon: 'Van', color: 'text-purple-500' },
{ key: 'COMPLETED', label: '已完成', count: 0, icon: 'CircleCheck', color: 'text-green-500' },
{ key: 'CANCELLED', label: '已取消', count: 0, icon: 'CircleClose', color: 'text-gray-400' },
])
// 格式化时间
const formatTime = (time: string) => {
return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
}
// 处理图片错误
const handleImageError = (e: Event) => {
const target = e.target as HTMLImageElement
target.src = '/default-product.png'
}
// 获取状态类型
const getStatusType = (status: string) => {
const map: Record<string, string> = {
'PENDING': 'warning',
'PAID': 'primary',
'SHIPPED': 'primary',
'COMPLETED': 'success',
'CANCELLED': 'info',
'REFUNDED': 'danger'
}
return map[status] || 'info'
}
// 获取状态文本
const getStatusText = (status: string) => {
const map: Record<string, string> = {
'PENDING': '待付款',
'PAID': '待发货',
'SHIPPED': '待收货',
'COMPLETED': '已完成',
'CANCELLED': '已取消',
'REFUNDED': '已退款'
}
return map[status] || status
}
// 加载订单列表
const loadOrders = async () => {
loading.value = true
try {
const res = await orderApi.getList({
...filters,
page: pagination.page - 1,
size: pagination.size
})
if (res.success) {
orders.value = res.data.content
pagination.total = res.data.totalElements
}
} catch (error) {
console.error('加载订单失败:', error)
} finally {
loading.value = false
}
}
// 加载订单统计
const loadStatistics = async () => {
try {
const res = await orderApi.getStatistics()
if (res.success) {
const stats = res.data
orderStats.value[0].count = stats.total
orderStats.value[1].count = stats.pending
orderStats.value[2].count = stats.paid
orderStats.value[3].count = stats.shipped
orderStats.value[4].count = stats.completed
orderStats.value[5].count = stats.cancelled
}
} catch (error) {
console.error('加载统计失败:', error)
}
}
// 状态筛选
const handleStatusFilter = (status: string) => {
filters.status = status
pagination.page = 1
loadOrders()
}
// 查看详情
const handleViewDetail = (orderId: number) => {
router.push(`/order/${orderId}`)
}
// 付款
const handlePay = (order: Order) => {
ElMessageBox.confirm(
`订单金额:¥${order.totalAmount},确认付款?`,
'付款确认',
{
confirmButtonText: '确认付款',
cancelButtonText: '取消',
type: 'warning',
}
).then(async () => {
try {
await orderApi.pay(order.id, 'ONLINE')
ElMessage.success('付款成功')
loadOrders()
loadStatistics()
} catch (error) {
console.error('付款失败:', error)
}
})
}
// 取消订单
const handleCancel = async (order: Order) => {
await ElMessageBox.confirm('确定要取消该订单吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
try {
await orderApi.cancel(order.id)
ElMessage.success('订单已取消')
loadOrders()
loadStatistics()
} catch (error) {
console.error('取消订单失败:', error)
}
}
// 确认收货
const handleConfirm = async (order: Order) => {
await ElMessageBox.confirm('确定已收到商品?', '确认收货', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
try {
await orderApi.confirm(order.id)
ElMessage.success('已确认收货')
loadOrders()
loadStatistics()
} catch (error) {
console.error('确认收货失败:', error)
}
}
// 评价
const handleReview = (order: Order) => {
ElMessage.info('评价功能开发中...')
}
// 再次购买
const handleRebuy = (order: Order) => {
// 将商品重新加入购物车
ElMessage.success('商品已加入购物车')
router.push('/cart')
}
// 删除订单
const handleDelete = async (order: Order) => {
await ElMessageBox.confirm('确定要删除该订单吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
try {
await orderApi.delete(order.id)
ElMessage.success('订单已删除')
loadOrders()
loadStatistics()
} catch (error) {
console.error('删除订单失败:', error)
}
}
onMounted(() => {
loadOrders()
loadStatistics()
})
</script>
<style scoped lang="scss">
.orders-page {
min-height: calc(100vh - 60px);
background-color: #f5f5f5;
}
</style>

View File

@@ -0,0 +1,314 @@
<template>
<div class="product-detail-page">
<div class="container mx-auto px-4 py-8">
<!-- 面包屑 -->
<el-breadcrumb separator="/" class="mb-6">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item :to="{ path: '/products' }">商品列表</el-breadcrumb-item>
<el-breadcrumb-item>{{ product?.name || '商品详情' }}</el-breadcrumb-item>
</el-breadcrumb>
<div v-if="loading" class="text-center py-12">
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
<p class="mt-2 text-gray-500">加载中...</p>
</div>
<div v-else-if="!product" class="text-center py-12">
<el-empty description="商品不存在" />
<el-button type="primary" @click="router.push('/products')">
返回商品列表
</el-button>
</div>
<div v-else class="bg-white rounded-lg shadow-lg overflow-hidden">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 p-8">
<!-- 左侧商品图片 -->
<div>
<div class="relative">
<img
:src="currentImage || '/default-product.png'"
:alt="product.name"
class="w-full rounded-lg"
@error="handleImageError"
>
<!-- 商品状态 -->
<div v-if="product.stock === 0" class="absolute top-4 right-4">
<el-tag type="info" size="large">已售罄</el-tag>
</div>
</div>
<!-- 图片列表 -->
<div v-if="product.images && product.images.length > 1" class="mt-4 flex gap-2 overflow-x-auto">
<img
v-for="(img, index) in product.images"
:key="index"
:src="img"
:alt="`${product.name}-${index}`"
class="w-20 h-20 object-cover rounded cursor-pointer border-2"
:class="currentImage === img ? 'border-primary-500' : 'border-transparent'"
@click="currentImage = img"
@error="handleImageError"
>
</div>
</div>
<!-- 右侧商品信息 -->
<div>
<h1 class="text-3xl font-bold mb-4">{{ product.name }}</h1>
<!-- 商品描述 -->
<p class="text-gray-600 mb-6">{{ product.description || '暂无描述' }}</p>
<!-- 价格 -->
<div class="bg-gray-50 rounded-lg p-6 mb-6">
<div class="flex items-end mb-4">
<span class="text-sm text-gray-500 mr-2">价格</span>
<span class="text-4xl font-bold text-red-500">¥{{ product.price }}</span>
</div>
<!-- 销售信息 -->
<div class="grid grid-cols-3 gap-4 text-sm">
<div>
<span class="text-gray-500">销量</span>
<p class="font-semibold">{{ product.sales || 0 }}</p>
</div>
<div>
<span class="text-gray-500">库存</span>
<p class="font-semibold">{{ product.stock }}</p>
</div>
<div>
<span class="text-gray-500">浏览</span>
<p class="font-semibold">{{ product.views || 0 }}</p>
</div>
</div>
</div>
<!-- 购买数量 -->
<div class="mb-6">
<label class="block text-sm text-gray-600 mb-2">购买数量</label>
<el-input-number
v-model="quantity"
:min="1"
:max="product.stock"
:disabled="product.stock === 0"
/>
<span class="ml-3 text-sm text-gray-500">
库存 {{ product.stock }}
</span>
</div>
<!-- 操作按钮 -->
<div class="flex gap-4 mb-6">
<el-button
type="primary"
size="large"
:disabled="product.stock === 0"
@click="handleAddToCart"
>
<el-icon class="mr-2"><ShoppingCart /></el-icon>
加入购物车
</el-button>
<el-button
type="danger"
size="large"
:disabled="product.stock === 0"
@click="handleBuyNow"
>
立即购买
</el-button>
<el-button size="large" @click="handleFavorite">
<el-icon class="mr-1">
<StarFilled v-if="isFavorited" class="text-yellow-500" />
<Star v-else />
</el-icon>
{{ isFavorited ? '已收藏' : '收藏' }}
</el-button>
</div>
<!-- 商品信息 -->
<div class="border-t pt-6">
<h3 class="font-semibold mb-4">商品信息</h3>
<dl class="grid grid-cols-2 gap-4 text-sm">
<div>
<dt class="text-gray-500">商品编号</dt>
<dd class="font-medium">{{ product.id }}</dd>
</div>
<div>
<dt class="text-gray-500">商品分类</dt>
<dd class="font-medium">{{ product.category }}</dd>
</div>
<div>
<dt class="text-gray-500">上架时间</dt>
<dd class="font-medium">{{ formatTime(product.createdAt) }}</dd>
</div>
<div>
<dt class="text-gray-500">更新时间</dt>
<dd class="font-medium">{{ formatTime(product.updatedAt) }}</dd>
</div>
</dl>
</div>
</div>
</div>
<!-- 商品详情 -->
<div class="border-t">
<el-tabs v-model="activeTab" class="px-8">
<el-tab-pane label="商品详情" name="detail">
<div class="py-6">
<div class="prose max-w-none" v-html="product.description || '暂无详细描述'"></div>
</div>
</el-tab-pane>
<el-tab-pane label="规格参数" name="specs">
<div class="py-6">
<p class="text-gray-500">暂无规格参数</p>
</div>
</el-tab-pane>
<el-tab-pane label="售后保障" name="service">
<div class="py-6">
<ul class="space-y-2 text-gray-600">
<li> 7天无理由退换</li>
<li> 正品保证</li>
<li> 极速发货</li>
<li> 售后无忧</li>
</ul>
</div>
</el-tab-pane>
</el-tabs>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { productApi } from '@/api/modules/product'
import { useCartStore } from '@/stores/cart'
import { useUserStore } from '@/stores/user'
import type { Product } from '@/types/api'
import dayjs from 'dayjs'
const route = useRoute()
const router = useRouter()
const cartStore = useCartStore()
const userStore = useUserStore()
const loading = ref(false)
const product = ref<Product | null>(null)
const currentImage = ref('')
const quantity = ref(1)
const activeTab = ref('detail')
const isFavorited = ref(false)
// 格式化时间
const formatTime = (time: string) => {
return dayjs(time).format('YYYY-MM-DD')
}
// 处理图片错误
const handleImageError = (e: Event) => {
const target = e.target as HTMLImageElement
target.src = '/default-product.png'
}
// 加载商品详情
const loadProductDetail = async () => {
loading.value = true
try {
const id = Number(route.params.id)
const res = await productApi.getDetail(id)
if (res.success) {
product.value = res.data
currentImage.value = res.data.imageUrl || res.data.images?.[0] || ''
}
} catch (error) {
console.error('加载商品详情失败:', error)
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
// 加入购物车
const handleAddToCart = async () => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push({
path: '/login',
query: { redirect: route.fullPath }
})
return
}
if (!product.value) return
const success = await cartStore.addToCart(product.value.id, quantity.value)
if (success) {
quantity.value = 1
}
}
// 立即购买
const handleBuyNow = async () => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push({
path: '/login',
query: { redirect: route.fullPath }
})
return
}
if (!product.value) return
// 先加入购物车
const success = await cartStore.addToCart(product.value.id, quantity.value)
if (success) {
// 跳转到购物车
router.push('/cart')
}
}
// 收藏/取消收藏
const handleFavorite = () => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push({
path: '/login',
query: { redirect: route.fullPath }
})
return
}
isFavorited.value = !isFavorited.value
ElMessage.success(isFavorited.value ? '已收藏' : '已取消收藏')
}
onMounted(() => {
loadProductDetail()
})
</script>
<style scoped lang="scss">
.product-detail-page {
min-height: calc(100vh - 60px);
background-color: #f5f5f5;
}
.prose {
max-width: none;
:deep(img) {
max-width: 100%;
height: auto;
}
}
</style>

View File

@@ -0,0 +1,227 @@
<template>
<div class="products-page">
<div class="container mx-auto px-4 py-8">
<!-- 页面标题 -->
<div class="mb-8">
<h1 class="text-3xl font-bold mb-2 flex items-center">
<el-icon class="text-blue-500 mr-2"><ShoppingBag /></el-icon>
商品列表
</h1>
<p class="text-gray-600">精选好物品质保证</p>
</div>
<!-- 筛选栏 -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
<div class="flex flex-wrap gap-4 items-center">
<!-- 分类筛选 -->
<el-select
v-model="filters.category"
placeholder="选择分类"
clearable
style="width: 150px"
@change="loadProducts"
>
<el-option
v-for="cat in categories"
:key="cat"
:label="cat"
:value="cat"
/>
</el-select>
<!-- 价格区间 -->
<div class="flex items-center gap-2">
<el-input-number
v-model="filters.minPrice"
:min="0"
placeholder="最低价"
style="width: 120px"
@change="loadProducts"
/>
<span>-</span>
<el-input-number
v-model="filters.maxPrice"
:min="0"
placeholder="最高价"
style="width: 120px"
@change="loadProducts"
/>
</div>
<!-- 排序 -->
<el-radio-group v-model="filters.sort" @change="loadProducts">
<el-radio-button label="default">默认</el-radio-button>
<el-radio-button label="price-asc">价格升序</el-radio-button>
<el-radio-button label="price-desc">价格降序</el-radio-button>
<el-radio-button label="sales">销量</el-radio-button>
</el-radio-group>
<!-- 搜索 -->
<el-input
v-model="filters.keyword"
placeholder="搜索商品"
style="width: 200px"
clearable
@keyup.enter="loadProducts"
>
<template #suffix>
<el-icon class="cursor-pointer" @click="loadProducts">
<Search />
</el-icon>
</template>
</el-input>
</div>
</div>
<!-- 商品列表 -->
<div v-if="loading" class="text-center py-12">
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
<p class="mt-2 text-gray-500">加载中...</p>
</div>
<div v-else-if="products.length === 0" class="text-center py-12">
<el-empty description="暂无商品" />
</div>
<div v-else>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<ProductCard
v-for="item in products"
:key="item.id"
:data="item"
@add-to-cart="handleAddToCart"
/>
</div>
<!-- 分页 -->
<div class="mt-8 flex justify-center">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
:total="pagination.total"
:page-sizes="[12, 24, 36, 48]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadProducts"
@current-change="loadProducts"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import ProductCard from '@/components/business/ProductCard.vue'
import { productApi } from '@/api/modules/product'
import { useCartStore } from '@/stores/cart'
import { useUserStore } from '@/stores/user'
import type { Product } from '@/types/api'
const route = useRoute()
const router = useRouter()
const cartStore = useCartStore()
const userStore = useUserStore()
// 数据状态
const loading = ref(false)
const products = ref<Product[]>([])
const categories = ref<string[]>([])
// 筛选条件
const filters = reactive({
keyword: '',
category: '',
minPrice: undefined as number | undefined,
maxPrice: undefined as number | undefined,
sort: 'default'
})
// 分页
const pagination = reactive({
page: 1,
size: 12,
total: 0
})
// 加载商品列表
const loadProducts = async () => {
loading.value = true
try {
const params: any = {
page: pagination.page - 1,
size: pagination.size
}
if (filters.keyword) params.keyword = filters.keyword
if (filters.category) params.category = filters.category
if (filters.minPrice !== undefined) params.minPrice = filters.minPrice
if (filters.maxPrice !== undefined) params.maxPrice = filters.maxPrice
// 处理排序
if (filters.sort === 'price-asc') {
params.sort = 'price'
params.order = 'asc'
} else if (filters.sort === 'price-desc') {
params.sort = 'price'
params.order = 'desc'
} else if (filters.sort === 'sales') {
params.sort = 'sales'
params.order = 'desc'
}
const res = await productApi.getList(params)
if (res.success) {
products.value = res.data.content
pagination.total = res.data.totalElements
}
} catch (error) {
console.error('加载商品列表失败:', error)
} finally {
loading.value = false
}
}
// 加载分类
const loadCategories = async () => {
try {
const res = await productApi.getCategories()
if (res.success) {
categories.value = res.data
}
} catch (error) {
console.error('加载分类失败:', error)
}
}
// 添加到购物车
const handleAddToCart = async (productId: number) => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push('/login')
return
}
await cartStore.addToCart(productId)
}
onMounted(() => {
// 从路由参数获取搜索关键词
if (route.query.keyword) {
filters.keyword = route.query.keyword as string
}
loadCategories()
loadProducts()
})
</script>
<style scoped lang="scss">
.products-page {
min-height: calc(100vh - 60px);
background-color: #f5f5f5;
}
</style>

View File

@@ -0,0 +1,165 @@
<template>
<div class="login-page min-h-screen flex items-center justify-center bg-gray-50">
<div class="max-w-md w-full">
<div class="bg-white rounded-lg shadow-lg p-8">
<!-- Logo -->
<div class="text-center mb-8">
<el-icon :size="48" class="text-red-500 mb-4">
<Lightning />
</el-icon>
<h1 class="text-2xl font-bold text-gray-900">欢迎回来</h1>
<p class="text-gray-600 mt-2">登录到秒杀系统</p>
</div>
<!-- 登录表单 -->
<el-form
ref="formRef"
:model="loginForm"
:rules="rules"
@submit.prevent
>
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
size="large"
placeholder="请输入用户名"
prefix-icon="User"
clearable
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
size="large"
placeholder="请输入密码"
prefix-icon="Lock"
show-password
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item>
<div class="flex justify-between items-center w-full">
<el-checkbox v-model="loginForm.rememberMe">记住我</el-checkbox>
<el-link type="primary" :underline="false">忘记密码</el-link>
</div>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
class="w-full"
:loading="loading"
@click="handleLogin"
>
</el-button>
</el-form-item>
<el-divider></el-divider>
<!-- 快速登录 -->
<div class="mb-4">
<el-button size="large" class="w-full mb-2" @click="quickLogin('user')">
<el-icon class="mr-2"><User /></el-icon>
普通用户快速登录
</el-button>
<el-button size="large" class="w-full" @click="quickLogin('admin')">
<el-icon class="mr-2"><Setting /></el-icon>
管理员快速登录
</el-button>
</div>
<div class="text-center">
<span class="text-gray-600">还没有账号</span>
<router-link to="/register" class="text-primary-500 hover:underline">
立即注册
</router-link>
</div>
</el-form>
</div>
<!-- 测试账号提示 -->
<div class="mt-4 p-4 bg-blue-50 rounded-lg">
<h3 class="font-semibold text-blue-900 mb-2">测试账号</h3>
<div class="text-sm text-blue-700">
<p>普通用户: user / 123456</p>
<p>管理员: admin / 123456</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElForm } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useUserStore } from '@/stores/user'
import type { LoginParams } from '@/types/api'
const router = useRouter()
const userStore = useUserStore()
const formRef = ref<FormInstance>()
const loading = ref(false)
const loginForm = reactive<LoginParams>({
username: '',
password: '',
rememberMe: false
})
const rules: FormRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' }
]
}
// 登录
const handleLogin = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
loading.value = true
try {
const success = await userStore.login(loginForm)
if (success) {
// 登录成功router跳转已在store中处理
}
} finally {
loading.value = false
}
}
})
}
// 快速登录
const quickLogin = (type: 'user' | 'admin') => {
if (type === 'user') {
loginForm.username = 'user'
loginForm.password = '123456'
} else {
loginForm.username = 'admin'
loginForm.password = '123456'
}
loginForm.rememberMe = true
handleLogin()
}
</script>
<style scoped lang="scss">
.login-page {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
</style>

View File

@@ -0,0 +1,309 @@
<template>
<div class="profile-page">
<div class="container mx-auto px-4 py-8">
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
<!-- 左侧菜单 -->
<div class="lg:col-span-1">
<div class="bg-white rounded-lg shadow-sm p-6">
<!-- 用户头像 -->
<div class="text-center mb-6">
<el-avatar :size="80" :src="userStore.user?.avatar">
{{ userStore.username[0] }}
</el-avatar>
<h3 class="mt-3 font-semibold">{{ userStore.username }}</h3>
<p class="text-sm text-gray-500">{{ userStore.user?.email }}</p>
</div>
<!-- 菜单列表 -->
<el-menu
:default-active="activeMenu"
@select="handleMenuSelect"
>
<el-menu-item index="info">
<el-icon><User /></el-icon>
<span>基本信息</span>
</el-menu-item>
<el-menu-item index="security">
<el-icon><Lock /></el-icon>
<span>账号安全</span>
</el-menu-item>
<el-menu-item index="address">
<el-icon><Location /></el-icon>
<span>收货地址</span>
</el-menu-item>
<el-menu-item index="orders">
<el-icon><List /></el-icon>
<span>我的订单</span>
</el-menu-item>
</el-menu>
</div>
</div>
<!-- 右侧内容 -->
<div class="lg:col-span-3">
<div class="bg-white rounded-lg shadow-sm p-6">
<!-- 基本信息 -->
<div v-if="activeMenu === 'info'">
<h2 class="text-xl font-semibold mb-6">基本信息</h2>
<el-form
ref="infoFormRef"
:model="infoForm"
:rules="infoRules"
label-width="100px"
>
<el-form-item label="用户名" prop="username">
<el-input v-model="infoForm.username" disabled />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="infoForm.email" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="infoForm.phone" />
</el-form-item>
<el-form-item label="头像">
<div class="flex items-center gap-4">
<el-avatar :size="60" :src="infoForm.avatar">
{{ infoForm.username[0] }}
</el-avatar>
<el-button>更换头像</el-button>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSaveInfo">
保存修改
</el-button>
</el-form-item>
</el-form>
</div>
<!-- 账号安全 -->
<div v-else-if="activeMenu === 'security'">
<h2 class="text-xl font-semibold mb-6">账号安全</h2>
<el-form
ref="passwordFormRef"
:model="passwordForm"
:rules="passwordRules"
label-width="100px"
>
<el-form-item label="原密码" prop="oldPassword">
<el-input
v-model="passwordForm.oldPassword"
type="password"
show-password
/>
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input
v-model="passwordForm.newPassword"
type="password"
show-password
/>
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input
v-model="passwordForm.confirmPassword"
type="password"
show-password
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleChangePassword">
修改密码
</el-button>
</el-form-item>
</el-form>
</div>
<!-- 收货地址 -->
<div v-else-if="activeMenu === 'address'">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-semibold">收货地址</h2>
<el-button type="primary" @click="handleAddAddress">
<el-icon class="mr-1"><Plus /></el-icon>
添加地址
</el-button>
</div>
<div class="space-y-4">
<div
v-for="addr in addresses"
:key="addr.id"
class="border rounded-lg p-4"
>
<div class="flex justify-between items-start">
<div>
<div class="flex items-center gap-2 mb-2">
<span class="font-semibold">{{ addr.name }}</span>
<span class="text-gray-500">{{ addr.phone }}</span>
<el-tag v-if="addr.isDefault" type="primary" size="small">
默认
</el-tag>
</div>
<p class="text-gray-600">
{{ addr.province }} {{ addr.city }} {{ addr.district }} {{ addr.address }}
</p>
</div>
<div class="space-x-2">
<el-button text type="primary" size="small">编辑</el-button>
<el-button text type="danger" size="small">删除</el-button>
</div>
</div>
</div>
<el-empty v-if="addresses.length === 0" description="暂无收货地址" />
</div>
</div>
<!-- 我的订单 -->
<div v-else-if="activeMenu === 'orders'">
<h2 class="text-xl font-semibold mb-6">我的订单</h2>
<p class="text-gray-500">请访问 <router-link to="/orders" class="text-primary-500">订单页面</router-link> 查看详细订单信息</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElForm } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { userApi } from '@/api/modules/user'
const router = useRouter()
const userStore = useUserStore()
const activeMenu = ref('info')
const infoFormRef = ref<FormInstance>()
const passwordFormRef = ref<FormInstance>()
// 基本信息表单
const infoForm = reactive({
username: userStore.user?.username || '',
email: userStore.user?.email || '',
phone: userStore.user?.phone || '',
avatar: userStore.user?.avatar || ''
})
const infoRules: FormRules = {
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
],
phone: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
]
}
// 修改密码表单
const passwordForm = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
const validatePassword = (rule: any, value: any, callback: any) => {
if (value === '') {
callback(new Error('请再次输入密码'))
} else if (value !== passwordForm.newPassword) {
callback(new Error('两次输入密码不一致'))
} else {
callback()
}
}
const passwordRules: FormRules = {
oldPassword: [
{ required: true, message: '请输入原密码', trigger: 'blur' }
],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' }
],
confirmPassword: [
{ required: true, validator: validatePassword, trigger: 'blur' }
]
}
// 收货地址
const addresses = ref<any[]>([])
// 菜单选择
const handleMenuSelect = (index: string) => {
activeMenu.value = index
if (index === 'orders') {
router.push('/orders')
}
}
// 保存基本信息
const handleSaveInfo = async () => {
if (!infoFormRef.value) return
await infoFormRef.value.validate(async (valid) => {
if (valid) {
try {
const res = await userApi.updateInfo(infoForm)
if (res.success) {
userStore.updateUserInfo(infoForm)
ElMessage.success('保存成功')
}
} catch (error) {
console.error('保存失败:', error)
}
}
})
}
// 修改密码
const handleChangePassword = async () => {
if (!passwordFormRef.value) return
await passwordFormRef.value.validate(async (valid) => {
if (valid) {
try {
const res = await userApi.changePassword({
oldPassword: passwordForm.oldPassword,
newPassword: passwordForm.newPassword
})
if (res.success) {
ElMessage.success('密码修改成功,请重新登录')
userStore.logout()
}
} catch (error) {
console.error('修改密码失败:', error)
}
}
})
}
// 添加地址
const handleAddAddress = () => {
ElMessage.info('功能开发中...')
}
onMounted(() => {
// 加载用户信息
userStore.getUserInfo()
})
</script>
<style scoped lang="scss">
.profile-page {
min-height: calc(100vh - 60px);
background-color: #f5f5f5;
}
</style>

View File

@@ -0,0 +1,197 @@
<template>
<div class="register-page min-h-screen flex items-center justify-center bg-gray-50">
<div class="max-w-md w-full">
<div class="bg-white rounded-lg shadow-lg p-8">
<!-- Logo -->
<div class="text-center mb-8">
<el-icon :size="48" class="text-red-500 mb-4">
<Lightning />
</el-icon>
<h1 class="text-2xl font-bold text-gray-900">创建账号</h1>
<p class="text-gray-600 mt-2">加入秒杀系统</p>
</div>
<!-- 注册表单 -->
<el-form
ref="formRef"
:model="registerForm"
:rules="rules"
@submit.prevent
>
<el-form-item prop="username">
<el-input
v-model="registerForm.username"
size="large"
placeholder="请输入用户名"
prefix-icon="User"
clearable
/>
</el-form-item>
<el-form-item prop="email">
<el-input
v-model="registerForm.email"
size="large"
placeholder="请输入邮箱"
prefix-icon="Message"
clearable
/>
</el-form-item>
<el-form-item prop="phone">
<el-input
v-model="registerForm.phone"
size="large"
placeholder="请输入手机号(选填)"
prefix-icon="Phone"
clearable
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="registerForm.password"
type="password"
size="large"
placeholder="请输入密码"
prefix-icon="Lock"
show-password
/>
</el-form-item>
<el-form-item prop="confirmPassword">
<el-input
v-model="registerForm.confirmPassword"
type="password"
size="large"
placeholder="请确认密码"
prefix-icon="Lock"
show-password
@keyup.enter="handleRegister"
/>
</el-form-item>
<el-form-item prop="agreement">
<el-checkbox v-model="registerForm.agreement">
我已阅读并同意
<el-link type="primary" :underline="false">用户协议</el-link>
<el-link type="primary" :underline="false">隐私政策</el-link>
</el-checkbox>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
class="w-full"
:loading="loading"
@click="handleRegister"
>
</el-button>
</el-form-item>
<div class="text-center">
<span class="text-gray-600">已有账号</span>
<router-link to="/login" class="text-primary-500 hover:underline">
立即登录
</router-link>
</div>
</el-form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElForm } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const userStore = useUserStore()
const formRef = ref<FormInstance>()
const loading = ref(false)
const registerForm = reactive({
username: '',
email: '',
phone: '',
password: '',
confirmPassword: '',
agreement: false
})
const validatePassword = (rule: any, value: any, callback: any) => {
if (value === '') {
callback(new Error('请再次输入密码'))
} else if (value !== registerForm.password) {
callback(new Error('两次输入密码不一致'))
} else {
callback()
}
}
const validateAgreement = (rule: any, value: any, callback: any) => {
if (!value) {
callback(new Error('请同意用户协议'))
} else {
callback()
}
}
const rules: FormRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9_]+$/, message: '用户名只能包含字母、数字和下划线', trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
],
phone: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' }
],
confirmPassword: [
{ required: true, validator: validatePassword, trigger: 'blur' }
],
agreement: [
{ required: true, validator: validateAgreement, trigger: 'change' }
]
}
// 注册
const handleRegister = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
loading.value = true
try {
const { confirmPassword, agreement, ...params } = registerForm
const success = await userStore.register(params)
if (success) {
// 注册成功跳转到登录页已在store中处理
}
} finally {
loading.value = false
}
}
})
}
</script>
<style scoped lang="scss">
.register-page {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
</style>