Files
sale/frontend/src/views/user/ProductDetail.vue
T
admin 057244d4b3 fix: 添加购物车时检查购物资格
- 后端 CartHandler.Add 新增购物资格检查
- 后端新增库存检查和最大购买数量检查
- 前端商品详情页添加资格不足提示和视觉反馈
- 显示用户当前资格和所需资格总数

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:39:38 +08:00

253 lines
7.6 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="product-detail-page" v-if="product">
<BackNav />
<div class="detail-card">
<el-row :gutter="32">
<el-col :span="12">
<div class="product-images">
<el-carousel v-if="productImages.length > 1" height="400px" :autoplay="false" indicator-position="outside">
<el-carousel-item v-for="(img, index) in productImages" :key="index">
<el-image :src="img" fit="contain" class="carousel-image" :preview-src-list="productImages" :initial-index="index">
<template #error>
<div class="image-placeholder">
<el-icon size="80"><Goods /></el-icon>
</div>
</template>
</el-image>
</el-carousel-item>
</el-carousel>
<el-image v-else-if="productImages.length === 1" :src="productImages[0]" fit="contain" class="single-image" :preview-src-list="productImages">
<template #error>
<div class="image-placeholder">
<el-icon size="80"><Goods /></el-icon>
</div>
</template>
</el-image>
<div v-else class="image-placeholder">
<el-icon size="80"><Goods /></el-icon>
</div>
</div>
</el-col>
<el-col :span="12">
<h1>{{ product.name }}</h1>
<p class="price">¥{{ product.price }}</p>
<p class="description">{{ product.description }}</p>
<div class="meta">
<el-tag v-if="product.require_credit" :type="hasEnoughCredit() ? 'warning' : 'danger'">
{{ $t('product.requireCredit') }}: {{ product.credit_cost * quantity }}
<span v-if="!hasEnoughCredit()"> (资格不足)</span>
</el-tag>
<el-tag v-if="product.credit_reward > 0" type="success">+{{ product.credit_reward * quantity }} {{ $t('product.creditReward') }}</el-tag>
</div>
<div class="user-credits" v-if="product.require_credit">
<span class="credits-label">当前资格</span>
<span class="credits-value" :class="{ 'insufficient': !hasEnoughCredit() }">{{ userCredits }}</span>
</div>
<div class="custom-fields" v-if="product.custom_fields?.length">
<h4>{{ $t('product.customFields') }}</h4>
<div v-for="f in product.custom_fields" :key="f.id" class="field-item">
<span class="field-name">{{ f.field_name }}:</span>
<span>{{ f.field_value }}</span>
</div>
</div>
<div class="purchase-section">
<el-input-number v-model="quantity" :min="product.min_purchase || 1" :max="product.max_purchase || 999" />
<el-button type="primary" size="large" @click="addToCart">{{ $t('product.addToCart') }}</el-button>
<el-button type="success" size="large" @click="buyNow">{{ $t('product.buyNow') }}</el-button>
</div>
</el-col>
</el-row>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Goods } from '@element-plus/icons-vue'
import { productApi, userApi } from '../../api'
import { useCartStore } from '../../store/cart'
import { useI18n } from 'vue-i18n'
import { getImageUrl } from '../../utils/image'
import BackNav from '../../components/BackNav.vue'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const cartStore = useCartStore()
const product = ref<any>(null)
const quantity = ref(1)
const userCredits = ref(0)
const productImages = computed(() => {
if (!product.value?.images) return []
return product.value.images.split(',').map((s: string) => s.trim()).filter(Boolean).map((url: string) => getImageUrl(url))
})
// 检查用户是否有足够资格购买
function hasEnoughCredit(): boolean {
if (!product.value || !product.value.require_credit) return true
const totalCost = product.value.credit_cost * quantity.value
return userCredits.value >= totalCost
}
// 获取用户资格信息
async function fetchUserCredits() {
try {
const res: any = await userApi.getProfile()
userCredits.value = res.data?.purchase_credits || 0
} catch {}
}
onMounted(async () => {
const res: any = await productApi.getById(Number(route.params.id))
product.value = res.data
// 设置默认数量为最小购买量
if (product.value?.min_purchase && product.value.min_purchase > 1) {
quantity.value = product.value.min_purchase
}
// 获取用户资格信息
await fetchUserCredits()
})
async function addToCart() {
// 检查购物资格
if (!hasEnoughCredit()) {
const totalCost = product.value.credit_cost * quantity.value
ElMessage.warning(`资格不足!需要 ${totalCost} 资格,您当前只有 ${userCredits.value} 资格`)
return
}
try {
await cartStore.addItem(product.value.id, quantity.value)
ElMessage.success(t('product.addToCart') + ' ✓')
} catch (err: any) {
// 处理后端返回的错误
const errorMsg = err.response?.data?.error || '添加购物车失败'
ElMessage.error(errorMsg)
}
}
function buyNow() {
addToCart().then(() => {
// 只有成功添加后才跳转
router.push('/cart')
})
}
</script>
<style scoped lang="scss">
.product-detail-page { padding: 0; }
.detail-card { background: #2d2d44; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 32px; }
.product-images {
height: 400px;
background: rgba(255, 255, 255, 0.04);
border-radius: 12px;
overflow: hidden;
.carousel-image, .single-image {
width: 100%;
height: 400px;
display: flex;
align-items: center;
justify-content: center;
}
.image-placeholder {
width: 100%;
height: 400px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.2);
}
}
h1 { font-size: 28px; color: #fff; margin-bottom: 16px; }
.price { font-size: 32px; font-weight: 700; color: #4e6ef2; margin-bottom: 16px; }
.description { color: rgba(255, 255, 255, 0.5); margin-bottom: 16px; line-height: 1.6; }
.meta { display: flex; gap: 8px; margin-bottom: 16px; }
.user-credits {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.04);
border-radius: 6px;
.credits-label {
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
}
.credits-value {
color: #10b981;
font-size: 16px;
font-weight: 600;
&.insufficient {
color: #f56c6c;
}
}
}
.custom-fields { margin-bottom: 24px; h4 { color: #fff; margin-bottom: 8px; } .field-item { color: rgba(255, 255, 255, 0.7); margin-bottom: 4px; .field-name { color: rgba(255, 255, 255, 0.5); margin-right: 8px; } } }
.purchase-section { display: flex; gap: 12px; align-items: center; margin-top: 24px; }
@media (max-width: 768px) {
.detail-card {
padding: 16px;
}
.el-row {
flex-direction: column;
}
.el-col {
max-width: 100%;
flex: 0 0 100%;
}
.product-images {
height: 300px;
}
.carousel-image, .single-image {
height: 300px;
}
.image-placeholder {
height: 300px;
}
h1 {
font-size: 18px;
margin-top: 12px;
margin-bottom: 12px;
line-height: 1.3;
}
.price {
font-size: 20px;
margin-bottom: 12px;
}
.description {
font-size: 14px;
}
.purchase-section {
flex-wrap: wrap;
:deep(.el-input-number) {
width: 100%;
}
:deep(.el-button) {
flex: 1;
}
}
}
</style>