feat: 前端页面和组件全面完善

- 优化通用组件:导航栏、页脚、图片上传、搜索
- 完善业务组件:商品卡片、秒杀卡片
- 更新用户端页面:首页、商品、秒杀、订单、购物车、个人中心
- 新增用户收藏页面
- 完善管理后台:仪表盘、商品/订单/用户/秒杀管理
- 新增管理后台:收藏管理、评价管理、系统监控页面
This commit is contained in:
2026-03-10 23:21:53 +08:00
parent abba469a20
commit c52d9c52e3
25 changed files with 3409 additions and 1467 deletions

View File

@@ -1,38 +1,32 @@
<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>
<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'"
<SafeImage
:src="flashSale.productImageUrl"
:alt="flashSale.productName"
class="w-full rounded-lg"
@error="handleImageError"
>
<!-- 状态标签 -->
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>
@@ -41,104 +35,70 @@
</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>
<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>
<span class="text-sm text-gray-500">剩余 {{ flashSale.remainingStock }} / {{ flashSale.flashStock }} </span>
</div>
<el-progress
:percentage="stockPercent"
:stroke-width="10"
:color="progressColor"
/>
<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"
/>
<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-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>
<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 class="mt-8 p-4 bg-yellow-50 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>
<h3 class="text-xl font-semibold mb-4">活动说明</h3>
<div class="text-gray-600" v-html="flashSale.description"></div>
</div>
</div>
@@ -151,6 +111,7 @@ 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'
@@ -164,7 +125,8 @@ 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) {
@@ -179,9 +141,9 @@ const statusText = computed(() => {
if (!flashSale.value) return ''
switch (flashSale.value.status) {
case 'UPCOMING': return '即将开始'
case 'ACTIVE': return '秒杀进行中'
case 'ACTIVE': return '秒杀中'
case 'ENDED': return '已结束'
default: return '未知'
default: return ''
}
})
@@ -192,7 +154,7 @@ const discountPercent = computed(() => {
const stockPercent = computed(() => {
if (!flashSale.value || flashSale.value.flashStock === 0) return 0
return Math.round(flashSale.value.remainingStock / flashSale.value.flashStock * 100)
return Math.round((flashSale.value.remainingStock / flashSale.value.flashStock) * 100)
})
const progressColor = computed(() => {
@@ -219,27 +181,11 @@ const buttonText = computed(() => {
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 () => {
const loadDetail = async () => {
loading.value = true
try {
const id = Number(route.params.id)
const res = await flashsaleApi.getDetail(id)
if (res.success) {
flashSale.value = res.data
}
const res = await flashsaleApi.getDetail(Number(route.params.id))
if (res.success) flashSale.value = res.data
} catch (error) {
console.error('加载秒杀详情失败:', error)
ElMessage.error('加载失败')
@@ -248,81 +194,49 @@ const loadFlashSaleDetail = async () => {
}
}
// 参与秒杀
const handleParticipate = async () => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push({
path: '/login',
query: { redirect: route.fullPath }
})
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 {
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
})
const res = await flashsaleApi.participate({ flashSaleId: flashSale.value.id, quantity: 1 })
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 || '抢购失败')
ElMessage.success('抢购成功')
router.push(`/order/${res.data.orderId}`)
}
} catch (error) {
console.error('参与秒杀失败:', error)
} finally {
participating.value = false
// 重新加载详情
loadFlashSaleDetail()
}
}
// 查看商品详情
const handleViewProduct = () => {
if (flashSale.value) {
router.push(`/product/${flashSale.value.productId}`)
}
if (flashSale.value) router.push(`/product/${flashSale.value.productId}`)
}
// 分享
const handleShare = () => {
ElMessage.success('分享链接已复制')
// 实际实现复制链接到剪贴板
const url = window.location.href
navigator.clipboard.writeText(url)
const handleShare = async () => {
await navigator.clipboard.writeText(window.location.href)
ElMessage.success('链接已复制,快去分享吧')
}
// 倒计时结束
const handleFinish = () => {
loadFlashSaleDetail()
loadDetail()
}
onMounted(() => {
loadFlashSaleDetail()
loadDetail()
})
</script>
@@ -331,4 +245,4 @@ onMounted(() => {
min-height: calc(100vh - 60px);
background-color: #f5f5f5;
}
</style>
</style>