后端功能增强:全局异常处理、API控制器、JSP视图和单元测试
- 添加 GlobalExceptionHandler 全局异常处理 - 添加 ApiController REST API 控制器 - 更新 WebConfig 跨域配置和 ProductRepository 查询方法 - 新增 monitor/product-detail/profile JSP 视图页面 - 添加 FlashSaleServiceTest 秒杀服务单元测试 - 更新 application.yml 配置
This commit is contained in:
382
flash-sale-frontend/src/pages/admin/dashboard.vue
Normal file
382
flash-sale-frontend/src/pages/admin/dashboard.vue
Normal 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>
|
||||
48
flash-sale-frontend/src/pages/admin/flashsales.vue
Normal file
48
flash-sale-frontend/src/pages/admin/flashsales.vue
Normal 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>
|
||||
41
flash-sale-frontend/src/pages/admin/orders.vue
Normal file
41
flash-sale-frontend/src/pages/admin/orders.vue
Normal 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>
|
||||
46
flash-sale-frontend/src/pages/admin/products.vue
Normal file
46
flash-sale-frontend/src/pages/admin/products.vue
Normal 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>
|
||||
42
flash-sale-frontend/src/pages/admin/users.vue
Normal file
42
flash-sale-frontend/src/pages/admin/users.vue
Normal 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>
|
||||
342
flash-sale-frontend/src/pages/cart/index.vue
Normal file
342
flash-sale-frontend/src/pages/cart/index.vue
Normal 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>
|
||||
39
flash-sale-frontend/src/pages/error/404.vue
Normal file
39
flash-sale-frontend/src/pages/error/404.vue
Normal 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>
|
||||
334
flash-sale-frontend/src/pages/flashsale/detail.vue
Normal file
334
flash-sale-frontend/src/pages/flashsale/detail.vue
Normal 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>
|
||||
273
flash-sale-frontend/src/pages/flashsale/index.vue
Normal file
273
flash-sale-frontend/src/pages/flashsale/index.vue
Normal 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>
|
||||
260
flash-sale-frontend/src/pages/home/index.vue
Normal file
260
flash-sale-frontend/src/pages/home/index.vue
Normal 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>
|
||||
357
flash-sale-frontend/src/pages/order/detail.vue
Normal file
357
flash-sale-frontend/src/pages/order/detail.vue
Normal 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>
|
||||
407
flash-sale-frontend/src/pages/order/index.vue
Normal file
407
flash-sale-frontend/src/pages/order/index.vue
Normal 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>
|
||||
314
flash-sale-frontend/src/pages/product/detail.vue
Normal file
314
flash-sale-frontend/src/pages/product/detail.vue
Normal 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>
|
||||
227
flash-sale-frontend/src/pages/product/index.vue
Normal file
227
flash-sale-frontend/src/pages/product/index.vue
Normal 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>
|
||||
165
flash-sale-frontend/src/pages/user/login.vue
Normal file
165
flash-sale-frontend/src/pages/user/login.vue
Normal 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>
|
||||
309
flash-sale-frontend/src/pages/user/profile.vue
Normal file
309
flash-sale-frontend/src/pages/user/profile.vue
Normal 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>
|
||||
197
flash-sale-frontend/src/pages/user/register.vue
Normal file
197
flash-sale-frontend/src/pages/user/register.vue
Normal 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>
|
||||
Reference in New Issue
Block a user