后端功能增强:全局异常处理、API控制器、JSP视图和单元测试
- 添加 GlobalExceptionHandler 全局异常处理 - 添加 ApiController REST API 控制器 - 更新 WebConfig 跨域配置和 ProductRepository 查询方法 - 新增 monitor/product-detail/profile JSP 视图页面 - 添加 FlashSaleServiceTest 秒杀服务单元测试 - 更新 application.yml 配置
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user