- 删除所有 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>
273 lines
9.1 KiB
Vue
273 lines
9.1 KiB
Vue
<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>
|