Files
FlashSaleSystem/flash-sale-frontend/src/pages/flashsale/detail.vue
YoVinchen c4582655d9 feat: 删除JSP视图层,完善评价和通知系统,新增拼团模块
- 删除所有 JSP 页面(20个文件),前端完全迁移至 Vue 3 SPA
- 完善评价系统:ReviewDialog 组件、用户评价历史页、评价状态检查API
- 新增通知系统:Notification 实体/仓库/服务/控制器,NotificationCenter 接入真实API
- 新增拼团模块:GroupBuying 全套后端和前端页面
- 修复 review check API 参数双重包装导致请求格式错误
- 修复通知 API 路径缺少 /api 前缀和响应格式处理
- MessageListenerService 集成 NotificationService 创建持久化通知

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 16:40:26 +08:00

273 lines
9.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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">
<SafeImage
:src="flashSale.productImageUrl"
:alt="flashSale.productName"
wrapper-class="w-full rounded-lg overflow-hidden bg-gray-100"
img-class="w-full rounded-lg object-cover"
/>
<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="price-card 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="detail-price">¥{{ flashSale.flashPrice }}</span>
<span class="ml-4 text-lg text-gray-400 line-through">¥{{ flashSale.originalPrice }}</span>
<span class="discount-pill">{{ 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="note-card mb-6 p-4 rounded-lg">
<div class="flex items-center">
<el-icon class="mr-2"><InfoFilled /></el-icon>
<span>每人限购 {{ flashSale.limitPerUser }} </span>
</div>
</div>
<div class="space-y-4">
<el-button type="primary" 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="rules-card mt-8 p-4 rounded-lg">
<h3 class="font-semibold mb-2">抢购说明</h3>
<ul class="text-sm text-gray-600 space-y-1">
<li> 秒杀商品数量有限先到先得</li>
<li> 每个用户限购{{ flashSale.limitPerUser }}</li>
<li> 下单后请在30分钟内完成支付</li>
<li> 商品一经售出非质量问题不支持退换</li>
</ul>
</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 SafeImage from '@/components/common/SafeImage.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 formatTime = (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss')
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 '#171715'
if (stockPercent.value > 20) return '#5e5e58'
return '#9f9f99'
})
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 loadDetail = async () => {
loading.value = true
try {
const res = await flashsaleApi.getDetail(Number(route.params.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
await ElMessageBox.confirm(`确定要抢购该商品吗?\n秒杀价¥${flashSale.value.flashPrice}`, '抢购确认', {
confirmButtonText: '立即抢购',
cancelButtonText: '取消',
type: 'warning',
})
participating.value = true
try {
const res = await flashsaleApi.participate({ flashSaleId: flashSale.value.id, quantity: 1 })
if (res.success) {
ElMessage.success('抢购成功')
router.push(`/order/${res.data.orderId}`)
}
} catch (error) {
console.error('参与秒杀失败:', error)
} finally {
participating.value = false
}
}
const handleViewProduct = () => {
if (flashSale.value) router.push(`/product/${flashSale.value.productId}`)
}
const handleShare = async () => {
await navigator.clipboard.writeText(window.location.href)
ElMessage.success('链接已复制,快去分享吧')
}
const handleFinish = () => {
loadDetail()
}
onMounted(() => {
loadDetail()
})
</script>
<style scoped lang="scss">
.flashsale-detail-page {
min-height: calc(100vh - 60px);
background: transparent;
}
.price-card,
.note-card,
.rules-card {
background: #fffaf2;
border: 1px solid #d8cebf;
}
.detail-price {
font-size: 2.25rem;
font-weight: 700;
color: #171715;
}
.discount-pill {
margin-left: 8px;
padding: 4px 10px;
border-radius: 999px;
background: #fffaf2;
color: #171715;
border: 1px solid #d8cebf;
font-size: 12px;
font-weight: 700;
}
</style>